Real-Time Upload Progress Events

A progress bar is a promise to the user, and a bar that freezes at 99% or leaps from 30% to done breaks that promise even when the transfer succeeded. Honest progress means separating two signals β€” bytes leaving the browser, which the client can measure, and server-side work like transcoding, which only the backend can report β€” then combining them into one throttled, monotonic display. This topic lives under Frontend UX, Chunking & Progress Tracking and reads the same committed offset produced by resumable upload state machines.

Prerequisites

  • [ ] Node 20+ with a modern ESM bundler
  • [ ] TypeScript 5.x, strict enabled
  • [ ] An endpoint that streams processing events (SSE) or accepts a WebSocket
  • [ ] Familiarity with XMLHttpRequest (Fetch still cannot report request-body upload progress)
  • [ ] A UI framework where you can batch state writes to requestAnimationFrame

How real-time progress works

There are two clocks. The transfer clock ticks as request-body bytes leave the browser; xhr.upload.onprogress reports loaded/total per request, and with concurrent chunks you sum loaded across all in-flight requests against the file’s true size. The processing clock ticks on the server β€” virus scan, thumbnail, transcode β€” and the client only learns about it through a push channel. Server-Sent Events are the lightweight default for one-way push; a WebSocket is warranted only when the server must also send control commands back.

The trap is firing a UI update on every event. A fast link emits thousands of progress events per second; rendering on each one starves the main thread. Coalesce them: write the latest value into a ref and flush once per animation frame.

Progress event sequence across transfer and processing A sequence diagram showing the browser sending chunks with upload progress events, storage acknowledging, and the server pushing processing progress and completion over a server-sent events channel. Browser UI Storage Server (SSE) PUT chunk (onprogress) 200 + ETag PUT final chunk open EventSource event: progress 40% event: progress 90% event: done
Transfer progress comes from upload.onprogress during PUTs; processing progress arrives afterward as server-pushed SSE events until done.

Step 1: Capture transfer progress per chunk with XHR

fetch cannot report request-body upload progress in current browsers, so the bytes leg uses XMLHttpRequest. Wrap it in a promise and emit loaded/total on each progress event.

export interface ChunkTick { index: number; loaded: number; total: number; }

export function putChunk(
  url: string,
  blob: Blob,
  index: number,
  onTick: (t: ChunkTick) => void,
): Promise<string> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onTick({ index, loaded: e.loaded, total: e.total });
    };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(xhr.getResponseHeader("ETag") ?? "");
      } else {
        reject(new Error(`HTTP ${xhr.status}`));
      }
    };
    xhr.onerror = () => reject(new Error("network error"));
    xhr.send(blob);
  });
}

Expected: during a 5 MB chunk on a slow link you receive a stream of ticks like { index: 0, loaded: 1048576, total: 5242880 } climbing to loaded === total.

Step 2: Aggregate progress across concurrent chunks

With several chunks in flight, per-request percentages are meaningless to the user β€” they want one number. Track loaded per chunk index in a map and divide the sum by the file’s true size.

export class ProgressAggregator {
  private loaded = new Map<number, number>();
  constructor(private readonly fileSize: number) {}

  update(tick: ChunkTick): number {
    this.loaded.set(tick.index, tick.loaded);
    let sum = 0;
    for (const bytes of this.loaded.values()) sum += bytes;
    // Clamp: late ticks can briefly overshoot during retries.
    return Math.min(1, sum / this.fileSize);
  }

  get fraction(): number {
    let sum = 0;
    for (const bytes of this.loaded.values()) sum += bytes;
    return Math.min(1, sum / this.fileSize);
  }
}

Expected: with a 20 MB file and four 5 MB chunks at loaded 5M, 5M, 2.5M, 0, update returns 0.625.

Step 3: Throttle UI writes to one per frame

Coalesce the firehose of ticks into a single render per animation frame. Store the latest value, schedule one flush, and let intermediate values be overwritten harmlessly.

export function rafThrottle(write: (value: number) => void): (v: number) => void {
  let pending: number | null = null;
  let scheduled = false;

  return (value: number) => {
    pending = value;
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      if (pending !== null) write(pending);
      pending = null;
    });
  };
}

Expected: 2,000 calls in one frame produce exactly one write, with the most recent value.

Step 4: Receive processing progress over SSE

Once bytes are stored, the server keeps working. Subscribe with EventSource, which auto-reconnects and parses named events for you. Map progress events into the same bar and done into completion.

export function subscribeProcessing(
  url: string,
  onProgress: (fraction: number) => void,
  onDone: () => void,
): () => void {
  const es = new EventSource(url, { withCredentials: true });

  es.addEventListener("progress", (e) => {
    const { fraction } = JSON.parse((e as MessageEvent).data) as { fraction: number };
    onProgress(Math.min(1, Math.max(0, fraction)));
  });
  es.addEventListener("done", () => {
    onDone();
    es.close(); // stop reconnect attempts once finished
  });
  es.onerror = () => {
    // EventSource reconnects automatically; log for observability only.
    console.warn("SSE transient error; awaiting auto-reconnect");
  };
  return () => es.close();
}

Expected: a server sending event: progress\ndata: {"fraction":0.4} advances the bar to 40% during transcoding; an event: done closes the stream and finalizes the UI. The choice between this and a WebSocket is covered below and in the dedicated comparison.

Step 5: Combine the two clocks into one monotonic bar

Users see a single bar. Split it: transfer fills the first portion, processing fills the rest, and the combined value never moves backward.

export class CombinedProgress {
  private transfer = 0;
  private processing = 0;
  private shown = 0; // monotonic guard
  constructor(private readonly transferWeight = 0.7) {}

  setTransfer(f: number): number { this.transfer = f; return this.value(); }
  setProcessing(f: number): number { this.processing = f; return this.value(); }

  private value(): number {
    const combined =
      this.transfer * this.transferWeight +
      this.processing * (1 - this.transferWeight);
    this.shown = Math.max(this.shown, combined); // never regress
    return this.shown;
  }
}

Expected: full transfer with no processing yet shows 0.7; processing then fills the remaining 0.3 smoothly to 1.0, and a stale late tick can never drag the bar backward.

Configuration reference

Option Type Default Effect
transferWeight number (0–1) 0.7 Share of the bar allocated to byte transfer vs processing
flushStrategy "raf" | "interval" "raf" How UI writes are throttled
channel "sse" | "websocket" "sse" Push transport for processing progress
withCredentials boolean true Send cookies on the EventSource/WebSocket handshake
clamp boolean true Bound fractions to [0,1] and enforce monotonicity

Edge cases & gotchas

lengthComputable is false

On some proxies or when the body is chunked-encoded without a length, e.lengthComputable is false and e.total is 0. Fall back to an indeterminate spinner for that chunk and rely on the aggregate of the chunks that do report.

SSE connection cap per origin

Browsers limit concurrent HTTP/1.1 connections per origin (commonly six), and each EventSource consumes one. Open a single processing stream per upload session rather than one per chunk, or serve over HTTP/2 where the cap effectively disappears.

Progress that exceeds 100%

Retried chunks re-send bytes, so a naive sum can briefly exceed fileSize. Always clamp the fraction to [0, 1] and key loaded by chunk index so a retry overwrites rather than adds.

Buffering proxies stalling SSE

Some reverse proxies buffer responses and hold SSE events until the connection closes. Disable buffering for the event endpoint (for nginx, X-Accel-Buffering: no) so events flush in real time.

Verification

Watch raw SSE frames with curl, then assert the aggregator math in a quick script.

# Stream processing events and confirm they arrive incrementally.
curl -N -H 'Accept: text/event-stream' \
  https://api.example.com/uploads/abc123/events
# event: progress
# data: {"fraction":0.4}
import { ProgressAggregator } from "./aggregator.js";

const agg = new ProgressAggregator(20 * 1024 * 1024);
agg.update({ index: 0, loaded: 5 * 1024 * 1024, total: 5 * 1024 * 1024 });
agg.update({ index: 1, loaded: 5 * 1024 * 1024, total: 5 * 1024 * 1024 });
console.assert(Math.abs(agg.fraction - 0.5) < 1e-6, "two of four chunks => 50%");

FAQ

Why does the Fetch API not report upload progress?

The Fetch standard only recently gained streaming request bodies, and browser support for using them to observe upload progress is still incomplete. XMLHttpRequest’s upload.onprogress remains the only reliable cross-browser way to measure request-body bytes, so production uploaders keep XHR for the data leg and use fetch for everything else.

Should I use Server-Sent Events or WebSockets for progress?

Default to SSE: it is one-way, rides ordinary HTTP, and reconnects automatically β€” ideal for pushing processing progress to the client. Choose a WebSocket only when the server must also receive control messages (pause, cancel, throttle) mid-upload. The full trade-off is laid out in the dedicated comparison of WebSockets versus SSE for upload progress.

How do I keep the bar from jumping or going backward?

Aggregate loaded across chunks against the true file size, clamp to [0,1], and pass the value through a monotonic guard so a late or retried tick can never lower the displayed number. Throttle writes to one per animation frame so the motion stays smooth.

Why does my bar stick at 100% transfer but the upload is not done?

Because transfer finished but server-side processing has not. Reserve part of the bar for the processing clock and only declare completion on the server’s done event β€” the same authoritative-completion principle used by resumable upload state machines.