Streaming Upload Progress with Server-Sent Events

Server-Sent Events push server-side processing progress to the browser over one long-lived HTTP connection, and EventSource reconnects automatically with Last-Event-ID so the UI never goes stale.

The bytes-uploaded number from an XMLHttpRequest only tells half the story. Once a file lands, the backend still transcodes, scans, and stores it β€” work the browser cannot see. Streaming that server-authoritative progress is a core concern of realtime upload progress events within frontend UX, chunking and progress tracking. SSE is the lightest fit: a single GET returns text/event-stream, the server writes data: lines as work proceeds, and EventSource handles parsing and reconnection. This guide consumes processing events emitted by the backend validation and cloud storage architecture.

When to use this approach

  • You need one-directional, server-to-client updates (progress, status, completion) and the client never streams data back on the same channel.
  • You want automatic reconnection and event replay for free, without writing a heartbeat or backoff loop.
  • You run over HTTP/2 (or limit concurrent streams) so the per-host SSE connection cap does not starve other requests.

Prerequisites

  1. A backend endpoint that responds with Content-Type: text/event-stream and flushes incrementally.
  2. The endpoint emits named events (progress, done, error) and an id: per message for replay.
  3. CORS configured if the stream is cross-origin (EventSource honors withCredentials).

Implementation

The client opens an EventSource, subscribes to named events, surfaces the parsed payload, and tears down cleanly on completion. EventSource reconnects on its own; the code only resets state and stops on terminal events.

export interface ProgressPayload {
  jobId: string;
  stage: "scanning" | "transcoding" | "storing";
  percent: number;
}

export interface ProgressHandlers {
  onProgress: (p: ProgressPayload) => void;
  onDone: (jobId: string) => void;
  onFailure: (message: string) => void;
}

export function trackProcessing(
  jobId: string,
  handlers: ProgressHandlers,
): () => void {
  const url = `/api/jobs/${encodeURIComponent(jobId)}/events`;
  const source = new EventSource(url, { withCredentials: true });

  // Named event: server writes "event: progress\n"
  source.addEventListener("progress", (ev) => {
    const data = JSON.parse((ev as MessageEvent).data) as ProgressPayload;
    handlers.onProgress(data);
  });

  source.addEventListener("done", (ev) => {
    const data = JSON.parse((ev as MessageEvent).data) as { jobId: string };
    handlers.onDone(data.jobId);
    source.close(); // terminal: stop reconnecting
  });

  source.addEventListener("error", (ev) => {
    // Distinguish a transport drop (will auto-reconnect) from an app error event.
    const msgEvent = ev as MessageEvent;
    if (msgEvent.data) {
      const data = JSON.parse(msgEvent.data) as { message: string };
      handlers.onFailure(data.message);
      source.close();
      return;
    }
    if (source.readyState === EventSource.CLOSED) {
      handlers.onFailure("Connection closed by server.");
    }
    // readyState === CONNECTING means the browser is already reconnecting; do nothing.
  });

  // Return a disposer so callers can cancel from a component unmount.
  return () => source.close();
}

// --- Usage ---
const bar = document.querySelector<HTMLProgressElement>("#bar")!;
const label = document.querySelector<HTMLSpanElement>("#stage")!;

const stop = trackProcessing("job_8f3a", {
  onProgress: ({ stage, percent }) => {
    bar.value = percent;
    label.textContent = stage;
  },
  onDone: (id) => {
    label.textContent = "complete";
    console.log("[sse] finished", id);
  },
  onFailure: (msg) => console.error("[sse]", msg),
});

window.addEventListener("beforeunload", stop);

Line-by-line of the critical parts

  • new EventSource(url, { withCredentials: true }) opens the stream and sends cookies cross-origin. The browser issues a GET with Accept: text/event-stream and keeps the socket open.
  • addEventListener("progress", ...) matches the server’s event: progress field. Without a named event, payloads arrive on the default message event instead.
  • JSON.parse(ev.data) β€” data is always the raw text after data:. SSE has no built-in JSON; you encode and decode it yourself.
  • source.close() on done is mandatory. SSE has no server-initiated close that stops reconnection; if you do not close, the browser reconnects after the stream ends and the server replays from Last-Event-ID.
  • The error handler distinguishes two cases. A transport drop fires error with no data and readyState === CONNECTING while the browser reconnects automatically β€” you do nothing. An application failure is an explicit event: error with a JSON body, which is terminal.
  • The returned disposer lets a UI framework cancel the stream on unmount, preventing leaked connections.

The diagram shows the wire format and the automatic reconnect with replay.

Server-Sent Events stream and reconnection The server streams id, event, and data lines; on a drop the browser reconnects sending Last-Event-ID so the server replays from the next event. EventSource Job server GET /events (Accept: text/event-stream) id:1 event:progress data:{percent:30} id:2 event:progress data:{percent:55} connection drops reconnect Last-Event-ID: 2 id:3 event:done data:{jobId}
The SSE wire format and how Last-Event-ID resumes the stream after a drop.

Configuration gotchas

Stream never updates; events arrive in one burst at the end. A reverse proxy is buffering the response. Set X-Accel-Buffering: no for Nginx and disable gzip on text/event-stream; the server must also call flush() after each write.

EventSource’s onerror fires repeatedly and never recovers. The server is sending a non-200 status or wrong content type. EventSource only treats 200 + text/event-stream as valid; a 502 or text/plain triggers an endless reconnect loop. Verify the response headers exactly.

Reconnection storms after deploy. When the server restarts, every client reconnects at once. Send a retry: 5000 line to set the client backoff, and add jitter server-side before accepting the reconnect to spread the load.

Six-connection cap on HTTP/1.1. Browsers allow only ~6 concurrent connections per host; several SSE streams plus normal requests deadlock. Serve over HTTP/2 (which multiplexes) or consolidate progress for many jobs onto one stream.

Verification

Confirm the stream emits correctly framed events and honors replay with curl:

# Watch the raw event frames β€” each block ends with a blank line
curl -N -H "Accept: text/event-stream" https://api.example.com/api/jobs/job_8f3a/events
# Expected, streaming line by line:
# id: 1
# event: progress
# data: {"jobId":"job_8f3a","stage":"scanning","percent":30}
#
# Resume from event 2 to prove Last-Event-ID replay works:
curl -N -H "Accept: text/event-stream" -H "Last-Event-ID: 2" \
  https://api.example.com/api/jobs/job_8f3a/events

FAQ

Does SSE report bytes uploaded, or only server processing?

SSE is a server-to-client channel, so it reports what the server knows β€” processing stages, queue position, completion. Track raw upload bytes with XMLHttpRequest’s progress event on the upload itself, then switch to SSE for post-upload processing.

How is reconnection handled?

EventSource reconnects automatically after a drop, waiting the interval from the last retry: line (default a few seconds). It sends the last seen id as the Last-Event-ID header, so a server that tracks ids can replay missed events.

When should I pick WebSockets instead?

When the client also needs to push messages on the same connection, or you need binary frames. SSE is simpler and reconnects for free for one-way progress. See WebSockets vs SSE for upload progress for the full trade-off.

Can I send the job authorization in a header?

EventSource cannot set custom request headers. Use a cookie (with withCredentials: true) or a short-lived token in the query string. Rotate query-string tokens because they leak into server logs.