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

  1. Per-file progress events: XHR upload.onprogress, or per-chunk acknowledgements from a chunked uploader.
  2. Per-file lifecycle events: started, retried, completed, failed, cancelled.
  3. A render loop that updates at most once per frame (rendering smooth progress bars without jank).

Why averaging percentages misleads

Average of percentages versus bytes-weighted progress A batch of nine 5 MB photos and one 2 GB video. When the photos are done and the video is 5 percent uploaded, the average of percentages shows 90.5 percent, while bytes-weighted progress shows about 7 percent, which matches the remaining time. Nine photos done, video at 5 % average of % 90 % bytes-weighted 7 % sent: 9 × 5 MB + 5 % of 2,048 MB ≈ 147 MB total: 45 MB + 2,048 MB = 2,093 MB → 147 / 2,093 ≈ 7 % at a steady rate, 93 % of the time is still to come The first bar tells the user they are nearly done; the second tells the truth.
Weight by bytes so the bar tracks remaining work, not remaining file count.

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 loaded slightly above the body size (headers, encoding). The cap stops a single request from counting more than its own bytes.
  • resumeAt from 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

Two-phase progress for upload followed by processing The bar is labelled Uploading while bytes are sent and fills from 0 to 100 percent. Then the label changes to Processing and a second bar, or an indeterminate indicator, shows server-side work such as transcoding. The overall status only reads complete after processing finishes. Don't let 100 % mean "wait some more" Uploading 2.1 GB of 2.1 GB · done Processing video 40 % · 2 photos ready Status: "Uploaded. Preparing your video — you can leave this page." If processing has no measurable progress, show an indeterminate state with a clear label instead.
Separate phases keep each number honest and tell the user what they are waiting for.

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

Adding per-file overhead for batches of small files For a batch of 500 small files, each file costs request setup time as well as bytes. Adding a fixed overhead of about 64 KB equivalent per file to both sent and total makes the bar track elapsed time better than bytes alone. When requests, not bytes, dominate bytes only 500 × 20 KB files bar races, then stalls on latency bytes + per-file weight each file counts size + 64 KB bar tracks elapsed time Measure your own per-request cost; 50–100 KB-equivalent is typical on broadband.
A small constant per file keeps the bar honest when latency dominates.

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.