Resumable File Upload
Learn how to build resumable file uploads in JavaScript: chunked transfer, resuming after interruptions, a Node.js server, and native File.slice + fetch.
Uploading a 2 GB video over a flaky mobile connection with a single fetch request is fragile: one dropped connection at 95% and the user starts over from zero. Resumable file uploads solve this by splitting the file into small pieces, uploading them one at a time, and remembering which pieces already arrived — so an interrupted upload picks up where it left off instead of restarting.
This page covers the full picture: how chunked, resumable uploads work conceptually, a working Node.js + Express server that stores and reassembles chunks, a client built with the resumable.js library, and a zero-dependency native version using File.slice and fetch. You'll also see the common reassembly bug to avoid and production hardening tips.
How Resumable Uploads Work
The core idea is simple and rests on three pieces working together:
- Slice the file into chunks. The browser splits the selected file into fixed-size pieces (for example, 1 MB each) using the
Blob.slicemethod thatFileinherits. The file itself is never loaded fully into memory. - Upload chunks one (or a few) at a time. Each chunk is a separate HTTP request carrying its index (
chunk 3 of 17), the total chunk count, the filename, and a stable identifier that uniquely tags this upload session. - Reassemble on the server. The server saves each chunk to disk keyed by its index. Once every chunk has arrived, it concatenates them in order into the final file.
Resumability comes from step 3 plus a check-before-send step on the client. Before uploading a chunk, the client asks the server "do you already have chunk N?" (typically via an HTTP HEAD request). If yes, it skips that chunk. So after a crash or refresh, the client re-scans the file and only re-sends the missing pieces. The stable identifier is what lets the server recognize a returning, half-finished upload.
File (2.5 MB)
└─ slice into 1 MB chunks ──► [chunk 1] [chunk 2] [chunk 3 (0.5 MB)]
│ │ │
HEAD /upload?chunk=N (already there? skip : send)
▼ ▼ ▼
POST /upload (one request per missing chunk)
└────────┬─────────┘
server saves chunk-N.bin, then concatenates in orderBenefits of Resumable File Uploads
- Improved user experience: Users can resume uploads without starting over.
- Efficiency: Only the missing parts are transferred after a failure, not the whole file.
- Reliability on poor networks: Network interruptions are handled gracefully, which matters most for large files and mobile connections.
- Lower memory pressure: Working with small slices avoids buffering a multi-gigabyte file in memory.
Implementing Resumable File Uploads in JavaScript
Setting Up the Environment
Before diving into the implementation, ensure you have the following tools and libraries:
- A modern web browser with JavaScript support.
- A server capable of handling file uploads.
- The
resumable.jslibrary (or a similar library) to manage the client-side logic.
Install the required Node.js dependencies:
npm install express corsServer-Side Configuration
First, configure your server to handle file chunks and store metadata about the uploaded files. Here is an example using Node.js and Express. Note that resumable.js sends chunk metadata in the query string by default, so we read from req.query and use a temporary directory per file to safely handle out-of-order chunk arrival.
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3000;
app.use(cors());
// Handle chunk verification for testChunks: true
app.head('/upload', (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
const chunkNumber = parseInt(req.query.resumableChunkNumber);
const identifier = req.query.resumableIdentifier;
const chunkPath = path.join('uploads', identifier, `chunk-${chunkNumber}.bin`);
fs.promises.access(chunkPath)
.then(() => res.status(200).end())
.catch(() => res.status(404).end());
});
app.post('/upload', async (req, res) => {
try {
const chunkNumber = parseInt(req.query.resumableChunkNumber);
const totalChunks = parseInt(req.query.resumableTotalChunks);
const identifier = req.query.resumableIdentifier;
const fileName = req.query.resumableFilename;
const chunkDir = path.join('uploads', identifier);
await fs.promises.mkdir(chunkDir, { recursive: true });
// Read raw body (resumable.js sends chunks as application/octet-stream)
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
const chunkPath = path.join(chunkDir, `chunk-${chunkNumber}.bin`);
await fs.promises.writeFile(chunkPath, buffer);
const receivedChunks = (await fs.promises.readdir(chunkDir)).length;
if (receivedChunks === totalChunks) {
// Concatenate chunks IN ORDER, one at a time (see warning below).
const finalPath = path.join('uploads', fileName);
await fs.promises.writeFile(finalPath, ''); // start with an empty file
for (let i = 1; i <= totalChunks; i++) {
const data = await fs.promises.readFile(
path.join(chunkDir, `chunk-${i}.bin`)
);
await fs.promises.appendFile(finalPath, data);
}
await fs.promises.rm(chunkDir, { recursive: true, force: true });
res.status(200).send('File uploaded successfully');
} else {
// resumable.js expects a 200 OK for successful chunk uploads
res.status(200).send('Chunk uploaded successfully');
}
} catch (error) {
console.error('Upload error:', error);
res.status(500).send('Server error during upload');
}
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});Reassemble chunks sequentially, not concurrently. A common bug is piping every chunk's read stream into one write stream at once (fs.createReadStream(...).pipe(writeStream) inside a loop). The streams race, so the bytes interleave in the wrong order and the first stream to finish closes the write stream early — producing a corrupted file. Read and append one chunk at a time, as shown above.
Client-Side Implementation
Now, let's implement the client-side logic using JavaScript and the resumable.js library. Ensure you include the resumable.js library in your project. We use v2.1.0 for modern compatibility. For production environments, consider the standardized tus protocol or native File.slice with fetch for better control and cross-platform support.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Resumable File Upload</title>
</head>
<body>
<input type="file" id="fileInput" />
<button id="uploadButton">Upload</button>
<p id="progress">Ready</p>
<script src="https://unpkg.com/[email protected]/resumable.min.js"></script>
<script>
const fileInput = document.getElementById('fileInput');
const uploadButton = document.getElementById('uploadButton');
const progressEl = document.getElementById('progress');
const r = new Resumable({
target: '/upload',
chunkSize: 1 * 1024 * 1024, // 1MB chunks
simultaneousUploads: 1,
testChunks: true,
throttleProgressCallbacks: 1,
});
r.assignBrowse(fileInput);
uploadButton.addEventListener('click', () => {
if (r.files.length > 0) {
r.upload();
} else {
alert('Please select a file to upload.');
}
});
r.on('progress', (file, loaded, total) => {
const percent = Math.round((loaded / total) * 100);
progressEl.textContent = `Uploading ${file.fileName}: ${percent}%`;
});
r.on('fileSuccess', (file, message) => {
console.log(`File ${file.fileName} uploaded successfully.`);
progressEl.textContent = 'Upload complete!';
});
r.on('fileError', (file, message) => {
console.error(`Error uploading file ${file.fileName}: ${message}`);
progressEl.textContent = 'Upload failed.';
});
</script>
</body>
</html>Native Alternative: File.slice + fetch
For projects that prefer zero dependencies, you can implement resumable uploads natively using the File.slice method and fetch. This gives you full control over headers, retries, and — crucially — the resume logic. The function below builds each chunk's query string, asks the server whether the chunk already exists with a HEAD request, and only uploads the ones that are missing. Calling it again after an interruption skips everything that already made it through:
async function uploadFileNative(file) {
const chunkSize = 1 * 1024 * 1024; // 1MB
const totalChunks = Math.ceil(file.size / chunkSize);
// A stable identifier so a re-run resumes the same upload session.
const identifier = `${file.name}-${file.size}`;
for (let i = 0; i < totalChunks; i++) {
const params = new URLSearchParams({
resumableChunkNumber: i + 1,
resumableTotalChunks: totalChunks,
resumableIdentifier: identifier,
resumableFilename: file.name,
});
const url = `/upload?${params}`;
// Resume support: skip chunks the server already has.
const probe = await fetch(url, { method: 'HEAD' });
if (probe.status === 200) continue;
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end); // a Blob, sent as the request body
await fetch(url, { method: 'POST', body: chunk });
}
console.log('Native upload complete');
}To make this production-grade you would wrap each POST in a retry loop with exponential backoff and support cancellation with an AbortController.
Managing Metadata
It is crucial to manage metadata about the uploaded file and its chunks — the chunk index, total count, filename, and the stable identifier. This information is what lets the server resume an upload from the correct chunk after an interruption. The server logic for tracking and assembling chunks is covered in the previous section.
For production, avoid relying on the file system alone to track progress: it lacks persistence guarantees and is not safe when several chunks arrive at the same time (the readdir length check can race). Use a database or cache (such as Redis) to record which chunks completed, and assemble the file only once every index is confirmed. If you need to send extra structured metadata alongside a chunk, the FormData API lets you bundle fields and the binary blob in one request.
Example: Uploading Large Files
The client configuration remains identical to the previous example. To optimize for large files, you can increase the chunkSize (e.g., to 5MB) and adjust simultaneousUploads based on your server's capacity and network conditions.
Professional Tips for Resumable File Uploads
- Optimize Chunk Size: Adjust the chunk size based on the average network speed and file size to balance between upload speed and reliability.
- Error Handling: Implement robust error handling mechanisms to deal with network interruptions and server issues.
- User Feedback: Provide real-time feedback to users about the upload progress and any issues encountered.
- Security: Ensure that the file upload process is secure by validating file types and implementing proper authentication and authorization.
- Modern Alternatives: For production environments, consider standardized protocols like
tusor nativeFile.slicewithfetchfor better control, resumability, and cross-platform compatibility.
By following these guidelines and examples, you can implement a robust, efficient resumable file upload system in JavaScript — one that survives flaky networks and gives users confidence that a large upload won't be wasted.
Related Topics
- Fetch API — the modern way to send each chunk to the server.
- Fetch: Download progress — read a streamed response body to report progress.
- Fetch: Abort — cancel an in-flight upload with
AbortController. - Blob — the type returned by
File.slice, which represents each chunk. - File and FileReader — reading the file the user selected.
- FormData — bundle binary data with extra fields in one request.