Skip to main content

Large Files

6 min read

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.

lib/uploads.ts

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 thresholdOver the threshold
Transportone presigned object PUTone presigned PUT per part
When the object existsthe moment the last byte landswhen the upload is completed at end
canPausefalsetrue while uploading
A failed chunkthe whole PUT is sent againonly that part is sent again
Ceiling~5 GiBno practical limit
A tab that dies mid-uploada whole stored object no callback acceptedparts, 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#

lib/uploads.ts
lib/videos.ts

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: false on a body over the single-PUT ceiling of about 5 GiB throws too_large.
  • On bucket.put(), allowOverwrite: false and ifUnchanged are 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 sizePart sizeParts
20 MB5 MiB4
200 MB5 MiB39
2 GB8 MiB239
20 GB77 MiB248

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#

app/upload.tsx

Every record carries the same fields on either side of the threshold:

FieldMeaning
loadedbytes of parts that landed, plus bytes on the wire right now
totalthe file's size
percentfloor(loaded / total * 100), capped at 99 until the status is done
pendingnot settled: queued, uploading, finishing or paused
stalledevery 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#

app/upload.tsx
SituationcanPause
single PUTfalse
queuedfalse
uploading, multiparttrue
pausedtrue
finishingfalse
done, error, canceledfalse

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#

app/upload.tsx

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 runs onBeforeUpload.

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#

lib/backup.ts

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.


Next steps#