Introduction: The Operational Pain of Large Uploads in Low-Bandwidth Networks
In regions with evolving digital infrastructure, such as Syria and parts of the Middle East, web and mobile users frequently encounter network drops, high packet loss, and low bandwidth. While downloading optimized assets can be managed using caching and responsive image pipelines, uploading content remains a major technical challenge. When a user submits a single large file via a standard HTTP POST request, any momentary connection drop kills the transfer entirely, forcing the user to restart the upload from 0%.
For Dragonfly Soft's two hero assets, this issue is a critical friction point:
- Lernce Web Learning Platform requires instructors to upload large course materials, including lecture PDFs, presentation decks, and audio/video files.
- Husomat Marketplace Website and Mobile App allows users to list products for sale, which involves uploading multiple high-resolution photos directly from mobile devices on unstable cellular connections.
To deliver a reliable user experience, we must replace standard, fragile file uploads with a resilient, resumable chunked upload system. This system slices files into small, manageable chunks on the client, uploads them sequentially, handles connection failures gracefully with automatic retries, and merges the chunks on the server once the transfer is complete.
This guide provides a step-by-step technical implementation of this architecture using vanilla JavaScript on the frontend and Node.js with Express on the backend.
---
Architectural Workflow of Chunked Uploads
The resumable upload process works through a client-server coordination protocol:
[ Client File Selection ]
|
v
[ Calculate Unique File ID ] ---> [ GET /api/upload/status/:fileId ]
|
v
[ Slice File into 1MB Chunks ] <--- [ Server Responds with Uploaded Chunk Indices ]
|
v
[ Loop: Upload Chunks Sequentially ] ---> [ POST /api/upload/chunk ]
(With Exponential Backoff Retry) | (Store in temp directory)
| v
+----------------------------------------+
|
v
[ Request Server Merge ] --------------> [ POST /api/upload/merge ]
| (Merge streams sequentially)
v
[ Final File Registered ] <------------- [ Server Returns Final File URL ]
---
Step 1: Client-Side Chunking and State Persistence
To implement chunking on the client, we utilize the HTML5 File API. Files are instances of Blob, which exposes a .slice(start, end) method. This allows us to extract slices of a file without loading the entire file into the device's RAM.
We also generate a unique identifier (fileId) based on the file name, size, and last modification timestamp. This ensures that if the user refreshes the browser, the upload can resume exactly where it was interrupted.
Save the following class as your client-side uploader module:
class ResumableUploader {
constructor(file, options = {}) {
this.file = file;
this.chunkSize = options.chunkSize || 1024 * 1024; // Default: 1MB chunks
this.endpoints = {
status: options.statusUrl || '/api/upload/status',
chunk: options.chunkUrl || '/api/upload/chunk',
merge: options.mergeUrl || '/api/upload/merge'
};
this.fileId = this.generateFileId();
this.uploadedChunks = [];
this.onProgress = options.onProgress || (() => {});
this.onSuccess = options.onSuccess || (() => {});
this.onError = options.onError || (() => {});
}
generateFileId() {
const cleanName = this.file.name.replace(/[^a-zA-Z0-9]/g, '');
return `${cleanName}-${this.file.size}-${this.file.lastModified}`;
}
async start() {
try {
// Step 1: Check which chunks the server already has
const statusRes = await fetch(`${this.endpoints.status}/${this.fileId}`);
if (!statusRes.ok) throw new Error('Could not verify upload status.');
const { uploadedChunks } = await statusRes.json();
this.uploadedChunks = uploadedChunks || [];
const totalChunks = Math.ceil(this.file.size / this.chunkSize);
// Step 2: Upload missing chunks sequentially
for (let i = 0; i < totalChunks; i++) {
if (this.uploadedChunks.includes(i)) {
continue; // Skip already uploaded chunk
}
await this.uploadChunkWithRetry(i, totalChunks);
this.uploadedChunks.push(i);
// Report progress
const progressPercent = Math.round((this.uploadedChunks.length / totalChunks) * 100);
this.onProgress({
percent: progressPercent,
uploaded: this.uploadedChunks.length,
total: totalChunks
});
}
// Step 3: Trigger chunk merging on the server
const mergeRes = await fetch(this.endpoints.merge, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId: this.fileId,
filename: this.file.name,
totalChunks
})
});
if (!mergeRes.ok) throw new Error('File assembly failed on the server.');
const result = await mergeRes.json();
this.onSuccess(result);
} catch (err) {
this.onError(err);
}
}
async uploadChunkWithRetry(chunkIndex, totalChunks, retries = 5, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunkBlob = this.file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunkBlob);
formData.append('fileId', this.fileId);
formData.append('chunkIndex', chunkIndex.toString());
formData.append('totalChunks', totalChunks.toString());
const response = await fetch(this.endpoints.chunk, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed with status code: ${response.status}`);
}
return; // Success, exit retry loop
} catch (error) {
console.warn(`Chunk ${chunkIndex} upload attempt ${attempt} failed:`, error);
if (attempt === retries) throw error;
// Exponential backoff delay
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, attempt)));
}
}
}
}
---
Step 2: Server-Side Express Handlers for Chunk Management
On the server side, Node.js needs to handle three distinct responsibilities:
- Provide a status endpoint to tell the client which chunks of a given
fileIdhave already been uploaded. - Accept individual chunks and save them in a temporary subdirectory named after the
fileId. - Merge all chunks in sequential order into a single file once all parts are present, and clean up the temporary directory.
We use multer to handle the multipart form-data chunks and standard Node.js file system streams (fs.createReadStream and fs.createWriteStream) to handle file merging. Piping streams sequentially avoids loading the entire file into server memory, preserving backend stability.
Here is the server-side controller implementation:
import express from 'express';
import multer from 'multer';
import path from 'path';
import { promises as fs } from 'fs';
import fsSync from 'fs';
const app = express();
app.use(express.json());
const UPLOAD_LIMIT = 15 * 1024 * 1024; // 15MB chunk limit
const tempDir = path.resolve('temp_chunks');
const finalDir = path.resolve('public/uploads');
const upload = multer({
dest: 'temp_chunks/raw/',
limits: { fileSize: UPLOAD_LIMIT }
});
// 1. Status Endpoint: Checks already uploaded chunks
app.get('/api/upload/status/:fileId', async (req, res) => {
const { fileId } = req.params;
const chunkFolder = path.join(tempDir, fileId);
try {
// If the directory doesn't exist, no chunks have been uploaded yet
const folderExists = await fs.access(chunkFolder).then(() => true).catch(() => false);
if (!folderExists) {
return res.status(200).json({ uploadedChunks: [] });
}
const files = await fs.readdir(chunkFolder);
// Extract chunk indices from filenames (e.g., "part-0" -> 0)
const uploadedChunks = files
.map(file => parseInt(file.replace('part-', ''), 10))
.filter(num => !isNaN(num));
return res.status(200).json({ uploadedChunks });
} catch (error) {
console.error('Status check failed:', error);
return res.status(500).json({ error: 'Failed to query upload status.' });
}
});
// 2. Chunk Ingress Endpoint: Receives and writes a single chunk
app.post('/api/upload/chunk', upload.single('chunk'), async (req, res) => {
const { fileId, chunkIndex } = req.body;
if (!req.file || !fileId || chunkIndex === undefined) {
return res.status(400).json({ error: 'Required fields missing.' });
}
const chunkFolder = path.join(tempDir, fileId);
const destPath = path.join(chunkFolder, `part-${chunkIndex}`);
try {
// Ensure the folder exists
await fs.mkdir(chunkFolder, { recursive: true });
// Move the uploaded temp file to its correct chunk destination
await fs.rename(req.file.path, destPath);
return res.status(200).json({ success: true });
} catch (error) {
console.error('Saving chunk failed:', error);
return res.status(500).json({ error: 'Could not write file chunk.' });
}
});
// 3. Merge Endpoint: Stream-merges all parts together
app.post('/api/upload/merge', async (req, res) => {
const { fileId, filename, totalChunks } = req.body;
if (!fileId || !filename || !totalChunks) {
return res.status(400).json({ error: 'Required fields missing.' });
}
const chunkFolder = path.join(tempDir, fileId);
const finalFilename = `${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const finalPath = path.join(finalDir, finalFilename);
try {
await fs.mkdir(finalDir, { recursive: true });
// Validate that all chunks are present before initiating merge
for (let i = 0; i < totalChunks; i++) {
const partPath = path.join(chunkFolder, `part-${i}`);
const partExists = await fs.access(partPath).then(() => true).catch(() => false);
if (!partExists) {
return res.status(400).json({ error: `Upload incomplete. Chunk ${i} is missing.` });
}
}
// Merge chunks sequentially using write streams
const writeStream = fsSync.createWriteStream(finalPath);
for (let i = 0; i < totalChunks; i++) {
const partPath = path.join(chunkFolder, `part-${i}`);
const readStream = fsSync.createReadStream(partPath);
await new Promise((resolve, reject) => {
readStream.pipe(writeStream, { end: false });
readStream.on('end', resolve);
readStream.on('error', reject);
});
}
writeStream.end();
// Clean up temporary chunks directory
const files = await fs.readdir(chunkFolder);
for (const file of files) {
await fs.unlink(path.join(chunkFolder, file));
}
await fs.rmdir(chunkFolder);
return res.status(200).json({
success: true,
fileUrl: `/uploads/${finalFilename}`
});
} catch (error) {
console.error('Merge assembly failed:', error);
return res.status(500).json({ error: 'Assembly process failed.' });
}
});
---
Step 3: Frontend Integration UI Example
On the frontend of our platforms (Lernce and Husomat), we wire the ResumableUploader class to file inputs and render progress bars. This gives immediate feedback to users uploading files under weak cellular connections.
Here is a practical integration example in HTML and vanilla JavaScript:
<div class="upload-container">
<input type="file" id="fileInput" />
<button id="uploadButton" disabled>Upload File</button>
<div id="progressContainer" style="display: none; margin-top: 15px;">
<progress id="progressBar" value="0" max="100" style="width: 100%;"></progress>
<span id="progressText">0% Uploaded</span>
</div>
<div id="statusMessage" style="margin-top: 10px; font-weight: 500;"></div>
</div>
<script>
const fileInput = document.getElementById('fileInput');
const uploadButton = document.getElementById('uploadButton');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const statusMessage = document.getElementById('statusMessage');
let selectedFile = null;
fileInput.addEventListener('change', (e) => {
selectedFile = e.target.files[0];
uploadButton.disabled = !selectedFile;
statusMessage.textContent = '';
});
uploadButton.addEventListener('click', () => {
if (!selectedFile) return;
uploadButton.disabled = true;
progressContainer.style.display = 'block';
statusMessage.textContent = 'Starting upload...';
const uploader = new ResumableUploader(selectedFile, {
chunkSize: 1 * 1024 * 1024, // 1MB chunks
onProgress: (data) => {
progressBar.value = data.percent;
progressText.textContent = `${data.percent}% (${data.uploaded}/${data.total} chunks)`;
},
onSuccess: (result) => {
statusMessage.textContent = 'Upload successful!';
statusMessage.style.color = '#00aa50';
console.log('Final file accessible at:', result.fileUrl);
},
onError: (err) => {
statusMessage.textContent = `Upload failed: ${err.message}. Re-connecting...`;
statusMessage.style.color = '#ff003c';
uploadButton.disabled = false;
}
});
uploader.start();
});
</script>
---
Technical Performance Audit: Traditional vs. Chunked Resumable Uploads
To validate the efficiency of this pipeline, we simulated two types of network scenarios using Linux tc (Traffic Control) representing regional connections (unstable 3G network with 10% random packet drop rate and temporary connection dropouts of 5 seconds):
| Metric | Traditional Multi-part Upload (Single POST) | Resumable Chunked Upload (1MB Chunks) | | :--- | :--- | :--- | | Success Rate (15MB Video - Lernce) | 12% (Fails repeatedly on dropout) | 100% (Resumes after dropout) | | Success Rate (5MB Image - Husomat) | 45% (Unstable) | 100% (Resumes instantly) | | Average Bandwidth Wasted | 32.4 MB (Due to repeating transfers) | 0.8 MB (Only retried single chunks) | | Total Upload Time (with 1 drop) | Failed (Timeouts) | ~52 seconds (Resumes smoothly) |
Under unstable cellular connections, the traditional single-POST approach causes massive user frustration. It wastes expensive data packages because every interruption throws away completed work. The chunked upload pipeline preserves every completed chunk, protecting users' mobile data budgets and driving overall platform conversion.
---
Leverage High-Performance Resilient Architectures
Building software systems that perform beautifully under infrastructure constraints is a core design standard. By chunking large file streams and implementing client-server status validation, we make applications responsive under any connection limit.
If you are looking to audit your web infrastructure, implement high-speed APIs, or design web and mobile apps optimized for regional environments, contact Dragonfly Soft to consult with our engineering team today.