Streaming Upload Progress with Server-Sent Events
Server-Sent Events push server-side processing progress to the browser over one long-lived HTTP response, and EventSource reconnects on its own with Last-Event-ID so the UI never goes stale.
The bytes-uploaded number from an XMLHttpRequest only tells half the story. Once the last byte lands, the backend still scans, transcodes, and indexes the file — work the browser cannot observe. 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: one GET returns text/event-stream, the server appends data: lines as work proceeds, and EventSource handles framing, event dispatch and reconnection for you. The events themselves usually originate in post-upload media transcoding or automated virus scanning.
When to use this approach
- Updates are one-directional. The server reports; the client never needs to push a message back on the same channel. If it does, read WebSockets vs SSE for upload progress before committing.
- You want automatic reconnection and event replay without writing a heartbeat, a backoff loop, or a resume cursor by hand.
- Payloads are small JSON blobs at a handful of events per second. SSE is UTF-8 text only; binary frames mean base64 and a 33% overhead.
Prerequisites
- Node 20+ (or any runtime that can hold a response open and flush incrementally) and a bundler that emits ESM.
- A job identifier minted when the upload starts, so the stream and the upload refer to the same work item.
- A reverse proxy you can configure — buffering and idle timeouts are the two things that break SSE in production.
- CORS configured if the stream is cross-origin; see CORS configuration for uploads.
How the wire format actually works
SSE is not a framing protocol in the WebSocket sense. It is a plain text body parsed line by line. The browser reads UTF-8 lines separated by \n, \r\n or a bare \r, splits each on the first colon, and accumulates fields into a buffer. A blank line dispatches whatever has accumulated; anything still in the buffer when the connection drops is discarded.
Only four field names mean anything — event, data, id, retry. Every other name is silently ignored, which is what makes a line beginning with a colon a comment. A single space after the colon is stripped, so data: {"a":1} and data:{"a":1} are identical. Multiple data: lines within one event are joined with a newline, and the trailing newline is removed before dispatch. Crucially, an event with an empty data buffer is not dispatched at all: event: ping on its own fires nothing, which is exactly why keep-alives are written as comments rather than as named events.
Implementation
The server
The server holds the response open, replays anything the client missed, and writes a comment every 15 seconds so no intermediary decides the connection is idle. This example keeps a bounded in-memory history per job; in a multi-process deployment, back it with Redis or read it from the job row instead.
// sse-server.ts — Node 20+, ESM. Run with: node --experimental-strip-types sse-server.ts
import { createServer } from "node:http";
import { EventEmitter } from "node:events";
export interface JobEvent {
id: number;
name: "progress" | "done" | "error";
data: unknown;
}
const KEEPALIVE_MS = 15_000; // shorter than any proxy read timeout on the path
const RETRY_MS = 5_000; // client-side reconnect delay
const REPLAY_LIMIT = 100; // per-job ring buffer depth
const bus = new EventEmitter();
const history = new Map<string, JobEvent[]>();
let seq = 0;
/** Call this from the scanner, transcoder or indexer as each stage advances. */
export function publish(jobId: string, name: JobEvent["name"], data: unknown): void {
const event: JobEvent = { id: ++seq, name, data };
const log = history.get(jobId) ?? [];
log.push(event);
if (log.length > REPLAY_LIMIT) log.shift();
history.set(jobId, log);
bus.emit(jobId, event);
}
function frame(event: JobEvent): string {
// JSON.stringify never emits a raw newline, so one data line is always enough.
return `id: ${event.id}\nevent: ${event.name}\ndata: ${JSON.stringify(event.data)}\n\n`;
}
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const match = /^\/api\/jobs\/([\w-]+)\/events$/.exec(url.pathname);
if (!match) {
res.writeHead(404).end();
return;
}
const jobId = match[1];
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform", // no-transform blocks proxy re-compression
"Connection": "keep-alive", // ignored, and harmless, on HTTP/2
"X-Accel-Buffering": "no", // tells Nginx not to buffer the body
});
res.flushHeaders();
res.socket?.setNoDelay(true); // Nagle would sit on 80-byte frames for 40ms
res.socket?.setTimeout(0); // never idle-close the response socket
res.write(`retry: ${RETRY_MS}\n\n`);
// Replay whatever this client missed. EventSource cannot set headers, so accept
// a query-string fallback for polyfills and for curl-based debugging.
const lastSeen = Number(
req.headers["last-event-id"] ?? url.searchParams.get("lastEventId") ?? 0,
);
for (const past of history.get(jobId) ?? []) {
if (past.id > lastSeen) res.write(frame(past));
}
const onEvent = (event: JobEvent) => {
res.write(frame(event));
};
bus.on(jobId, onEvent);
const keepAlive = setInterval(() => {
res.write(": keep-alive\n\n");
}, KEEPALIVE_MS);
const cleanup = () => {
clearInterval(keepAlive);
bus.off(jobId, onEvent);
};
res.on("close", cleanup);
res.on("error", cleanup);
});
server.requestTimeout = 0; // Node 18+ defaults to 300_000 and would cut long streams
server.headersTimeout = 0;
server.listen(8080);
The client
EventSource parses the stream and dispatches by event name. The code below adds the one thing the browser will not do for you: recover from a fatal handshake failure, which never retries automatically.
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;
}
const MAX_FATAL_RETRIES = 4;
export function trackProcessing(jobId: string, handlers: ProgressHandlers): () => void {
const url = `/api/jobs/${encodeURIComponent(jobId)}/events`;
let source: EventSource | null = null;
let fatalRetries = 0;
let reopenTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
const dispose = () => {
disposed = true;
clearTimeout(reopenTimer);
source?.close();
source = null;
};
const open = () => {
source = new EventSource(url, { withCredentials: true });
source.addEventListener("open", () => {
fatalRetries = 0; // a successful handshake resets the budget
});
source.addEventListener("progress", (ev) => {
handlers.onProgress(JSON.parse((ev as MessageEvent).data) as ProgressPayload);
});
source.addEventListener("done", (ev) => {
const { jobId: finished } = JSON.parse((ev as MessageEvent).data) as { jobId: string };
handlers.onDone(finished);
dispose(); // terminal — without this the browser reconnects and replays
});
source.addEventListener("error", (ev) => {
const raw = (ev as MessageEvent).data;
if (typeof raw === "string" && raw.length > 0) {
// Application failure: the server sent `event: error` with a JSON body.
handlers.onFailure((JSON.parse(raw) as { message: string }).message);
dispose();
return;
}
// Transport failure. CONNECTING means the browser is already retrying itself.
if (!source || source.readyState === EventSource.CONNECTING) return;
source.close();
if (disposed) return;
if (fatalRetries >= MAX_FATAL_RETRIES) {
handlers.onFailure("Progress stream unavailable after 4 attempts.");
return;
}
const backoff = Math.min(30_000, 1000 * 2 ** fatalRetries);
fatalRetries += 1;
reopenTimer = setTimeout(open, backoff * (0.5 + Math.random() / 2));
});
};
open();
return dispose;
}
// --- 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
res.flushHeaders()sends the status line and headers before the first event. Without it Node waits for the firstwrite(), and a client that opens the stream before any work starts sits inCONNECTINGwith noopenevent.retry: 5000as the first frame sets the reconnection delay for the lifetime of thatEventSource. The browser default is implementation-defined (roughly 3 seconds in Chrome and Firefox); state it explicitly so your reconnect load is predictable.- The
id:on every frame is what the browser echoes asLast-Event-ID. Use a monotonic integer, not a UUID — the server has to answer “everything after this” cheaply. ": keep-alive\n\n"is a comment with no data buffer, so no listener fires. It exists purely to put bytes on the socket.new EventSource(url, { withCredentials: true })issues aGETwithAccept: text/event-streamand sends cookies cross-origin. There is no options object for headers — that limitation drives several decisions below.addEventListener("progress", …)matches the server’sevent: progressfield. Omit theevent:line and everything arrives onmessageinstead.dispose()ondoneis mandatory. SSE has no server-initiated close that stops the client; if the server simply ends the response, the browser reconnects afterretryms and the server replays fromLast-Event-ID, firingdonea second time.
The sequence below shows the whole lifecycle, including the drop that the browser repairs without your code being involved.
Configuration reference
| Knob | Where it lives | Default | Effect |
|---|---|---|---|
event: |
stream field | message |
Chooses which addEventListener name receives the frame. |
data: |
stream field | — | The payload. Repeat the field for multi-line text; lines join with \n. |
id: |
stream field | unset | Becomes Last-Event-ID on the next reconnect. A NUL byte makes the browser ignore the line. |
retry: |
stream field | ~3000 ms | Reconnect delay. Digits only — retry: 5s is silently discarded. |
: comment |
stream field | — | Ignored by the parser; the standard keep-alive. |
Content-Type |
response header | — | Must be text/event-stream; anything else fails the connection permanently. |
Cache-Control |
response header | — | no-cache, no-transform stops caching and proxy re-compression. |
X-Accel-Buffering |
response header | — | no disables Nginx response buffering for this response only. |
withCredentials |
EventSource option |
false |
Sends cookies cross-origin; requires an exact-origin CORS reply. |
readyState |
EventSource property |
0 |
0 CONNECTING, 1 OPEN, 2 CLOSED — the only way to tell a retry from a fatal error. |
Telling a retry apart from a fatal failure
This is the part that catches teams out. A mid-stream drop and a bad handshake both fire an error event, but the browser treats them completely differently. If the response arrives with a status other than 200, or a Content-Type that is not text/event-stream, or the TCP connection cannot be established at all, the browser fails the connection: it fires error once, sets readyState to 2, and never retries. A 204 No Content reply on reconnect does the same thing deliberately — it is the documented way for a server to tell a client to stop. Only a drop after a successful handshake puts the object back into CONNECTING and schedules an automatic retry.
Multiplexing many jobs onto one stream
A gallery uploader that opens one EventSource per file hits the browser’s per-origin connection limit almost immediately. Over HTTP/1.1 that limit is six, and SSE streams never close, so the seventh file’s stream — and every ordinary fetch the page makes — queues behind them indefinitely. The symptom is maddening: uploads stall with no error anywhere, and the network panel shows requests stuck in “Queued”.
Two fixes exist. Serve the endpoint over HTTP/2, where a single TCP connection multiplexes roughly 100 concurrent streams, or collapse progress onto one stream and put the job identifier inside each frame. The second is worth doing regardless: it costs one server-side subscription instead of n, and it survives a client that falls back to HTTP/1.1 through a corporate proxy.
Configuration gotchas
Events arrive in one burst at the end
The job finishes and every frame lands at once. An intermediary is buffering the response body. Nginx buffers by default: set proxy_buffering off; for the location, or emit X-Accel-Buffering: no as above, and add proxy_http_version 1.1; plus gzip off; for that route — gzip accumulates a compression window before flushing anything. The same class of proxy setting bites during upload; the surrounding configuration is covered in raising Nginx and Cloudflare upload size limits.
EventSource’s error fires once and the UI freezes
The console shows EventSource's response has a MIME type ("text/plain") that is not "text/event-stream". Aborting the connection. — usually an error page from a proxy or a framework’s default JSON error handler. Because this is a fatal failure the browser will not retry, which is why the client above tracks fatalRetries and reopens itself. Never return a 500 body on this route; report application failures as an in-band event: error frame on a 200 response instead.
The stream dies at exactly 60 seconds
Nginx’s proxy_read_timeout defaults to 60s, AWS ALB’s idle timeout to 60s, and Cloudflare’s proxy closes at around 100s of silence. A job that spends two minutes in a transcode queue emits nothing, so the connection is torn down and the client reconnects on a loop. The 15-second keep-alive comment fixes it for every intermediary at once. Raise proxy_read_timeout 3600s; as belt and braces, but do not rely on it alone.
CORS rejects the stream even though the endpoint works
With withCredentials: true, a wildcard is invalid and you get The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. Echo the exact request origin, send Access-Control-Allow-Credentials: true, and remember that EventSource sends a simple GET — there is no preflight to debug, so a missing header shows up only on the real request.
Every client reconnects at the same instant after a deploy
Rolling a new revision drops every open stream simultaneously, and a fixed retry: 5000 brings them all back in the same 100 ms window. Jitter the reopen on the client, as the implementation above does, and cap concurrent stream acceptances server-side. The same reasoning as exponential backoff for failed chunks applies here.
Verification
Prove the framing and the replay from a terminal before wiring any UI. curl -N disables output buffering so you see frames as they arrive.
# 1. Headers only — Content-Type is the single most common cause of a dead stream.
curl -sSI https://api.example.com/api/jobs/job_8f3a/events | grep -Ei 'content-type|cache-control|accel'
# content-type: text/event-stream; charset=utf-8
# cache-control: no-cache, no-transform
# x-accel-buffering: no
# 2. Watch raw frames. Each block must end with a blank line.
curl -N -H "Accept: text/event-stream" https://api.example.com/api/jobs/job_8f3a/events
# retry: 5000
#
# id: 1
# event: progress
# data: {"jobId":"job_8f3a","stage":"scanning","percent":30}
#
# 3. Prove replay: ask for everything after event 1 and confirm id 1 is not resent.
curl -N -H "Accept: text/event-stream" -H "Last-Event-ID: 1" \
https://api.example.com/api/jobs/job_8f3a/events
# 4. Prove incremental flushing: timestamps must be seconds apart, not identical.
curl -N -s https://api.example.com/api/jobs/job_8f3a/events | while read -r line; do
printf '%s %s\n' "$(date +%T)" "$line"
done
In DevTools, open the request and use the EventStream tab: Chrome lists each dispatched event with its id, type and data, which is the fastest way to confirm that a frame you think you sent was actually parsed rather than swallowed as an unknown field. Once the stream is trustworthy, feed its percent values into the smoothing described in showing accurate time-remaining estimates rather than binding them straight to a bar.
Frequently Asked Questions
Does SSE report bytes uploaded, or only server processing?
It reports what the server knows: queue position, stage transitions, completion. Raw bytes leaving the browser come from xhr.upload.onprogress or a TransformStream wrapped around the request body; most products show one bar that is client-measured up to 100% of transfer, then server-reported for the processing tail.
Why does my done handler fire twice?
Almost always because nothing called close(). When the server ends the response, EventSource treats it as a drop, reconnects after retry ms, and your replay logic re-sends every event after Last-Event-ID — including the terminal one. Close on terminal events and make the handlers idempotent anyway.
Can I send an Authorization header with EventSource?
No. The constructor accepts only withCredentials. Use a SameSite=Lax session cookie, or a short-lived single-use token in the query string that you rotate per stream, because query strings land in access logs and Referer headers. If you genuinely need headers, drop EventSource and parse the response body yourself with fetch and the Streams API.
How many events per second can one stream carry?
Far more than a UI can use. A frame is 80–150 bytes, so 20 events/second is under 3 KB/s, but the browser dispatches each one on the main thread. Coalesce server-side to roughly 4–10 events per second per job and let the client interpolate between them.
Does SSE behave differently over HTTP/2 and HTTP/3?
The protocol is identical, but the connection accounting changes: streams share one connection, so the six-per-origin limit becomes a per-connection stream limit near 100. HTTP/3 additionally removes head-of-line blocking, so a lost packet on one upload no longer stalls delivery of progress frames on another.