Aggregating Progress Across Multiple Files
Compute batch progress as confirmed bytes over total bytes, not as the average of per-file percentages: keep, per file, the bytes the server has acknowledged plus the bytes of the request in flight, sum them, and divide by the sum of sizes of files that are still part of the batch. Remove cancelled and permanently failed files from both numerator and denominator, cap the in-flight contribution so a retried chunk cannot count twice, and never let the displayed value decrease — hold it until the true value catches up. If uploads are followed by server processing, split the bar into an upload phase and a processing phase with its own label, rather than stopping at 100 % while work continues.
Averaging percentages makes a batch of one 2 GB video and nine photos look 90 % done when the photos finish, even though almost all the work remains. Summing sent bytes naively double-counts retried chunks and makes the bar jump backwards when a request fails. Users read a stuck or reversing bar as “broken” and a leaping one as “lying”. This page belongs to realtime upload progress events in frontend UX, chunking and progress tracking; the same number drives showing accurate time remaining estimates and screen-reader milestones in announcing upload progress to screen readers.
When to use this approach
- Users upload several files at once and see a single overall bar or percentage.
- Files vary widely in size.
- Uploads retry, can be cancelled individually, or are processed after upload.
Prerequisites
- Per-file progress events: XHR
upload.onprogress, or per-chunk acknowledgements from a chunked uploader. - Per-file lifecycle events: started, retried, completed, failed, cancelled.
- A render loop that updates at most once per frame (rendering smooth progress bars without jank).
Why averaging percentages misleads
Implementation
type FileState = "queued" | "uploading" | "done" | "failed" | "cancelled";
interface Track {
size: number;
confirmed: number; // bytes acknowledged by the server (completed chunks or finished file)
inFlight: number; // bytes of the current request reported by progress events
inFlightCap: number; // size of the current request
state: FileState;
processing?: number; // 0..1 for a server-side processing phase, if any
}
export class BatchProgress {
private files = new Map<string, Track>();
private shown = 0; // monotonic value for display
add(id: string, size: number) { this.files.set(id, { size, confirmed: 0, inFlight: 0, inFlightCap: 0, state: "queued" }); }
startRequest(id: string, bytes: number) { const t = this.files.get(id)!; t.state = "uploading"; t.inFlight = 0; t.inFlightCap = bytes; }
requestProgress(id: string, loaded: number) { const t = this.files.get(id)!; t.inFlight = Math.min(loaded, t.inFlightCap); }
requestDone(id: string, bytes: number) { const t = this.files.get(id)!; t.confirmed = Math.min(t.size, t.confirmed + bytes); t.inFlight = 0; t.inFlightCap = 0; }
requestFailed(id: string) { const t = this.files.get(id)!; t.inFlight = 0; t.inFlightCap = 0; } // bytes return to "not sent"
resumeAt(id: string, serverOffset: number) { const t = this.files.get(id)!; t.confirmed = Math.min(t.size, serverOffset); }
finish(id: string) { const t = this.files.get(id)!; t.confirmed = t.size; t.inFlight = 0; t.state = "done"; }
fail(id: string) { this.files.get(id)!.state = "failed"; }
cancel(id: string) { this.files.get(id)!.state = "cancelled"; }
setProcessing(id: string, fraction: number) { this.files.get(id)!.processing = Math.max(0, Math.min(1, fraction)); }
/** Raw values: what is true right now. */
snapshot() {
let total = 0, sent = 0, active = 0, done = 0, failed = 0;
for (const t of this.files.values()) {
if (t.state === "cancelled") continue;
if (t.state === "failed") { failed++; continue; } // out of the denominator until retried
total += t.size;
sent += Math.min(t.size, t.confirmed + t.inFlight);
if (t.state === "uploading") active++;
if (t.state === "done") done++;
}
return { total, sent, fraction: total ? sent / total : 1, active, done, failed };
}
/** Display value: never decreases, so the bar does not jump backwards. */
display(): number {
const f = this.snapshot().fraction;
this.shown = Math.max(this.shown, f);
return this.shown;
}
/** Call when the batch composition changes a lot (files removed or retried), to allow a reset. */
rebase() { this.shown = this.snapshot().fraction; }
}
Wiring it to XHR-based uploads:
function uploadWithProgress(id: string, url: string, body: Blob, bp: BatchProgress) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
bp.startRequest(id, body.size);
xhr.upload.onprogress = (e) => bp.requestProgress(id, e.loaded);
xhr.onload = () => (xhr.status < 300 ? (bp.requestDone(id, body.size), resolve()) : (bp.requestFailed(id), reject(new Error(String(xhr.status)))));
xhr.onerror = () => { bp.requestFailed(id); reject(new Error("network")); };
xhr.send(body);
});
}
Line-by-line on the decisions that matter
- Confirmed plus in-flight. Confirmed bytes are safe; in-flight bytes move smoothly between acknowledgements. Keeping them separate lets a failed request drop its in-flight share without touching confirmed progress.
- Capping in-flight at the request size. Some browsers report
loadedslightly above the body size (headers, encoding). The cap stops a single request from counting more than its own bytes. resumeAtfrom the server. After a resume, the server’s offset replaces the local count. That handles partial chunks that arrived before a failure and prevents double counting.- Failed and cancelled files leave the denominator. A failed 2 GB file would otherwise freeze the bar far below 100 % forever. Removing it lets the bar complete for the files that succeeded; the failure is reported separately. If the user retries, the file re-enters and
rebase()resets the display. - A monotonic display value. The true fraction can dip when a request fails and its in-flight bytes vanish. Holding the displayed value until the truth catches up keeps the bar calm; the dip is usually recovered within seconds.
- Counts alongside bytes. “3 of 12 files” is useful text next to a bytes-based bar. It answers a different question — how many are done — and does not mislead when paired with the right bar.
Upload phase and processing phase
Many products upload and then process: virus scanning, transcoding, thumbnail generation. If the bar reaches 100 % and then the page waits another minute, users assume something broke. Show two phases. The upload phase is bytes-based as above. The processing phase uses progress reported by the server — via server-sent events or polling — or an indeterminate indicator with a descriptive label when the server cannot measure progress. Tell users whether they can leave: if processing continues server-side, say so.
Avoid folding processing into a single bar with a guessed split (“upload is 80 %, processing is 20 %”). The split is wrong for every file that is not average, and it makes the upload portion lie about bytes.
Many small files
For batches of hundreds of tiny files, bytes are a poor proxy for time: each file costs a URL request and an upload request, and those round trips dominate. Add a fixed weight per file — a byte equivalent of the per-request cost — to both the file’s size and its confirmed bytes on completion. The bar then advances per file as well as per byte, matching what users experience.
Configuration gotchas
The bar reaches 100 % before uploads finish. Progress events report bytes handed to the network stack, not bytes the server has. The last chunk’s loaded can reach its total seconds before the response arrives. Hold at 99 % until requestDone for the final request.
The bar jumps backwards on retries. In-flight bytes from the failed request were counted as sent. Use the monotonic display value and keep in-flight separate from confirmed.
The total changes as files are added mid-batch. Adding files increases the denominator and lowers the true fraction. Either rebase when files are added (acceptable with a visible “+3 files” note) or show the new files as a separate batch.
Progress events stop for large chunks on some mobile browsers. Some Android WebViews fire upload progress only at start and end. Smaller chunks give more frequent confirmed updates.
Verification
- Batch one 1 GB file with nine 5 MB files: after the small files finish, the bar shows about 5 %, not 90 %.
- Force a chunk failure (DevTools request blocking): the displayed bar pauses rather than dropping.
- Cancel the large file mid-batch: the bar jumps up to reflect the remaining files and completes when they do.
- With processing enabled, the label changes to “Processing” at the end of the upload phase instead of sitting at 100 %.
Frequently Asked Questions
Should the overall bar include failed files?
No; failed files cannot contribute more bytes, so including them freezes the bar. Show failures separately with a count and actions, and let the bar complete for the rest.
Is fetch able to report upload progress?
Not reliably across browsers yet. XHR’s upload.onprogress remains the standard source; with fetch, progress comes from chunk completions. See fetch upload progress vs XMLHttpRequest.
What should the bar show before any bytes have moved?
Show the batch as waiting, with a label such as “Preparing 12 files”, rather than an empty bar at 0 %. URL requests, hashing and client-side resizing can take noticeable time before the first byte is sent, and a labelled waiting state explains the pause.
How often should the displayed value update?
Compute on every event, render at most once per animation frame, and round the visible percentage to whole numbers; sub-percent changes add motion without information.