Resumable Uploads
upup supports two resumable protocols. Both are opt-in through the single
resumable prop, and they are mutually exclusive — a resumable value is
either a multipart config or a tus config, never both.
| Multipart | tus | |
|---|---|---|
| Upload target | serverUrl (@useupup/server) | tus endpoint |
| Splits large files into parts | Yes | Yes (when chunkSizeBytes is set) |
| Survives a page reload | Yes (localStorage session) | Yes (fingerprint URL storage) |
| Extra dependency | None | tus-js-client (optional peer) |
| Storage | Your S3-compatible bucket | Whatever the tus server writes to |
Pick multipart if your bytes already go to S3 through @useupup/server: large
files are split into parallel, individually-retried parts, and an interrupted
upload picks up at the last completed part after a reload, a tab close, or a
crash — see Cross-reload resume.
Pick tus when server-mode multipart is not on the table: a client-mode
uploadEndpoint deployment (multipart refuses to run there at all), a non-S3
destination, or an existing tus server you already operate. tus also resumes
without @useupup/server in the path, because the tus protocol — not upup —
owns the offset bookkeeping.
Multipart
Multipart splits a file into parts, signs each part individually, uploads
several parts at a time (three by default), and then asks the server to
assemble them. Core routes a file
to the multipart strategy when its size is at or above thresholdBytes;
everything smaller takes the ordinary single-PUT presigned path.
<UpupUploader
mode="server"
serverUrl="/api/upup"
resumable={{
protocol: 'multipart',
thresholdBytes: 5 * 1024 * 1024,
chunkSizeBytes: 8 * 1024 * 1024,
}}
/>Multipart requires server mode
Multipart needs a credential source that can init, sign parts, complete, and
abort. Only serverUrl (backed by @useupup/server) provides those. A
client-mode uploadEndpoint supplies presigned single-PUT URLs only, so
pairing it with protocol: 'multipart' throws a UpupConfigError
("CredentialStrategy must implement multipart methods") the moment the
uploader is constructed.
Options
Seven fields, all optional.
| Option | Type | Default | What it does |
|---|---|---|---|
thresholdBytes | number | 5242880 (5 MiB) | Files at or above this size use multipart; smaller files use a single presigned PUT. |
chunkSizeBytes | number | 5242880 (5 MiB) | Requested part size. A fallback only — see Part size, the server decides the real value. |
persist | boolean | true | Keep a per-file session in localStorage so an interrupted upload resumes at the last completed part — see Cross-reload resume. false restores the older behavior: any failure aborts the server-side upload and the next attempt starts from byte zero. |
retryDelays | number[] | [0, 1000, 3000, 5000] | Backoff schedule, in milliseconds, for retrying a transiently failed or stalled part — the same vocabulary tus uses. The array's length is the retry budget; [] disables part retries. See Part retries. |
partTimeoutMs | number | 180000 (3 min) | Watchdog budget for each phase of a part attempt (sign, source read, PUT): a phase that neither succeeds nor fails within its window is aborted and retried instead of hanging the upload forever — see Part retries. |
maxConcurrentParts | number | 3 | How many parts of a single file upload at once — see Part concurrency. |
autoResume | boolean | false | Continue a crash-restored upload automatically instead of waiting for the Resume click — see The full reload story. |
Part concurrency
maxConcurrentParts sets how many parts of one file are in flight at the same
time. Three is a deliberate middle: raising it fills a high-bandwidth link
faster, and costs memory and sockets, because every in-flight part holds its
own chunk of the file in memory — parts up to 16 MiB are read into an
ArrayBuffer before the PUT (so each in-flight part costs up to
min(partSize, 16 MiB) of resident bytes, plus the copy XHR makes while
sending), and larger parts stream from the file. Lower it when your users upload from
constrained devices or you are being rate-limited; raise it when big files
crawl on connections you know are fast. Values below 1 are clamped to 1.
This is parts within one file. maxConcurrentUploads is the other axis — how
many files upload in parallel — and the two multiply: four files at three parts
each is twelve requests in flight.
Part retries and the stall watchdog
Every part attempt runs under a watchdog, applied to each of its phases
separately: the sign call, the source read (for parts small enough to be
materialized), and the PUT. For the PUT it is an inactivity timer, not a
deadline on the whole transfer: as long as bytes keep moving, a part is never
timed out, no matter how slow the link or how large the part. The watchdog only
fires after partTimeoutMs of no upload progress at all — a genuinely
stalled or dead connection — at which point the attempt is aborted and retried
rather than hanging for the lifetime of a half-open TCP socket. The sign call
and the source read, which have no progress to measure, are each bounded by the
same timeout as a plain deadline — so the worst case for one attempt is up to
three windows, not one. The 3-minute default suits almost everyone; raise it only if your
users sit behind proxies that buffer a whole part before forwarding it (so no
progress is visible mid-part), and lower it if you want a dead connection
declared failed sooner.
A failed attempt is retried on the retryDelays schedule when the failure is
transient: a network error, a watchdog timeout, an HTTP 429, or any 5xx.
Definitive rejections — a 403 forged-token refusal, a 400 — are not
retried and fail the file immediately. Retries wait out their delay, but a
pause() or cancel cuts the wait short rather than blocking on it.
Part retries sit inside one upload run; maxRetries governs whole-run
retries around it. A part that recovers on retry never surfaces as a file
failure at all.
Server routes
Multipart is a five-call lifecycle. All five are POST, all five are
implemented by @useupup/server under your serverUrl base path:
POST /multipart/init
POST /multipart/sign-part
POST /multipart/complete
POST /multipart/abort
POST /multipart/resume/multipart/init returns { key, uploadId, partSize, expiresIn, token }. The
token is an HMAC-signed capability bound to the key, upload id, and a byte
envelope; the client replays it on every subsequent call, and an init response
without one is rejected client-side as a misconfigured or outdated server.
/multipart/resume takes that token — and nothing else — and answers with the
parts storage already holds plus a freshly-signed replacement token. It is what
makes a reload survivable, and it is also how the client refreshes a token that
outlived its one-hour expiry mid-upload.
/multipart/abort is issued on an explicit cancel. It is not issued on a
plain failure while persist is on, because those parts are exactly what the
next attempt resumes from — see Cross-reload resume for
what that means for your bucket.
Request and response shapes for all five routes are documented in the server HTTP reference.
Part size, the 5 MiB floor, and the 10,000-part cap
S3 imposes two hard limits that shape every multipart upload: a part must be at
least 5 MiB (except the last one), and a single upload may have at most
10,000 parts. @useupup/server reconciles both before answering
/multipart/init:
- Start from the requested chunk size, or 5 MiB if none was given.
- Raise it to 5 MiB if it is below the floor.
- Raise it again to
ceil(fileSize / 10000)if the file would otherwise need more than 10,000 parts.
The returned partSize wins — core uses the server's value and falls back to
your chunkSizeBytes only if the server omits it. In practice that means the
part size in server mode is decided by the server, not by the browser.
The auto-bump in step 3 is why there is no hard file-size ceiling: a part size
of P covers files up to P × 10000, and anything larger simply gets a bigger
part size.
| Part size | Files covered without an auto-bump |
|---|---|
| 5 MiB (the floor) | up to ~48.8 GiB |
| 8 MiB | up to ~78.1 GiB |
| 16 MiB | up to ~156.3 GiB |
| 64 MiB | up to ~625 GiB |
Larger parts mean fewer signing round-trips but coarser progress and more bytes to re-send when one part fails. The 5 MiB default is the right choice unless you routinely move files in the tens of gigabytes.
Cross-reload resume
Multipart parallelizes and isolates failures — a failed part is retried on its
own rather than taking the whole file down with it. On top of that, with
persist on (the default) an interrupted upload continues from the last
completed part instead of restarting, across all four ways an upload gets
interrupted:
| Interruption | What happens with persist on |
|---|---|
| A part's request fails | That part is retried; the rest of the file is untouched |
pause() then resume() | Continues mid-file — completed parts are not re-uploaded |
| Automatic retry after a failed run | Re-enters the upload and resumes from the persisted session |
| Page reload / tab close / crash | Resumes once the file is selected again, or restored by crashRecovery |
The mechanism is a small session record per file, held in localStorage under
the upup_mp_ prefix, keyed by a fingerprint of name:size:lastModified:type,
expiring after 24 hours. It holds the upload token, the object key, the part
size, and the bytes uploaded so far — never the S3 uploadId, which stays
sealed inside the signed token.
On the next upload of a file whose fingerprint matches a live session, the
client presents the stored token to POST /multipart/resume. The server hands
back the parts storage already holds — each with its byte size — plus a
freshly-signed token. The client then:
- Validates every returned part's size: each part except the file's true final
one must be exactly
partSize, the final one must be the exact tail, and a part reported without a size is not trusted. Any mismatch discards the session and starts fresh. - Compares content hashes when both sides have one. With
checksumVerificationon, the pipeline writesmetadata.originalContentHashand the session records it — which catches a file whose bytes changed while its name, size, and timestamp did not. Without it, the fingerprint alone decides. - Skips the stored part numbers, pre-fills the progress bar at the resumed byte
offset, and uploads only what is missing. If every part is already present it
goes straight to
/multipart/complete.
Resume failures are never fatal: a 404, a 403, an old server without the
route, or a plain network error all fall through to an ordinary
/multipart/init and a fresh upload. A resume that cannot happen costs
bandwidth, not the upload.
Configure an S3 lifecycle rule for incomplete uploads
Because persist defaults to on, a failed or abandoned upload no longer
aborts itself server-side — its parts are deliberately kept so the next
attempt can resume from them. Parts that are never resumed are never
cleaned up by upup, and S3 bills for them.
Set an AbortIncompleteMultipartUpload lifecycle rule on the bucket, with
an expiry of 1–7 days. Every S3-compatible provider supports it, MinIO
included. This is the one piece of operational setup cross-reload resume
asks of you; without it, incomplete parts accrue storage cost silently.
The full reload story: persist + crashRecovery
The two features cover different halves of a reload and are designed to compose. Turn both on:
<UpupUploader
mode="server"
serverUrl="/api/upup"
resumable={{ protocol: 'multipart' }}
crashRecovery
/>After a reload, crashRecovery restores the file list from IndexedDB — blobs
included, with the file's name, size, lastModified, and type preserved
byte-exactly, which is what keeps the fingerprint valid — and marks the
interrupted files PAUSED. Calling resume() (the shipped UI's resume button)
then re-enters the upload, the persisted session is found, and the transfer
picks up mid-file.
The explicit Resume click is the industry default — closing a tab is as often
"cancel" as "oops", and silently re-uploading a file the user meant to abandon
is the worse failure. If your product knows better (a backup tool, a sync
client), set autoResume: true in the multipart config and a crash-restored
upload continues by itself, no click required.
Without crashRecovery the session still works, but the user has to re-select
the same file themselves; nothing on the page remembers it was there.
What still starts over
The fingerprint is a heuristic — the same model tus-js-client uses — and
localStorage is not durable storage. These cases silently fall back to a
fresh upload, which is the safe direction to fail in:
- A
Blobrather than aFile. Blobs have no name orlastModified, so they cannot be fingerprinted and are never persisted. - A file the pipeline transformed (compression, HEIC conversion). The
rewritten file carries a new
lastModified, so it simply never matches an older session. - A different server. Sessions record the
serverUrlthey were created against; a session saved against one server is never resumed against another. - Evicted or unavailable
localStorage. Safari's ITP evicts storage after a period of inactivity, and private-browsing modes may refuse writes outright. The store returns "no session" rather than throwing. - A session older than 24 hours, or an upload past the server's resume
window (
multipartResumeWindowSeconds, 24 hours by default — see the server HTTP reference). - An upload the provider no longer has — already completed, aborted, or
reaped by your lifecycle rule. The server answers
404 NOT_FOUNDand the client drops the session.
Uploads longer than the token's lifetime
The upload token expires an hour after init. When a sign-part or complete
call comes back 403 with code expired, the client refreshes through
/multipart/resume once, swaps in the new token, and retries the call — the
three concurrent part uploaders share one in-flight refresh rather than racing.
A slow multi-hour upload therefore completes without the integrator configuring
anything.
Turning it off
persist: false on the client restores the pre-3.2 behavior exactly: no session
is written, and any failure or abort issues a best-effort /multipart/abort so
no orphaned upload is left behind. This is the switch that turns off parts
retention.
Setting multipartResumeWindowSeconds: 0 on the server is a different lever:
it unregisters the resume route, so cross-reload resume stops working (clients
see an old server and fall back to a fresh upload gracefully). It does not by
itself stop clients from retaining parts on failure — a client still running the
default persist: true keeps skipping the abort, because it cannot know the
server disabled the route until it tries. To both disable resume and reclaim the
old auto-abort, pair multipartResumeWindowSeconds: 0 with persist: false on
the client — or, as always, rely on the AbortIncompleteMultipartUpload
lifecycle rule, which is the durable backstop either way.
Explicit cancellation always cleans up regardless of persist: cancel(),
removeFile(), and removeAll() abort the server-side upload and clear the
persisted session on a best-effort basis. A user who cancels does not leave
parts behind.
tus
tus talks directly to an external tus-compatible service — your own
tusd, Transloadit, or any other implementation of
the protocol. @useupup/server is not involved.
tus-js-client is an optional peer dependency, kept out of core's mandatory
path so projects that never use tus don't pay for it. Install it yourself:
npm i tus-js-clientThe error you get without it
The import is dynamic, so a missing tus-js-client fails at upload time
rather than at construction time: the first tus upload rejects with a
UpupError carrying code UPLOAD_FAILED and the message "Resumable (tus)
uploads require the optional dependency 'tus-js-client'. Install it: npm i
tus-js-client".
<UpupUploader
resumable={{
protocol: 'tus',
endpoint: 'https://uploads.example.com/files',
chunkSizeBytes: 8 * 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000],
headers: { Authorization: `Bearer ${token}` },
metadata: { album: 'summer-2026' },
}}
/>Options
Nine fields. endpoint is required; every other field is passed through to
tus-js-client only when you set it, so an omitted field keeps that library's
own default.
| Option | Type | Default | What it does |
|---|---|---|---|
endpoint | string | — | Required. The tus creation endpoint. Also counts as your upload target. |
chunkSizeBytes | number | Infinity (one request) | Bytes per PATCH request. Set it to get real chunking — and resumability that survives a mid-upload failure. |
chunkSize | number | — | Deprecated alias of chunkSizeBytes, still honored. chunkSizeBytes wins when both are set. |
retryDelays | number[] | [0, 1000, 3000, 5000] | Backoff schedule, in milliseconds, for automatic retries. Pass [] to disable retrying. |
storeFingerprintForResuming | boolean | true | Store the upload URL in localStorage, keyed by a file fingerprint, so a later upload of the same file resumes instead of restarting. |
removeFingerprintOnSuccess | boolean | false | Delete that stored entry once the upload succeeds. Turn it on if users re-upload the same filename often. |
headers | Record<string, string> | — | Extra headers on every tus request — the usual place for an auth token. |
metadata | Record<string, string> | — | Upload-Metadata entries. Merged over the filename and filetype upup sets, so a key of either name overrides upup's value. |
parallelUploads | number | 1 | Split the file into N partial uploads sent concurrently, then concatenate server-side. Requires the tus Concatenation extension. |
A completed tus upload reports the tus upload URL as the file's storage key.
parallelUploads and resuming interact
tus-js-client stores fingerprints for whole uploads, not for the partial
uploads created by parallelUploads. With parallelUploads above 1 the
partial uploads are not fingerprinted, so a fresh page load restarts them.
Leave it at 1 if resume-after-reload matters more than raw throughput.
Choosing exactly one upload target
uploadEndpoint, serverUrl, and a tus endpoint are three upload targets and
you may configure exactly one. Two or more throws a UpupConfigError with code
AMBIGUOUS_UPLOAD_TARGET at construction time.
When a tus config is present it takes precedence for every file regardless of
size — thresholdBytes is a multipart-only concept and has no tus equivalent.
Crash recovery is a different feature
Do not confuse resumable uploads with crash recovery. They solve neighboring problems, are enabled independently, and — for multipart — are two halves of the same reload story:
- Resumable uploads are about the bytes of one in-flight transfer — parts, retries, and (for tus) reattaching to a partly-uploaded object on the server.
- Crash recovery (
crashRecovery) is about the uploader's own state. It writes a snapshot to IndexedDB holding the file list — each file's blob plus its id, name, type, source, status, metadata, and storage key if it got one — along with the overall uploader status, and restores that after an unexpected unmount or reload.
The snapshot carries no partial upload progress: no byte offsets, no part list, no multipart upload id. Crash recovery gives the user their file list back; it does not pick a transfer up mid-stream. Conversely, a persisted multipart session does not remember which files were selected — it only knows how to continue one, once that file is in front of it again.
Turned on together in server mode they cover the whole reload: crash recovery restores the file, the multipart session restores the transfer. See Cross-reload resume above, and the reliability guide for the side-by-side comparison.