A progress bar that jumps from 0% to 100% with nothing in between tells the user exactly one thing: something is probably happening. That's not good enough. Upload progress UI is the user's only feedback channel during a process that can take seconds or minutes. Bad feedback causes duplicate uploads (user clicks the button again), premature cancels ("it's stuck"), and support tickets ("the upload doesn't work"). Good feedback keeps users patient, informed, and confident.
Basic progress with Resumable.js events
Resumable.js provides the building blocks through its event system. The fileProgress event fires after each chunk completes, giving you the data to calculate and display progress.
const r = new Resumable({
target: '/api/upload',
chunkSize: 2 * 1024 * 1024,
simultaneousUploads: 3,
});
r.on('fileProgress', (file) => {
const percent = Math.floor(file.progress() * 100);
updateProgressBar(percent);
});
r.on('fileSuccess', (file) => {
showComplete(file.fileName);
});
r.on('fileError', (file, message) => {
showError(file.fileName, message);
});
The file.progress() method returns a value between 0 and 1 representing the fraction of chunks successfully uploaded. Multiply by 100 for a percentage. This is chunk-level granularity — if your file has 50 chunks, each completion bumps progress by 2%.
A basic HTML progress bar:
<div class="upload-progress">
<div class="progress-bar" role="progressbar"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div class="progress-fill" style="width: 0%"></div>
</div>
<span class="progress-text">0%</span>
</div>
function updateProgressBar(percent) {
const fill = document.querySelector('.progress-fill');
const text = document.querySelector('.progress-text');
fill.style.width = `${percent}%`;
fill.setAttribute('aria-valuenow', percent);
text.textContent = `${percent}%`;
}
This gets you a working progress bar. But users want more than a number — they want to know how long this is going to take.
Speed calculation and ETA
Raw progress percentage doesn't answer the question users actually care about: "How much longer?" Calculating upload speed and estimated time remaining requires tracking bytes over time.
class UploadSpeedTracker {
constructor(windowSize = 5) {
this.samples = [];
this.windowSize = windowSize;
this.lastBytes = 0;
this.lastTime = Date.now();
}
update(bytesUploaded) {
const now = Date.now();
const elapsed = (now - this.lastTime) / 1000; // seconds
if (elapsed < 0.1) return; // skip if too frequent
const bytesDelta = bytesUploaded - this.lastBytes;
const speed = bytesDelta / elapsed; // bytes per second
this.samples.push(speed);
if (this.samples.length > this.windowSize) {
this.samples.shift();
}
this.lastBytes = bytesUploaded;
this.lastTime = now;
}
getSpeed() {
if (this.samples.length === 0) return 0;
const sum = this.samples.reduce((a, b) => a + b, 0);
return sum / this.samples.length;
}
getETA(bytesRemaining) {
const speed = this.getSpeed();
if (speed <= 0) return Infinity;
return bytesRemaining / speed; // seconds
}
}
The rolling average (window of 5 samples) smooths out the jitter from individual chunk completions. Without smoothing, the displayed speed bounces wildly as chunks complete in bursts.
Wire it into Resumable.js events:
const tracker = new UploadSpeedTracker();
r.on('fileProgress', (file) => {
const totalBytes = file.size;
const uploadedBytes = file.progress() * totalBytes;
const remainingBytes = totalBytes - uploadedBytes;
tracker.update(uploadedBytes);
const speedBps = tracker.getSpeed();
const etaSeconds = tracker.getETA(remainingBytes);
const percent = Math.floor(file.progress() * 100);
updateUI({
percent,
speed: formatSpeed(speedBps),
eta: formatETA(etaSeconds),
});
});
function formatSpeed(bytesPerSecond) {
if (bytesPerSecond > 1048576) {
return `${(bytesPerSecond / 1048576).toFixed(1)} MB/s`;
}
return `${(bytesPerSecond / 1024).toFixed(0)} KB/s`;
}
function formatETA(seconds) {
if (!isFinite(seconds)) return 'Calculating...';
if (seconds < 60) return `${Math.ceil(seconds)}s remaining`;
const mins = Math.floor(seconds / 60);
const secs = Math.ceil(seconds % 60);
return `${mins}m ${secs}s remaining`;
}
A note on ETA accuracy: the first few seconds of an upload produce unreliable estimates because the sample window is too small. Show "Calculating..." until you have at least 3 samples.
State indicators: more than just a bar
An upload goes through multiple states, and each deserves distinct visual treatment:
| State | Visual Treatment |
|---|---|
| Idle | Neutral — file selected, waiting to start |
| Uploading | Animated progress bar, speed/ETA display |
| Paused | Dimmed progress bar, "Paused" label, resume button |
| Retrying | Pulsing/yellow indicator, "Retrying chunk..." message |
| Complete | Green checkmark, file details |
| Error | Red indicator, error message, retry button |
Distinguishing "retrying" from "uploading" is critical for chunked uploads. When Resumable.js retries a failed chunk, the overall progress bar stalls. Without a retry indicator, the user thinks the upload is frozen.
r.on('fileRetry', (file) => {
showRetryIndicator(file.fileName);
});
r.on('fileProgress', (file) => {
clearRetryIndicator(file.fileName);
// update progress as normal
});
The pause and resume methods should also update the UI state:
document.getElementById('pause-btn').addEventListener('click', () => {
r.pause();
setUIState('paused');
});
document.getElementById('resume-btn').addEventListener('click', () => {
r.upload();
setUIState('uploading');
});
Multi-file progress
When uploading multiple files, users need both per-file and overall progress. Per-file progress bars show individual status. Overall progress answers "how much of everything is done?"
r.on('fileProgress', (file) => {
// Per-file progress
updateFileProgress(file.uniqueIdentifier, Math.floor(file.progress() * 100));
});
r.on('progress', () => {
// Overall progress across all files
const overallPercent = Math.floor(r.progress() * 100);
updateOverallProgress(overallPercent);
});
For many files, rendering individual progress bars for each becomes noisy. A practical pattern: show per-file progress for the currently uploading files (based on simultaneousUploads) and a summary count for the rest. "Uploading 3 of 47 files — 12% overall."
Preventing duplicate actions
Users click buttons twice. They click "Upload" while an upload is in progress. They close the tab during upload. Defensive UI prevents these problems.
const uploadBtn = document.getElementById('upload-btn');
r.on('uploadStart', () => {
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading...';
});
r.on('complete', () => {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
});
// Warn before closing the tab during upload
window.addEventListener('beforeunload', (e) => {
if (r.isUploading()) {
e.preventDefault();
e.returnValue = '';
}
});
For cancel actions, always confirm:
document.getElementById('cancel-btn').addEventListener('click', () => {
if (confirm('Cancel the upload? Progress will be lost.')) {
r.cancel();
resetUI();
}
});
Completion UX
The moment after upload completes is often neglected. A bare "100%" doesn't tell the user what happened or what to do next.
Good completion feedback includes:
- A clear success indicator (green checkmark, "Upload complete" text).
- File details: name, size, time taken.
- Next-step guidance: "Processing your file..." if there's server-side processing, or a link/preview if the file is immediately available.
- Option to upload more files.
r.on('fileSuccess', (file) => {
const duration = ((Date.now() - uploadStartTime) / 1000).toFixed(1);
showSuccess({
name: file.fileName,
size: formatBytes(file.size),
duration: `${duration}s`,
});
});
Mobile considerations
Mobile upload UX has unique constraints:
- Screen space is limited. A full progress bar with speed, ETA, file list, and control buttons may not fit. Prioritize: progress bar, percentage, and a single cancel/pause toggle.
- Keyboard interaction. When the user opens a file picker, the keyboard may stay visible briefly. Ensure the progress UI is visible above the keyboard area.
- Background behavior. On mobile browsers, backgrounding the tab throttles JavaScript. Warn users to keep the tab active during upload, or consider implementing background sync for PWAs for critical uploads.
Accessibility
Upload progress should work for all users, including those using screen readers.
<div role="progressbar"
aria-valuenow="45"
aria-valuemin="0"
aria-valuemax="100"
aria-label="File upload progress">
<div class="progress-fill" style="width: 45%"></div>
</div>
Key accessibility practices:
- Use
role="progressbar"witharia-valuenow,aria-valuemin, andaria-valuemax. - Announce state changes with
aria-live="polite"regions: "Upload started," "Retrying," "Upload complete." - Ensure the cancel button is focusable and keyboard-accessible.
- Don't rely solely on color to indicate state — pair colors with icons or text labels.
<div aria-live="polite" class="sr-only" id="upload-status">
Upload in progress: 45% complete
</div>
Update the live region text when meaningful state changes occur (not on every percentage tick — that would overwhelm screen readers). Announce at 25%, 50%, 75%, completion, and any error/retry events.
Putting it all together
A well-built upload UI combines all of these elements: a progress bar driven by Resumable.js events, speed and ETA from a rolling average tracker, distinct state indicators for each upload phase, defensive controls to prevent duplicate actions, and accessible markup that works for everyone.
The effort is worth it. Users who can see exactly what's happening during an upload — and who trust that the interface is telling them the truth — don't file support tickets, don't retry uploads unnecessarily, and don't abandon the process out of frustration.
Start with the basic uploader for the upload mechanics, then layer in the feedback patterns from this guide. For framework-specific implementations, see the React integration or the modern frameworks guide for component-level patterns. For drag-and-drop interactions, the same progress patterns apply — just wire them to a different trigger element.
