# Upload engine (/docs/upload-engine)



`uploadToPresigned` is the raw engine underneath everything else. It takes a file
and a presigned [upload descriptor](/docs/server-client#createupload) and sends
the bytes to the bucket: a single PUT for small files, parallel multipart for
large ones, with retry, backoff, progress, and abort built in.

Reach for it when you broker the create/complete handshake yourself — for example
a dashboard that already has its own authenticated API client and just needs to
push the bytes. Most apps should use [`upload`](/docs/server-client#upload) (server)
or [`uploadFiles`](/docs/browser-uploads) (browser) instead.

## Signature [#signature]

```ts
uploadToPresigned(
  file: Blob,
  upload: UploadDescriptor,
  options?: {
    onProgress?: (progress: UploadProgress) => void;
    concurrency?: number;   // parallel parts (multipart). Default 6.
    signal?: AbortSignal;
    retries?: number;        // default 3
    retryDelay?: number;     // base backoff ms, default 300
    maxRetryDelay?: number;  // cap ms, default 10000
  },
): Promise<{ parts?: CompletedPart[] }>;
```

The `upload` descriptor is the `upload` field returned by `createUpload`:

```ts
type UploadDescriptor =
  | { mode: "single"; url: string; method: "PUT" }
  | {
      mode: "multipart";
      uploadId: string;
      partSize: number;
      parts: { partNumber: number; url: string }[];
    };
```

For a `single` descriptor it returns `{}`. For a `multipart` descriptor it returns
`{ parts }` — the collected part ETags, sorted by part number — which you pass back
to `complete`.

## Brokering the handshake yourself [#brokering-the-handshake-yourself]

```ts
import { uploadToPresigned } from "s3delivery";

// 1. Reserve via your own authenticated API.
const created = await myApi.createUpload({ filename, contentType, size });

// 2. Push the bytes straight to the bucket.
const { parts } = await uploadToPresigned(file, created.upload, {
  onProgress: (p) => setProgress(p.percent),
});

// 3. Confirm.
await myApi.complete(created.file.id, {
  uploadId: created.upload.mode === "multipart" ? created.upload.uploadId : undefined,
  parts,
});
```

## Retry and abort model [#retry-and-abort-model]

This is the engine the `retries` / `retryDelay` / `maxRetryDelay` and `signal`
options on every higher-level uploader flow into.

* **What is retried.** Transient failures only: network errors and `5xx` / `429`
  storage responses. Permanent failures (`4xx` other than `429`) and aborts
  propagate immediately.
* **Backoff.** Exponential with full jitter — each retry waits a random time in
  `[0, min(maxRetryDelay, retryDelay * 2^n)]`, up to `retries` attempts.
* **Progress resets between attempts.** A failed partial attempt's `loaded` count
  is reset before the next try, so aggregate progress never double-counts.
* **Aborts are clean.** A fired `signal` cancels in-flight parts and pending
  retries and rejects with an `S3DeliveryError` whose code is `ABORTED`.

## Runtime behaviour [#runtime-behaviour]

In the browser the engine uses `XMLHttpRequest` (the only way to observe real
upload progress). In Node, Bun, Deno, and Cloudflare Workers it uses `fetch`, and
progress fires once on completion.

<Callout type="warn" title="Multipart needs the ETag exposed">
  Each multipart part must return its `ETag`. If your bucket's CORS config does not expose the
  `ETag` response header to the browser, the engine throws `NO_ETAG`. Make sure the bucket exposes
  `ETag` for cross-origin `PUT`s — see [Large files & multipart](/docs/large-files#cors).
</Callout>
