Documentation menu

Reliability

Uploads fail for boring reasons: a tunnel, a dropped VPN, a 502 from a storage edge node, a laptop lid closing mid-transfer. This guide covers what upup does about each on its own, and which knobs you get.

Two things worth separating up front, because their names sound alike and their jobs are not:

  • Retries happen within a page session — an attempt fails, upup waits and tries again.
  • Crash recovery happens across page sessions — the tab dies, and the file selection comes back on reload.

Retry policy

Every file is uploaded through a retry loop of its own. A failure on one file never cancels the others; the run collects failures and reports them at the end.

maxRetries is the number of extra attempts after the first one, so the total attempt count is maxRetries + 1. It defaults to 3 — four attempts in total.

tsx
<UpupUploader
    provider="aws"
    uploadEndpoint="/api/upload-token"
    maxRetries={3}
/>

Backoff math

Between attempts upup sleeps on a fixed exponential curve: 100ms * 2^attempt, where attempt is the zero-based index of the attempt that just failed. There is no jitter and no configurable multiplier.

AttemptResultWait before next
1fails100 ms
2fails200 ms
3fails400 ms
4fails— gives up

With the default maxRetries={3} the whole sequence adds about 700 ms of waiting before a file is declared failed. Raising maxRetries extends the curve (the fifth attempt waits 800 ms, the sixth 1600 ms, and so on).

What is actually retried

upup does not retry things that cannot plausibly succeed on a second try:

SituationRetried?Why
Network failure with no HTTP statusYesConnection reset, DNS, CORS-level failure
HTTP 5xx from storageYesServer-side transient
HTTP 4xx from storageNoExpired signature, denied, malformed — resending fails again
Any non-network error thrown by the pipelineYesTreated as potentially transient
The upload was aborted (cancel(), pause())NoAborting is intentional
isSuccessfulCall returned falseNoYour predicate made a deliberate ruling

When every attempt is exhausted, the file's last error surfaces on the upload-error event, and the batch as a whole rejects with an UpupUploadBatchError carrying an errors array of { file, error } pairs.

Manual retry

Automatic retries are not the only path. core.retry(fileId?) re-runs failed work on demand — pass a file id to retry one file, or omit it to retry every file that has not completed. It emits a retry event before the run starts.

ts
import { UpupCore } from '@useupup/core'

const core = new UpupCore({ uploadEndpoint: '/api/upload-token' })

core.on('upload-error', ({ file }) => {
    if (file) void core.retry(file.id)
})

core.resume() is the sibling for a paused run: it re-runs the upload for every file that has no storage key yet. Both throw if called after destroy().

The shipped UI already wires this. The button appears whenever the run reaches a failed state — it is not gated on maxRetries, which governs how many automatic attempts happen before that state, not whether the manual button is offered. It invokes retryUpload() regardless of protocol; only its label changes, reading Retry normally and Resume when resumable.protocol is 'multipart'. Both labels run the same code path — what differs is what that path does underneath: a multipart file with a live session continues from its last completed part, anything else starts over.

Do not confuse that button with the Resume control shown while a run is paused — a separate button on the genuine pause/resume path that calls core.resume(). The two share a label but not a behavior.

Custom success predicates: isSuccessfulCall

Some upload targets answer with something other than a plain 2xx, or return a 200 whose body signals a failure. isSuccessfulCall lets you make the call:

ts
const core = new UpupCore({
    uploadEndpoint: '/api/upload-token',
    isSuccessfulCall: response => {
        const body = response.body as { key?: string }
        return typeof body.key === 'string'
    },
})

Return false and the attempt is treated as a failure. It is not retried — a predicate that rejects is read as a deliberate verdict, not a transient glitch.

The response object is synthetic

isSuccessfulCall does not receive the raw HTTP response. upup builds the argument from the completed UploadResult, so status is always 200 and headers is always an empty object — only body carries real information (the UploadResult, with fields such as key, publicUrl, and etag). Write your predicate against body alone; branching on status or headers will not do what it looks like it does.

This also means the predicate runs only after a transfer succeeds at the transport level. It is a post-check on the result, not a replacement for HTTP error handling.

Concurrency: maxConcurrentUploads

maxConcurrentUploads caps how many files transfer at the same time. It defaults to 3. Remaining files wait in a queue and start as slots free up.

tsx
<UpupUploader uploadEndpoint="/api/upload-token" maxConcurrentUploads={3} />

Raising it helps when you are uploading many small files against a fast link; lowering it to 1 serializes the run, which is the friendlier choice for constrained mobile connections and for servers that rate-limit presign requests. This limit is about files. Within a single multipart file, parts have their own separate concurrency limit of 3.

Offline and online

upup listens for the browser's online and offline events and republishes them on the core bus as connection-online and connection-offline. Both carry an empty payload. The initial value is seeded from navigator.onLine, and the listeners are torn down when the uploader is destroyed.

ts
core.on('connection-offline', () => {
    console.warn('link down')
})

core.on('connection-online', () => {
    console.info('link back')
})

In the shipped UI this state drives a banner across the top of the panel while the browser reports itself offline.

Nothing pauses automatically

Going offline does not pause, cancel, or defer an in-flight run, and coming back online does not restart one. The connectivity state is observational — it updates the banner and emits the events, and that is all. In practice an upload attempted while offline simply fails its network request and is picked up by the ordinary retry loop described above.

If you want uploads to actually stop when the link drops, wire it yourself: call core.pause() on connection-offline and core.resume() on connection-online.

Crash recovery vs. multipart session resume

These two are genuinely different mechanisms with different storage and different scope. They are also complementary: together they are what makes a reload survivable, and neither one does the whole job alone.

Crash recoveryMultipart session resume
What it restoresThe file selection and its statusProgress within one large file
GranularityWhole filesIndividual parts
StorageIndexedDB (upup-crash-recovery)localStorage (upup_mp_* keys)
Survives a reloadYesYes
Turned on bycrashRecoveryresumable.persist (on by default)
RequiresNothingServer mode (serverUrl) + multipart

Crash recovery

Crash recovery snapshots your file selection to IndexedDB so a refresh, a tab crash, or an accidental navigation does not lose the user's work. Enable it with a single prop:

tsx
<UpupUploader uploadEndpoint="/api/upload-token" crashRecovery />

How it behaves:

  • Saving happens on every state change, as long as at least one file is selected. When a run completes successfully the snapshot is cleared — there is nothing left to recover.
  • Restoring is automatic in the shipped UI: when crashRecovery is on, the uploader attempts a restore as it initializes. Driving the core yourself, call await core.restoreFromCrashRecovery(), which resolves to true when a snapshot was found and applied.
  • Files that were mid-flight come back as PAUSED, not as uploading. The run is not resurrected; the user (or your code) decides whether to continue. What happens on continue depends on the upload path: a server-mode multipart file with a live session resumes at its last completed part, everything else uploads again from the beginning.
  • Two events fire on a successful restore: snapshot-restored ({ count, status }), then crash-recovery-restored (empty payload).

destroy() deliberately leaves the stored snapshot in place, so a normal unmount stays recoverable. Call core.clearCrashRecovery() when you genuinely want it gone.

In headless code the option also accepts an object, which is how you substitute your own persistence layer:

ts
const core = new UpupCore({
    uploadEndpoint: '/api/upload-token',
    crashRecovery: { storage: myPersistentStorage },
})

The storage object needs three async methods — get(key), set(key, value), and delete(key). Omit it and upup uses IndexedDB.

Degradation on SSR and in private mode

Crash recovery is best-effort by design. IndexedDB is absent during server-side rendering and can be blocked or quota-limited in private-browsing modes. In every one of those cases upup degrades quietly instead of failing the upload:

  • A failed save or clear is swallowed; uploads proceed normally.
  • A failed read is treated as "no snapshot", so a restore just returns false.
  • Failures are logged with console.warn in development only, so a dead durability opt-in is visible while you are building and silent in production.

The practical consequence: never treat a snapshot as guaranteed. Crash recovery is a nicety that improves the common case, not a durability contract.

Multipart session resume

In server mode with resumable: { protocol: 'multipart' }, upup checkpoints each file's upload to localStorage — fingerprinted by name, size, last-modified time, and type, holding the signed upload token, the object key, the part size, and the bytes uploaded so far, expiring after 24 hours, and treating corrupted JSON as "no session" rather than throwing.

On the next attempt at a file whose fingerprint matches a live session, the client presents the stored token to POST /multipart/resume, gets back the parts storage already holds plus a fresh token, validates their sizes, skips them, and uploads only the remainder. The Resume button on a failed multipart upload is no longer cosmetic: it continues the file rather than restarting it. The same machinery makes in-session pause() / resume() and the automatic retry loop continue mid-file, and refreshes an upload token that expires during a long transfer.

It is on by default (persist: true); persist: false restores the older behavior, where any failure aborts the server-side upload and the next attempt starts from byte zero.

Set an S3 lifecycle rule for incomplete uploads

Keeping parts for a later resume is the point, and it has an operational cost: a failed or abandoned upload is no longer aborted server-side, and parts nobody resumes are never cleaned up by upup. S3 bills for them.

Configure an AbortIncompleteMultipartUpload lifecycle rule on the bucket with a 1–7 day expiry. Every S3-compatible provider supports it, MinIO included.

Resume is best-effort in the same way crash recovery is. It falls back to a fresh upload — never to a failure — when the fingerprint no longer matches (a pipeline-transformed file, a Blob with no name), when localStorage is evicted or unavailable, when the session belongs to a different serverUrl, when the server has resume disabled or is too old to have the route, or when the provider no longer holds the upload. The full list, and what each case costs you, is in Cross-reload resume.

For a client-mode (uploadEndpoint) deployment, where multipart cannot run at all, tus remains the resumable option.

A note on fastAbortThreshold

fastAbortThreshold is accepted by the core options and forwarded internally, but nothing reads it as of v3.3 — setting it has no effect on behavior. It is documented here only so you do not spend an afternoon tuning a value that does nothing. Use maxRetries to control how long upup persists on a failing file.

For most applications the defaults are the right starting point, and the one line worth adding is crash recovery:

tsx
<UpupUploader provider="aws" uploadEndpoint="/api/upload-token" crashRecovery />

Reach past that when you have a specific reason: lower maxConcurrentUploads for mobile-heavy traffic or a rate-limited presign endpoint, raise maxRetries for genuinely flaky networks, and add isSuccessfulCall only when your upload target reports failure in a response body rather than a status code.

Next steps

  • Resumable Uploads — multipart and tus protocol configuration, including the thresholds that decide which strategy a file uses.
  • Events — the full payload reference for upload-error, retry, connection-online / connection-offline, snapshot-restored, and crash-recovery-restored.
  • Error Handling — the UpupError taxonomy and the retryable flag behind the retry decisions above.
  • Error Monitoring — routing these failures into Sentry or another tracker.