WebSockets vs SSE for Upload Progress
For one-way upload progress, Server-Sent Events win on simplicity and free reconnection; reach for WebSockets only when the client must also push messages on the same socket or you need binary frames.
Choosing a transport for progress is a recurring decision in realtime upload progress events under frontend UX, chunking and progress tracking. Both deliver live updates, and on a laptop plugged into office Wi-Fi both look identical. The differences only surface in production: how each one frames a message, what happens on the twelfth reconnection of a flaky mobile session, how many of the browser’s per-origin connection slots you burn, and how much extra plumbing you need once the feed is served by more than one app instance. This page walks those four axes, gives a complete client for each transport, and names a default so you do not over-engineer a one-directional feed.
When to use this approach
- You are picking between WebSockets and SSE for an upload or post-upload processing feed and want the trade-offs spelled out before you commit an API surface you will support for years.
- You need to know how each transport behaves behind nginx, an ALB or Cloudflare, and what changes on HTTP/2.
- Your progress numbers come from the server (scanning, transcoding, replication), not from the browser’s own
upload.onprogress— byte-level client progress needs no transport at all, only a TransformStream wrapped around the request body.
Prerequisites
- A backend that can expose either a
text/event-streamendpoint or a WebSocket upgrade endpoint, with a job identifier the client already knows. - A worker that emits stage events — for example the transcode pipeline described in queueing transcode jobs with SQS and Lambda.
- Knowledge of your edge: HTTP/1.1 vs HTTP/2, and whether the proxy buffers responses or cuts idle connections.
- Node 20+ and
ws@8if you follow the server-side heartbeat snippet below.
How each transport frames a progress update
An SSE message is plain UTF-8 text with a tiny line-oriented grammar: optional id:, optional event:, one or more data: lines, terminated by a blank line. There is no length prefix, so the framing overhead is the field prefixes plus the newlines — about 25 bytes for a named, identified progress event. Binary has to be base64-encoded, inflating it by 33%, which is why you send JSON numbers and never thumbnails.
A WebSocket message is a RFC 6455 frame: a two-byte header carrying FIN, the opcode and a 7-bit length, an optional extended length field, and — for every client-to-server frame — a mandatory four-byte masking key XORed over the payload. Server-to-client frames are unmasked, so a 40-byte progress JSON costs 2 bytes of framing downstream against SSE’s 25. Over a thousand updates that is 23 KB. Irrelevant for progress; decisive if you were streaming audio.
Reconnection is the real difference
EventSource owns its own retry loop. When the connection drops it waits the reconnection time — 3000 ms by default in Chrome and Firefox, overridable by the server writing a retry: 5000 line — then re-issues the same GET with a Last-Event-ID header containing the last id: it saw. If your handler honours that header, the client resumes exactly where it left off with zero client-side code.
The browser WebSocket never reconnects. A dropped socket fires close with code 1006 (abnormal closure, no close frame received) and that is the end of it. You write the backoff, you write the jitter, you track your own resume cursor, and you decide what a duplicate replayed event means. That is roughly forty lines you now own, and each of them is a place a rare bug can hide — the same discipline you already apply when resuming uploads after network loss.
The decision table
| Dimension | Server-Sent Events | WebSockets |
|---|---|---|
| Direction | Server → client only | Full duplex (both ways) |
| Protocol | Plain HTTP, text/event-stream |
wss:// upgrade, HTTP/1.1 only in browsers |
| Reconnect | Automatic, with Last-Event-ID replay |
Manual — you write backoff and resume |
| Payload | UTF-8 text (JSON you encode) | Text or binary frames |
| Framing per event | ~25 bytes | 2 bytes down, 6 bytes up |
| HTTP/2 | Multiplexed over one connection | Not multiplexed; one TCP socket each |
| Idle detection | Comment lines (:ping) |
Protocol ping/pong frames |
| Proxy friction | Buffering and gzip stall the stream | Upgrade/Connection must be forwarded |
| Browser cap | 6 per origin on HTTP/1.1; lifted on HTTP/2 | ~255 per origin, no HTTP/1.1 cap |
| Auth headers | None; cookie or query token only | None; cookie or subprotocol only |
| Load-balancer state | Stateless — any instance can serve | Sticky routing or a shared hub |
| Client code | ~10 lines | ~40 lines |
| Best for | Progress, status, notifications | Chat, live cursors, bidirectional control |
Connection budgets and HTTP/2
Under HTTP/1.1 a browser allows six concurrent connections per origin, and an open EventSource holds one of them for the entire life of the job. Four tabs watching four uploads leaves two slots for every other request the origin needs to serve — API calls, images, the next navigation. Users hit it as a page that simply stops loading, with no error anywhere. WebSockets sit outside that pool, which is the one genuine argument for them on legacy HTTP/1.1 edges.
Serve the stream over HTTP/2 and the problem disappears: streams multiplex over a single TLS connection, and the practical limit becomes SETTINGS_MAX_CONCURRENT_STREAMS (100 in most servers) rather than six. Browsers still speak the WebSocket handshake over HTTP/1.1, so each socket keeps its own TCP connection and its own TLS handshake — roughly 1–2 RTT of setup per reconnection that SSE amortises across the shared connection.
Implementation
Both clients below return the same Channel shape, so a UI layer can swap transports without touching anything else. Each one also carries a stale-stream watchdog, because a TCP connection that has quietly died looks exactly like a healthy idle one until something probes it.
export interface ProgressEvent {
jobId: string;
stage: "scanning" | "transcoding" | "storing";
percent: number;
seq: number;
}
export interface ChannelOptions {
onProgress: (event: ProgressEvent) => void;
onDone: (jobId: string) => void;
/** Declare the stream dead after this much silence. Must exceed the server heartbeat. */
staleAfterMs?: number;
}
/** Both transports return this, so the UI never learns which one it got. */
export interface Channel {
close: () => void;
}
export function openSseChannel(jobId: string, options: ChannelOptions): Channel {
const staleAfterMs = options.staleAfterMs ?? 45_000;
const url = `/api/jobs/${encodeURIComponent(jobId)}/events`;
let source: EventSource | null = null;
let watchdog = 0;
let finished = false;
const connect = () => {
source = new EventSource(url, { withCredentials: true });
const arm = () => {
clearTimeout(watchdog);
watchdog = window.setTimeout(() => {
// Silence is indistinguishable from health: force a fresh connection.
source?.close();
if (!finished) connect();
}, staleAfterMs);
};
source.addEventListener("progress", (ev) => {
arm();
options.onProgress(JSON.parse((ev as MessageEvent).data) as ProgressEvent);
});
source.addEventListener("heartbeat", arm);
source.addEventListener("done", () => {
finished = true;
clearTimeout(watchdog);
source?.close(); // terminal: EventSource would otherwise reconnect forever
options.onDone(jobId);
});
arm();
};
connect();
return {
close: () => {
finished = true;
clearTimeout(watchdog);
source?.close();
},
};
}
export function openWebSocketChannel(jobId: string, options: ChannelOptions): Channel {
const staleAfterMs = options.staleAfterMs ?? 45_000;
let socket: WebSocket | null = null;
let attempt = 0;
let lastSeq = 0; // our hand-rolled Last-Event-ID; the protocol has no equivalent
let watchdog = 0;
let finished = false;
const connect = () => {
// The resume cursor rides in the query string: WebSocket() cannot set request headers.
socket = new WebSocket(
`wss://api.example.com/jobs/${encodeURIComponent(jobId)}?since=${lastSeq}`,
);
const arm = () => {
clearTimeout(watchdog);
watchdog = window.setTimeout(() => socket?.close(4000, "stale"), staleAfterMs);
};
socket.onopen = arm;
socket.onmessage = (ev) => {
arm();
attempt = 0; // healthy traffic resets the backoff ceiling
const msg = JSON.parse(ev.data as string) as ProgressEvent | { type: "done" };
if ("type" in msg && msg.type === "done") {
finished = true;
socket?.close(1000, "done");
options.onDone(jobId);
return;
}
if (msg.seq <= lastSeq) return; // drop replays after a resume
lastSeq = msg.seq;
options.onProgress(msg);
};
socket.onclose = (ev) => {
clearTimeout(watchdog);
if (finished || ev.code === 1000) return;
// Full jitter capped at 30s: the failure mode here is a 1006 storm after a deploy.
const ceiling = Math.min(30_000, 2 ** attempt * 500);
attempt += 1;
window.setTimeout(connect, Math.random() * ceiling);
};
socket.onerror = () => socket?.close(); // browser errors carry no detail; funnel to onclose
};
connect();
return {
close: () => {
finished = true;
clearTimeout(watchdog);
socket?.close(1000, "client teardown");
},
};
}
Line-by-line of the critical parameters
staleAfterMs(default 45 s) must be at least twice your server heartbeat interval, or a single delayed heartbeat tears down a perfectly good stream. With a 15-second heartbeat, 45 s tolerates two misses.finishedseparates an intentional teardown from a dropped connection. Without it, callingclose()still triggers the reconnect path — the single most common bug in hand-written WebSocket clients.?since=${lastSeq}is the WebSocket equivalent ofLast-Event-ID, and you have to invent it. Everything about resumability thatEventSourcegives away is a design decision here, exactly as it is when persisting upload state in IndexedDB.if (msg.seq <= lastSeq) returnmakes the stream idempotent. After a resume the server may re-send the boundary event; a progress bar that jumps backwards is the visible symptom of skipping this line.Math.random() * ceilingis full jitter, notceiling— see implementing exponential backoff for failed chunks for why the random floor matters more than the ceiling.attempt = 0on a message stops a long-lived connection that blipped once an hour ago from starting its next retry at 30 seconds.- Close code
1000means a clean, intended shutdown and is the only code that must not reconnect.1006is synthesised by the browser when no close frame arrived — always a network or proxy event, never an application decision.
Keeping the socket alive from the server
EventSource needs nothing here; a comment line (:ping\n\n) every 15 seconds is enough to defeat proxy idle timers. WebSockets have real ping/pong frames, and browsers answer a ping automatically without waking your JavaScript, so the sweep belongs on the server:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080, path: "/jobs", maxPayload: 64 * 1024 });
// A dead TCP connection is indistinguishable from an idle one until you probe it.
wss.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", () => {
socket.isAlive = true;
});
});
const sweep = setInterval(() => {
for (const socket of wss.clients) {
if (socket.isAlive === false) {
socket.terminate(); // skip the close handshake: the peer is already gone
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 15_000);
wss.on("close", () => clearInterval(sweep));
Configuration reference
| Knob | Where | Default | Effect |
|---|---|---|---|
retry: 5000 |
SSE frame body | 3000 ms in Chrome/Firefox | Sets the EventSource reconnection delay for this and later connections |
Last-Event-ID |
SSE request header | Sent automatically after a drop | Your handler must resume from it or the client silently loses events |
X-Accel-Buffering: no |
SSE response header | absent | Disables nginx response buffering for this stream only |
proxy_read_timeout |
nginx location | 60 s | Kills any stream, SSE or WebSocket, that is silent for longer |
| Heartbeat interval | Your app | none | Keep it under half proxy_read_timeout; 15 s against a 60 s timeout |
maxPayload |
ws server |
100 MiB | Larger frames are rejected with close code 1009; 64 KiB is plenty for JSON |
perMessageDeflate |
ws server |
off in ws@8 |
Costs ~300 µs and a zlib context per socket; leave off for small JSON |
Sec-WebSocket-Protocol |
Handshake | none | The only request header a browser lets you set — a common auth-token channel |
withCredentials |
EventSource |
false |
Sends cookies cross-origin; the response then needs an exact Access-Control-Allow-Origin |
Close code 4000–4999 |
Your app | none | Private range; use it to signal “stale” or “job cancelled” distinctly from 1006 |
Configuration gotchas
Error during WebSocket handshake: Unexpected response code: 200. The proxy answered the upgrade itself instead of forwarding it. In nginx the location needs proxy_http_version 1.1;, proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade"; — the same class of edge configuration as raising nginx and Cloudflare upload size limits. A 403 here usually means an origin allow-list rejected the handshake before it reached your app.
EventSource's response has a MIME type ("text/html") that is not "text/event-stream". Aborting the connection. Something upstream returned an error page — most often an auth redirect, because EventSource follows redirects but cannot carry an Authorization header. Fix the auth (cookie or short-lived query token) rather than loosening the MIME check, which you cannot do anyway.
Refused to connect to 'wss://api.example.com/jobs/job_1' because it violates the following Content Security Policy directive: "connect-src 'self'". CSP connect-src governs both transports, and 'self' does not imply the wss: scheme on another host. Add the exact origin, wss://api.example.com, and keep the https:// entry for the SSE fallback.
Reconnect storm after a deploy. Every WebSocket closes with 1006 in the same second, and without jitter they all return together and take the fresh instance down again. In one rollout of 4,000 connected clients, a fixed 1-second retry produced a 4,000-request spike; full jitter over the same ceiling spread it to under 150 requests per second. SSE clients stagger themselves slightly but not enough — send retry: with a value you vary per connection if the fleet is large.
Silent stall at exactly 60 seconds. An idle progress feed dies on the dot because a load balancer cut it. Both the heartbeat and the client watchdog above exist for this; if you only add one, add the heartbeat, since the client cannot tell a slow job from a severed socket. The same 60-second ceiling shows up in browser fetch timeouts — see aborting uploads with AbortController and timeouts.
Running the feed behind more than one instance
Neither transport survives horizontal scaling on its own. The worker that produces stage events is not the process holding the client’s connection, so events have to travel through a broker — Redis pub/sub, NATS, or a Postgres LISTEN/NOTIFY channel — and fan out to whichever instance owns that client.
The asymmetry is in the routing. An SSE stream is an ordinary GET, so any instance can serve any reconnection as long as it can read the job’s event log; no sticky sessions, no session affinity on the load balancer. A WebSocket handshake pins the client to one instance for the life of the socket, so a rolling deploy disconnects every client on the instance being replaced, and a resume must be answerable by whichever instance catches the retry.
Verification
Probe each endpoint directly before you debug the client. The SSE check must show the right content type and no Content-Encoding; the WebSocket check must show a 101.
# SSE: content type must be text/event-stream and the body must NOT be gzipped
curl -sS -D - -o /dev/null -H "Accept: text/event-stream" \
https://api.example.com/api/jobs/job_1/events | grep -iE 'content-(type|encoding)'
# Expected: content-type: text/event-stream
# Expected: no content-encoding line at all
# SSE: watch frames arrive live — heartbeats should appear every 15s
curl -sS -N -H "Accept: text/event-stream" \
https://api.example.com/api/jobs/job_1/events | head -n 12
# WebSocket: the handshake must return 101, not 200/403/502
curl -sS -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://api.example.com/jobs/job_1 | head -n 3
# Expected: HTTP/1.1 101 Switching Protocols
# Expected: Upgrade: websocket
If the second command prints nothing for ten seconds and then dumps every frame at once, a proxy is buffering the stream rather than passing it through. In the browser, the Network panel’s EventStream tab lists parsed SSE events with their ids, and the Messages tab does the same for WebSocket frames — both are the fastest way to confirm the server is actually emitting sequence numbers you can resume from.
Frequently Asked Questions
What is the single best default for upload progress?
SSE. Progress is one-directional, EventSource gives you reconnection and Last-Event-ID replay with no recovery code, and the stream stays a plain GET your load balancer already understands. Switch to WebSockets only when the client genuinely needs to send messages back on the same channel — a cancel button can just as easily be a DELETE.
Does HTTP/2 change the recommendation?
It strengthens it. HTTP/2 multiplexes streams over one connection, so the six-per-origin limit that used to hurt SSE disappears, while browsers still negotiate every WebSocket over its own HTTP/1.1 connection with its own TLS handshake.
Can I send the file bytes over the WebSocket too?
Binary frames make it technically possible, but you lose range resumption, CDN participation and every proxy-level size control, and a dropped socket costs you the whole message rather than one part. Keep bytes on HTTP and the socket for control messages; if resumability is the goal, build a resumable upload flow with tus instead.
How do I authenticate either transport?
Neither EventSource nor the browser WebSocket lets you set an Authorization header. Use a same-site cookie at the handshake, or mint a short-lived job-scoped token and pass it in the query string (SSE) or as a Sec-WebSocket-Protocol value (WebSockets), issued by the same service that owns your backend validation and cloud storage architecture.
How often should I emit progress events?
Two to four per second is the ceiling worth paying for — beyond that the UI cannot render the difference and you are just burning connections and CPU. Coalesce on the server, emit on percentage change rather than on every byte, and let the client smooth the rest when it is showing accurate time-remaining estimates.