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
- A backend endpoint that responds with
Content-Type: text/event-streamand flushes incrementally. - The endpoint emits named events (
progress,done,error) and anid:per message for replay. - CORS configured if the stream is cross-origin (
EventSourcehonorswithCredentials).
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 aGETwithAccept: text/event-streamand keeps the socket open.addEventListener("progress", ...)matches the serverβsevent: progressfield. Without a named event, payloads arrive on the defaultmessageevent instead.JSON.parse(ev.data)βdatais always the raw text afterdata:. SSE has no built-in JSON; you encode and decode it yourself.source.close()ondoneis 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 fromLast-Event-ID.- The
errorhandler distinguishes two cases. A transport drop fireserrorwith nodataandreadyState === CONNECTINGwhile the browser reconnects automatically β you do nothing. An application failure is an explicitevent: errorwith 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.
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.