October 8, 2026
How We Made File Uploads Resume Instead of Restart
A simple way to save bandwidth when large uploads fail.

By Shivankar Mehta
2 min read
A 500 MB PDF upload stops at 180 MB. Instead of sending all 500 MB again, the browser sends the remaining 320 MB.
How it works
- Keep the partial file on the server.
- Ask the status endpoint how many bytes are saved.
- Send the rest with
file.slice(offset). - Include that offset in the
X-Upload-Offsetheader. - The server checks the offset, then appends the bytes.
The offset comes from the file size on disk. The browser's "bytes sent" counter is not reliable: some bytes may never reach the server.
Surviving a server restart
We save the upload ID, expected size, checksum, and chunk size on disk alongside completed parts. After a restart, the server checks which parts remain, and the browser uses the same upload ID to send only the missing ones.
For example, if parts 1โ6 of a 10-part file are saved before a crash, only parts 7โ10 need uploading. This requires the same persistent disk to survive.
Save the session, then read its progress after a restart:
const session = { size, sha256, chunkSize };
const name = `_session.${randomUUID()}.tmp`;
const temporary = path.join(dir, name);
const target = path.join(dir, "_session.json");
await writeFile(temporary, JSON.stringify(session));
await rename(temporary, target);
// After restart:
const progress = await getDocumentUploadStatus(uploadId);
const { receivedChunks } = progress;const session = { size, sha256, chunkSize };
const name = `_session.${randomUUID()}.tmp`;
const temporary = path.join(dir, name);
const target = path.join(dir, "_session.json");
await writeFile(temporary, JSON.stringify(session));
await rename(temporary, target);
// After restart:
const progress = await getDocumentUploadStatus(uploadId);
const { receivedChunks } = progress;This simplified example omits validation and locks. Completed parts also use a temporary file followed by rename.
Here is the flow:
- Describe the upload. The session stores the expected size, checksum, and chunk size. Its directory identifies the upload.
- Write safely. Write JSON to a uniquely named temporary file, then rename it to _session.json. A crash during writing leaves the final file untouched. Rename within the same filesystem publishes the completed metadata atomically.
- Recover from disk. After restart, getDocumentUploadStatus(uploadId) reads the metadata and checks saved parts. The actual file bytes are stored separately from the JSON.
- Send missing parts. receivedChunks uses zero-based indices. For [0, 1, 2, 4], index 3 - the fourth partis missing. Send that part, then retry completion.
Keeping successful parts
If completion detects a missing part, it reports the error without deleting parts already saved. The browser uploads the missing part and retries completion.
For example, if part 3 is missing but parts 1, 2, 4, and 5 are saved, only part 3 is sent again. Restart recovery protects progress across crashes; missing-part recovery protects it during validation.
Simplified completion check: report missing parts without deleting saved files.
if (receivedChunks.length !== totalChunks) {
return Response.json({
code: "UPLOAD_PARTS_MISSING", receivedChunks
}, { status: 400 });
}if (receivedChunks.length !== totalChunks) {
return Response.json({
code: "UPLOAD_PARTS_MISSING", receivedChunks
}, { status: 400 });
}On retry, the browser checks saved parts and sends only missing indices. These helpers represent the existing status and part-upload requests.
const { receivedChunks } = await status();
const received = new Set(receivedChunks);
const missing = Array.from({ length: totalChunks }, (_, i) => i)
.filter(i => !received.has(i));
for (const i of missing) await uploadPart(i);
// Retry completion after all parts are saved.const { receivedChunks } = await status();
const received = new Set(receivedChunks);
const missing = Array.from({ length: totalChunks }, (_, i) => i)
.filter(i => !received.has(i));
for (const i of missing) await uploadPart(i);
// Retry completion after all parts are saved.The browser request
const status = await fetch(statusUrl);
const { offset } = await status.json();
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-Upload-Offset': String(offset) },
body: file.slice(offset),
});const status = await fetch(statusUrl);
const { offset } = await status.json();
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-Upload-Offset': String(offset) },
body: file.slice(offset),
});This is the core request, not a complete retry loop. If the offset is stale, the server returns 409. The client asks for status again before retrying.
Protect the file
- Check the offset. Reject it unless it matches the saved file size.
- Block overlapping writes. Use a shared lock when running multiple server processes.
- Verify the result. Compare the final size and server SHA-256 against the original file's checksum.
- Retain upload identity. Keep a stable upload ID so retries find the same file.
- Clean up old uploads. Expire abandoned partial files.
The trade-off
On resume, the server reads the saved prefix to rebuild its hash, then hashes incoming bytes. It saves network transfer, but still performs a disk read.
Recovery also requires persistent storage. If the original bytes disappear during a restart, there is nothing to resume.
Keep saved bytes, trust the server's offset, and verify the completed file.