# Large files & multipart (/docs/large-files)



Large files upload as **multipart**: the file is split into parts that upload
concurrently and directly to your bucket, then the API stitches them together. You
do not configure when this happens or wire anything up — it is automatic.

## When multipart kicks in [#when-multipart-kicks-in]

A file at or above **100 MB** uploads as multipart; anything smaller is a single
PUT. The threshold is decided server-side by [`createUpload`](/docs/server-client#createupload),
which returns either a `single` or `multipart` upload descriptor. Parts are
**16 MB** each.

| File size     | Upload mode | Parts                             |
| ------------- | ----------- | --------------------------------- |
| Under 100 MB  | `single`    | One presigned PUT.                |
| 100 MB and up | `multipart` | One presigned PUT per 16 MB part. |

## How it flows [#how-it-flows]

1. `createUpload` initiates a multipart upload on the bucket and returns a
   presigned PUT URL for each part, plus the `uploadId` and `partSize`.
2. The uploader sends parts **concurrently** — 6 at a time by default
   ([`partConcurrency`](/docs/browser-uploads#concurrency)) — straight to the
   bucket, slicing the file locally per the `partSize`.
3. Each part returns an `ETag`. The uploader collects them and passes them to
   `complete`.
4. The API completes the multipart upload from the part ETags and confirms the
   object exists.

The byte path is still browser → bucket; the API only signs and stitches.

## Tuning throughput [#tuning-throughput]

For very large files, raising `partConcurrency` can saturate more of the
available bandwidth. For a batch of large files, balance it against `concurrency`
(files in parallel) so you do not open too many sockets at once.

```ts
await uploadFiles(files, {
  concurrency: 2, // 2 large files at a time
  partConcurrency: 10, // 10 parts each → 20 in-flight PUTs total
});
```

Every part gets the same automatic [retry with backoff](/docs/browser-uploads#retries--backoff),
so a single flaky part does not fail the whole file. A fired `signal` cancels all
in-flight parts and pending retries cleanly.

## CORS [#cors]

For multipart to work from the browser, the bucket's CORS config must **expose the
`ETag` response header** to the page's origin — the uploader reads each part's
`ETag` to complete the upload. If `ETag` is not exposed, the engine throws
[`NO_ETAG`](/docs/errors).

A working CORS rule allows `PUT` from your origin and exposes `ETag`:

```json
[
  {
    "AllowedOrigins": ["https://your-app.example.com"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 86400
  }
]
```

<Callout title="Single uploads need CORS too">
  The same `PUT`-from-your-origin rule is what lets the browser upload directly to the bucket at all
  — multipart just additionally needs `ETag` exposed. Configure CORS once on the bucket and both
  modes work.
</Callout>

## Single-file size limit [#single-file-size-limit]

Multipart raises the practical ceiling on file size, but the &#x2A;*plan's
`maxFileBytes`** still applies — `createUpload` rejects a file over it with
`FILE_TOO_LARGE` (`413`) before any URL is minted. See
[Quotas & plan limits](/docs/limits).

## Abandoned uploads [#abandoned-uploads]

A reserved-but-never-completed multipart upload leaves a `pending` row. A
scheduled job sweeps `pending` rows older than an hour, deleting any orphaned
bucket object and marking the row deleted — so an interrupted large upload never
lingers or counts against your quota.
