DEV Community

137Foundry
137Foundry

Posted on

How to Resume a Failed File Upload From the Last Successful Chunk

The most common way teams half-solve resumable uploads is building the chunk-sending logic and stopping there. The client splits the file, sends each piece, retries a chunk if it fails, and calls it resumable. It isn't, not fully, because nothing on the server actually remembers state across a full session interruption like a closed tab or a dead battery. Here's the missing piece, step by step.

Step 1: Give Every Upload Session a Stable ID

Before the first chunk goes out, the client requests a new upload session from the server and gets back an ID. That ID is the thread that ties every chunk request, and any later resume attempt, back to the same file. Store it somewhere that survives a page reload, localStorage is the obvious choice for browser clients, so a returning session can look it up instead of starting fresh.

POST /uploads/start
{ "filename": "export.csv", "total_size": 314572800, "chunk_size": 10485760 }

Response:
{ "upload_id": "up_8f2ac91b", "chunks_expected": 30 }
Enter fullscreen mode Exit fullscreen mode

Step 2: Track Received Chunks Server-Side

Every chunk request needs to carry the upload ID and a chunk index. When a chunk lands, the server writes it to a temporary location and marks that index as received in a tracking record, a single database row per upload session is usually enough: upload ID, expected chunk count, a bitmap or set of received indexes, and a timestamp.

PUT /uploads/up_8f2ac91b/chunks/17
Content-Type: application/octet-stream
X-Chunk-Checksum: sha256:4f2b...

Response: { "received": true, "chunk_index": 17 }
Enter fullscreen mode Exit fullscreen mode

Step 3: Query What's Missing Before Resuming

This is the step most half-built systems skip entirely. When a client reconnects after an interruption, it doesn't just start sending from chunk zero again and hope for the best. It asks the server what it already has.

GET /uploads/up_8f2ac91b/status

Response:
{ "received_chunks": [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16],
  "missing_chunks": [17,18,19,20,21,22,23,24,25,26,27,28,29] }
Enter fullscreen mode Exit fullscreen mode

If chunk 17 shows as received here even though the client's last known state said it failed, that's a signal the acknowledgment got lost on the way back, not the chunk itself. The server needs to treat a re-sent chunk as a safe no-op rather than throwing a duplicate error, keyed on chunk index.

Step 4: Resume Sending Only What's Missing

With the missing-chunk list in hand, the client resumes sending exactly those indexes, in whatever order makes sense (sequential is simplest, parallel is faster if your server tolerates out-of-order writes). Nothing already confirmed gets re-sent, which is the entire point.

Step 5: Finalize Explicitly

Once every chunk index shows as received, the client sends an explicit finalize call rather than the server assuming completion just because the last expected chunk index arrived. A chunk can legitimately arrive twice under retry, so completion should be a deliberate signal, not an inference.

POST /uploads/up_8f2ac91b/finalize
Response: { "status": "complete", "file_url": "/files/export.csv" }
Enter fullscreen mode Exit fullscreen mode

Step 6: Expire Abandoned Sessions

Sessions that never get resumed will otherwise sit in your tracking table and temporary storage forever. A scheduled job that expires anything inactive for 24 to 48 hours, cleaning up both the database row and any orphaned chunk files, keeps this from becoming a slow, invisible storage leak.

A Worked Example With Real Numbers

Say a 300MB file is split into thirty 10MB chunks, indexes 0 through 29. The client sends chunks 0 through 16 successfully. Chunk 17 goes out, gets written to storage, but the response never makes it back before the connection drops entirely, the user's train goes into a tunnel. The client's own state thinks chunk 17 failed and chunks 18 through 29 were never attempted.

Twenty minutes later, the user is back online. A naive resume would start from chunk 17 again since that's what the client's local state remembers as the failure point, which is fine, mostly, except now there's ambiguity about whether the server has 17 or not. A correct resume queries the status endpoint first: the server reports chunks 0 through 17 as received, missing 18 through 29. The client sends only those twelve remaining chunks and finalizes. No wasted bandwidth re-sending seventeen chunks that already succeeded, and no confusion about chunk 17's actual state, because the server was asked rather than assumed.

Handling Parallel Chunk Uploads

Everything above assumes chunks are sent sequentially, which is the simpler case to implement and reason about. Some implementations send several chunks in parallel to use available bandwidth more fully, particularly useful on fast connections uploading very large files. This works with the same tracking approach described above, but it adds a wrinkle: the server needs to handle out-of-order arrival cleanly, since chunk 22 might land before chunk 19 finishes.

The tracking record needs to be a set or bitmap of received indexes rather than a single "highest confirmed" pointer, since "highest confirmed" breaks down the moment arrival order isn't guaranteed. The status endpoint's response also needs to communicate the full picture (every received index, or equivalently every missing one) rather than just a single resume point, so the client knows exactly which chunks to send regardless of what order they land in.

Common Mistakes When Implementing This

A few patterns show up repeatedly in upload implementations that look resumable but aren't, quite:

  • Trusting client-reported state over server-confirmed state. If the client's local record of "what I've sent" and the server's record of "what I've received" ever disagree, the server's record is the one that's actually true. Always query it before resuming rather than trusting whatever the client remembers.
  • No explicit finalize step. Inferring completion from "all expected chunk indexes have arrived" breaks the moment a chunk legitimately arrives twice under retry. An explicit finalize call, sent once by the client, avoids this ambiguity entirely.
  • Forgetting to expire abandoned sessions. An upload that's started and never finished will otherwise sit in the tracking table and in temporary storage indefinitely. A scheduled cleanup job for sessions inactive past 24 to 48 hours keeps this from becoming a slow, invisible storage leak.
  • Skipping checksum verification because "the request succeeded." A chunk can be corrupted in transit and still result in an HTTP 200 if nothing checks its actual contents. A checksum sent alongside each chunk, verified server-side before acknowledgment, catches this before it becomes a corrupted file discovered much later.

What to Store on the Client Between Sessions

The upload ID by itself isn't quite enough to make resuming across a browser restart smooth. It's worth also storing the filename and a fingerprint of the file (its size and a quick hash, not a full checksum, just enough to detect "is this the same file the user picked last time") in the same local storage entry as the upload ID. When the user returns to the upload screen, the client can check whether a saved session matches the file they're about to select and prompt "resume your previous upload?" rather than silently starting a new session for what looks like the same file, or worse, silently trying to resume a session against a completely different file the user picked this time.

Without that fingerprint check, a subtle bug shows up: a user starts uploading file A, abandons it, comes back later and picks file B instead, and the client tries to resume file A's session against file B's bytes. The chunk indexes won't line up and the whole thing fails in a confusing way that's hard to diagnose from a bug report alone.

Handling the Case Where the Server Has Forgotten the Session

Sessions expire eventually, by design, from the cleanup job described earlier. If a client tries to resume a session the server has already cleaned up, the status endpoint needs to return a clear, distinct response, not a generic 404 that looks the same as any other missing resource. The client should treat this specific case as "your progress is gone, please start a new upload," ideally with a clear message rather than a cryptic error, since silently failing here just looks like the resume feature is broken rather than working as designed.

This is a small detail that's easy to skip during initial implementation and then shows up as a confusing edge case in production once real users start hitting the expiration window in practice, typically weeks after the code first ships, since it takes that long for someone to actually abandon an upload for more than 24 to 48 hours and then come back to it.

A Few Implementation Notes

Send a checksum with every chunk (MD5 or SHA-256 of that chunk's bytes) and verify it server-side before acknowledging. A chunk that arrives corrupted but gets marked as received is a worse failure mode than one that visibly fails, because it surfaces later as a corrupted file with no obvious cause.

If you'd rather not build this protocol from scratch, tus implements this exact offset-tracking pattern as an open, documented protocol with libraries for most major stacks. The File and Blob APIs on MDN cover the browser-side slicing needed to generate chunks without loading the whole file into memory, and the HTTP Range Requests RFC documents the header semantics a lot of resumable protocols borrow from even when they're not using literal byte ranges.

137Foundry's engineering team put together a fuller walkthrough covering chunk protocol design and backend options in this guide to building resumable file uploads, worth a look if you're implementing this for the first time.

Top comments (0)