How I Built a File Upload That Survives Network Failures
A resilient chunked upload flow with resumability, retries, cancellation, and useful progress feedback.
- JavaScript
- Node.js
- Web Development
- Backend
- File Upload
- Performance
- English
The Problem Everyone Has Felt
As users, we get absolutely frustrated when we are uploading large document or video and suddenly the connection drops at 90% progress and we are forced to start over from the beginning.
As we know, most apps handle it with a single POST request and never think twice about it but one thing to note that this decision is not scalable. Sure, small-scale apps with small-sized files uploaded is fine and sufficient but if your app has any chance of scaling or you are trying to create new app with large file upload flow in the business process to begin with, you need to really consider it carefully.
The sections below explain exactly why that breaks and how to fix it.
Why Naive Uploads Fail
Timeout
Frameworks like Express and most HTTP servers have their own default body size limits and request timeout values in order to protect the server in the first place.
Memory
Beyond timeout issues, memory pressure is equally dangerous, the entire file must sit in memory during upload process which is fine for relatively small-sized file with less than 5 MB, but it will be disaster for file with size more than 1 GB as it will occupy the entire server memory quickly (not to mention the concurrent uploads from many active users) and crashing the server with OOM (Out-Of-Memory) error.
Connectivity
Even if the server survives, the user might not have good connectivity. What if their connection drops at 99%? They need to start over at 0% again and left frustrated.
User Experience (UX)
File upload UX on most apps are poorly designed. There are no progress feedback, no estimated upload speed, and no way to cancel upload gracefully.
The Core Idea - Chunked Uploading
Main concept of this mechanism is to slice the file into small pieces or chunks (1 MB max.) using Blob.slice() and then upload each chunk sequentially.
Browser assign each chunk an index so that the server knows exactly which chunk it is currently receiving.
After all chunks arrive, server assembles them in order into final, completed file.
API contract is something like this: /upload/chunk with chunkIndex, totalChunks, uploadId in client’s FormData. This contract is also what makes resumability possible, the server always knows exactly which chunks it has received.
Resumability - Picking Up Where You Left Off
Upload progress is saved to localStorage with key consist of uploadId_ and fileName with the intention to persist the upload state from page refresh, tab close, and even network/connection drop.
On start phase, client calls GET /upload/status/:id and the server returns which chunks it already has.
Client skips already-uploaded chunks and resumes from the first missing one.
This mechanism works like a bookmark in a book, you do not re-read chapter 1 just because you closed the book.
Retry Logic & Error Handling
Not all failures are equal.
There are network blip (retry) case and server-rejected file (permanent) case. The client should carefully handle these situations.
The max retry attempts per chunk is capped at 3 with a 1.5s delay between each attempt to prevent hammering an already-struggling server. After that, if the total retries exceed the max retry attempts then the app show the error to the user.
Aside from that, we have to consider case which users might cancel their uploads. Client have to handle that condition by utilize AbortController to cancel ongoing fetch cleanly.
UX Layer - Progress That Actually Means Something
Users may lose their trust to our app as the progress bar freezes and not showing any useful information such as how many chunks already uploaded out of total chunks, transfer speed (MB/s), and estimated time (ETA) remaining.
Users should be notified as soon as file does not match our criteria: file types, max file size.
Implementation Highlights
Blob.slice() Chunking Loop
Each chunk carry their own metadata. The most important information needed here are uploadId, chunkIndex, and totalChunks.
uploadId- to know which upload the chunk belong in.chunkIndex- to keep track the order of the chunks when all of them need to be assembled on the server.totalChunks- to make sure the amount of chunks sent is not exceeding the total amount of chunks.
// startChunk is conditionally taken from current state of upload or
// if fresh upload then start from 1
// uploadId is conditionally taken from localStorage or
// if fresh upload using crypto.randomUUID()
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
for (let chunkIndex = startChunk; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileSize);
// extract the exact byte range for this chunk
const chunk = file.slice(start, end);
const chunkBody = new FormData();
chunkBody.append("chunk", chunk);
chunkBody.append("uploadId", uploadId);
chunkBody.append("chunkIndex", chunkIndex);
chunkBody.append("totalChunks", totalChunks);
chunkBody.append("fileName", fileName);
// rest of codes
}Resume Check
Browser will check if the user trying to upload the same file based on file name through localStorage. After that, client hit GET /upload/status/:id endpoint to get the current state of the upload (at which index the last chunk was sent) and then continue uploading from that point.
const savedUploadId = localStorage.getItem(`uploadId_${file.name}`);
if (savedUploadId) {
const response = await fetch(
`${API_URL}/upload/status/${savedUploadId}`,
);
if (!response.ok) throw new Error("Status check failed");
const data = await response.json();
// handle stale uploadId
}Retry Logic
Each chunk have their MAX_RETRIES attempts respectively, if the attempt fail then it will wait for RETRY_DELAY_MS time, then continue uploading again. However, if the attempt exceed MAX_RETRIES then the entire file upload process is failed.
for (let retry = 0; retry < MAX_RETRIES; retry++) {
try {
const response = await fetch(`${API_URL}/upload/chunk`, {
method: "POST",
body: chunkBody,
});
if (!response.ok) {
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
continue;
}
break;
} catch (error) {
if (retry === MAX_RETRIES - 1) {
errorArea.textContent = "Upload failed. Please try again.";
return;
}
statusText.textContent = `Retrying... (${retry + 1}/${MAX_RETRIES})`;
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}Tradeoffs & Decisions
Sequential vs Concurrent Chunks
Sequential is simpler and ordered, while concurrent is faster but requires more complex state management on both client and server side. For early adoption, sequential approach is perfectly fine. However, if someday a bottleneck occurs a concurrent approach may be worth exploring, depending on your use case.
Local Storage vs Server-Side Session
localStorage is not taking use of infrastructure (like DB or Redis) but tied solely on one browser as a downside but if there is need for multi device uploads that share the same upload state, server-side session would be a significant advantage. Again, for simplicity, choosing the localStorage approach is reasonable for now.
Vanilla JS over Frameworks
Vanilla JS picked here with intention to show understanding of browser APIs such as: AbortController, FormData, Blob, Fetch without any abstraction layers hiding complexity.
1 MB Chunk Size
1 MB is small enough to create retries cheaply, but large enough to avoid excessive HTTP overhead. So, 1 MB choice here was not just throwing darts blindly.
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Upload strategy | Sequential | Concurrent | Simplicity |
| State storage | localStorage | Server session | No infrastructure needed |
| Language | Vanilla JS | Framework | Direct API exposure |
| Chunk size | 1 MB | Variable | Cost/overhead balance |
What’s Next
- Add server-side chunk validation with SHA-256 checksums to detect file corruption from network transfer, so not just check for missing chunks.
- Migrate to concurrent chunk uploads approach for 3-5x faster throughput on large files.
- Integrate with cloud storage by replacing local disk with S3/GCS presigned URLs so every chunk is sent directly from client to the cloud.
- Add drag-and-drop multi-file support with queue management in client side.
Closing
Now, your upload process survives network failures and become resilient.
What would you add to this feature? Concurrent uploads? Cloud storage integration? Please let me know in the email :)
If you are interested in this project, you can fork and running the project locally based on the instructions in the GitHub repository.