A progress bar is easy until the connection disappears just after the server saves a chunk. The browser does not know whether that chunk succeeded. A resumable protocol resolves this by asking the server what it has committed, rather than guessing from the last local percentage.
This walkthrough builds the client-side planning model for an existing chunk upload API. The downloadable, dependency-free planner was tested on Node.js 20.11.1. It is not an upload server: authorization, storage, checksums and durable acknowledgement belong to the API described below.
Give the upload an identity
Create an upload session before sending file data. Store a server-generated upload ID, account owner, total bytes, chunk size, immutable file metadata and expiry. Require authentication for creation, status, chunk writes, completion and cancellation. An upload ID alone must not grant access to another account's staged files.
A useful API contract is:
POST /api/uploadsreserves quota and returns upload ID and chunk size.PUT /api/uploads/:id/chunks/:indexaccepts one raw bounded chunk.GET /api/uploads/:idreturns committed indices and the session state.POST /api/uploads/:id/completevalidates completeness and assembles.DELETE /api/uploads/:idmarks the session cancelled and queues cleanup.
Duplicate writes must be idempotent only when their length and digest match. A duplicate with different bytes is a conflict, not permission to replace committed data.
Separate sent from confirmed
The downloaded planner returns the remaining byte ranges and the number of server-confirmed bytes. It handles a short final chunk and rejects invalid or duplicate indices in a server manifest.
add-resumable-uploads-with-progress-and-cancellation/
├── README.md
├── plan.mjs
└── test.mjsconst status = await fetch("/api/uploads/" + uploadId, {
cache: "no-store", credentials: "same-origin"
});
if (!status.ok) throw new Error("Cannot read upload status");
const manifest = await status.json();
const plan = planUpload(file.size, manifest.chunkSize, manifest.committed);
for (const chunk of plan.pending) {
const response = await fetch(
"/api/uploads/" + uploadId + "/chunks/" + chunk.index,
{
method: "PUT",
body: file.slice(chunk.start, chunk.end),
signal: controller.signal
// Add the application's CSRF header here.
}
);
if (!response.ok) throw new Error("Chunk was not acknowledged");
}Before this excerpt runs, verify that the reselected file matches the session. Size and last-modified time are useful hints, not cryptographic identity. A manifest of content digests prevents silently mixing files that happen to share a name and size.
Show confirmed progress as the stable baseline. If you want smooth in-flight progress, XMLHttpRequest upload events can supply bytes sent for the current request. Label that portion as transferring; it is not committed storage. The relevant browser API is XMLHttpRequest.upload.
Define pause and cancellation differently
Pause aborts the current request but leaves committed chunks available until session expiry. Cancellation marks the session terminal on the server and schedules deletion. A browser abort alone does not prove the server stopped, so cancellation must be coordinated with in-flight writes.
On a timeout, reconnect and request the status manifest. Do not automatically create a fresh session or mark the uncertain chunk complete. Use bounded retries with jitter for temporary network failures. A rejected authorization, expired session or conflicting chunk needs user action, not endless retries.
Allow only one active uploader per session in a tab, and make the server tolerate multiple tabs anyway. Disable duplicate completion requests in the UI, but make completion idempotent on the server.
Verify recovery, not only percentages
Run node --test test.mjs in the download. The tests cover short final chunks, empty files, invalid dimensions and malformed manifests. They validate the planner, not network persistence.
Integration testing should interrupt a request before upload, during transfer and after the server commits but before the response reaches the browser. After reconnecting, the manifest should describe the committed state in all three cases. Test cancellation racing with the last chunk, and completion racing with another completion.
If progress jumps backwards after reconnecting, the old display probably confused sent bytes with committed bytes. If resumed content is corrupt, investigate file identity and conflicting chunk retries. Continue with iPhone suspension recovery, or discuss a custom Next.js upload workflow.
