Files under the multipart threshold go up as a single presigned PUT. Files over it are cut into parts, which is what makes pause, resume and per-part retry possible. This page covers where the threshold sits, what changes on each side, and how the browser runs a multipart upload.
The multipart threshold#
The default is 16 MB decimal, 16,000,000 bytes. A 16,000,000 byte body is a single PUT; 16,000,001 is multipart. Sizes are decimal everywhere. bucket.put() on your server and a direct browser upload use the same threshold.
| Under the threshold | Over the threshold | |
|---|---|---|
| Transport | one presigned object PUT | one presigned PUT per part |
| When the object exists | the moment the last byte lands | when the upload is completed at end |
canPause | false | true while uploading |
| A failed chunk | the whole PUT is sent again | only that part is sent again |
| Ceiling | ~5 GiB | no practical limit |
| A tab that dies mid-upload | a whole stored object no callback accepted | parts, invisible to list(), reaped by abortStaleMultipartUploads() |
The row that matters most is when the object comes into existence. Under the threshold the object is stored the moment the last byte lands, before your route has been told anything. Over the threshold nothing exists at the path until the upload is completed. See Abandoned uploads for what that means when a tab closes.
Parts are not free. A single PUT is one round trip; a multipart upload is three plus one per part.
Changing the threshold#
multipart takes a size, true or false. A size becomes the threshold for that handler, route or write; true always uses parts; false never does. On a handler it is the default for every route, and a route overrides it.
multipart: falseon a body over the single-PUT ceiling of about 5 GiB throwstoo_large.- On
bucket.put(),allowOverwrite: falseandifUnchangedare single-PUT only; see Conditional writes. - An unparseable size (
'100 megs') throws at startup, where the option is written.
multipart: true costs no extra browser requests, only two extra round trips between your server and storage.
Part sizing#
| File size | Part size | Parts |
|---|---|---|
| 20 MB | 5 MiB | 4 |
| 200 MB | 5 MiB | 39 |
| 2 GB | 8 MiB | 239 |
| 20 GB | 77 MiB | 248 |
The part size is derived from the file size, never configured. The SDK aims at roughly 250 parts, with a floor of 5 MiB. A failed part costs at most one part's worth of re-sent bytes.
Four parts upload at once per file, and the whole page shares a cap of six requests in flight. Part URLs are presigned in batches as the upload needs them.
Progress and status#
Every record carries the same fields on either side of the threshold:
| Field | Meaning |
|---|---|
loaded | bytes of parts that landed, plus bytes on the wire right now |
total | the file's size |
percent | floor(loaded / total * 100), capped at 99 until the status is done |
pending | not settled: queued, uploading, finishing or paused |
stalled | every in-flight part is waiting on a backoff |
percent is capped at 99 because 100 means stored, not sent. It stays there through status finishing, while your onUploadComplete runs. Render that state so the bar does not look stuck.
A failed part's bytes are subtracted from loaded again, because they were never stored.
Pause and resume#
| Situation | canPause |
|---|---|
| single PUT | false |
queued | false |
uploading, multipart | true |
paused | true |
finishing | false |
done, error, canceled | false |
A single PUT cannot pause, since stopping it would throw its bytes away. While queued the SDK does not yet know which kind the upload is. From finishing there is nothing left to hold back.
Pause stops the queue, not the parts in flight. A part that has already sent bytes finishes and keeps them. Only parts that have sent nothing go back to the queue. resume() picks up from there and nothing that landed is sent again.
Resuming after a reload#
A closed tab is not a canceled upload. When an upload starts, the client remembers it in localStorage, keyed by the route, the file name, the size and its last-modified time.
The user picking the same file again resumes it. There is no API to call. On a match the SDK asks your server which parts landed and sends only the missing ones. onBeforeUpload does not run again, so a row it inserted is not written twice.
- The browser is not trusted about what landed. Your server reads it back from storage.
- A mismatch is a fresh upload, never an error. A different file, a different size, or an expired upload starts over.
- A single PUT has nothing to resume. The file is simply sent again.
Resume is best effort. Private mode or a browser with no localStorage means no resume, never a failed upload.
Retries#
Parts are retried on a network failure and on 408, 429, 500, 502, 503 and 504, with an exponential backoff that honors Retry-After. A 403 is treated as an expired signature first: the SDK asks your route for fresh URLs and sends the part again. Only a freshly signed URL refused a second time is reported as signature_mismatch.
When the retry budget runs out the record settles as error with a BlobError. retry() runs the same upload again from the parts that landed, and replaces done with a fresh promise.
Retry budgets
- Most failures get eight attempts.
- A dropped connection gets twenty. A phone changing cell towers outlives a shorter budget.
- A request that failed without sending a byte gets three. That is almost always CORS, which retrying does not fix, and the error says so.
- A part that goes 60 seconds with no progress is treated as failed. That is silence, not total time, so a slow large part is fine.
- Calls to your own route get three attempts, except
begin, which is never retried because it runsonBeforeUpload.
Cancel#
cancel() aborts the parts in flight and tells your route to abort the multipart upload, so the parts stop costing storage. For a single PUT, it deletes the object if the bytes already landed and they belong to this upload.
From status finishing only the local task is canceled. Your onUploadComplete may already have written a row, so deleting the object at that point could remove a file your app thinks it has.
Large writes from the server#
bucket.put() uses the same threshold and the same multipart option. Over the threshold it streams the body one part at a time, and any failure aborts the upload rather than leaving billed parts behind. An unknown-length stream with neither size nor maxSize throws length_required; see Streams and unknown lengths.