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, but they differ sharply in directionality, reconnection behavior, and how they interact with proxies and HTTP/2. This guide gives a side-by-side table, a small client for each, and a clear 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 processing progress feed and want the trade-offs spelled out before committing.
- You need to know how each transport behaves behind a reverse proxy and on HTTP/2.
- You want a runnable minimal client for whichever you choose.
Prerequisites
- A backend that can expose either a
text/event-streamendpoint or a WebSocket upgrade endpoint. - Knowledge of your edge: HTTP/1.1 vs HTTP/2, and whether your proxy buffers or terminates idle connections.
- The SSE implementation details in streaming upload progress with server-sent events if you lean that way.
The decision table
| Dimension | Server-Sent Events | WebSockets |
|---|---|---|
| Direction | Server β client only | Full duplex (both ways) |
| Protocol | Plain HTTP, text/event-stream |
ws:// / wss:// upgrade from HTTP |
| Reconnect | Automatic, with Last-Event-ID replay |
Manual β you write backoff + resume |
| Payload | UTF-8 text only (JSON you encode) | Text or binary frames |
| HTTP/2 | Multiplexed over one connection | Not multiplexed (one TCP per socket) |
| Proxy friction | Works through most HTTP proxies; beware buffering | Needs Upgrade/Connection headers passed through |
| Browser cap | ~6 per host on HTTP/1.1; lifted on HTTP/2 | High; not subject to the SSE 6-stream cap |
| Auth headers | No custom headers (cookie or query token) | Cookie at handshake; no custom headers either |
| Client code | Trivial (EventSource) |
More (open/close/error/backoff) |
| Best for | Progress, status, notifications | Chat, live cursors, bidirectional control |
Implementation
Both clients below expose the same onProgress shape so a UI layer can swap transports without other changes. SSE is a few lines; WebSockets needs an explicit reconnect loop because the browser does not provide one.
export interface Progress {
jobId: string;
percent: number;
}
// ---------- SSE: reconnection is free ----------
export function sseProgress(
jobId: string,
onProgress: (p: Progress) => void,
): () => void {
const source = new EventSource(`/api/jobs/${jobId}/events`);
source.addEventListener("progress", (ev) => {
onProgress(JSON.parse((ev as MessageEvent).data) as Progress);
});
source.addEventListener("done", () => source.close());
return () => source.close();
}
// ---------- WebSocket: reconnection is your job ----------
export function wsProgress(
jobId: string,
onProgress: (p: Progress) => void,
): () => void {
let socket: WebSocket | null = null;
let attempt = 0;
let closedByCaller = false;
const connect = () => {
socket = new WebSocket(`wss://api.example.com/jobs/${jobId}`);
socket.onmessage = (ev) => {
const msg = JSON.parse(ev.data) as Progress | { type: "done" };
if ("type" in msg && msg.type === "done") {
closedByCaller = true;
socket?.close();
return;
}
attempt = 0; // healthy traffic resets backoff
onProgress(msg as Progress);
};
socket.onclose = () => {
if (closedByCaller) return;
// Full-jitter backoff, capped at 30s β the browser will not do this for us.
const delay = Math.min(30000, 2 ** attempt * 500);
const jittered = Math.random() * delay;
attempt++;
setTimeout(connect, jittered);
};
socket.onerror = () => socket?.close(); // funnel errors into onclose/backoff
};
connect();
return () => {
closedByCaller = true;
socket?.close();
};
}
Line-by-line of the critical parts
sseProgressis complete in a handful of lines.EventSourceopens, parses named events, and reconnects withLast-Event-IDon its own; the caller only closes ondone.wsProgressmust implement reconnection manually. The browserβsWebSocketnever reconnects, sooncloseschedules a retry. This is the single biggest operational difference.closedByCallerdistinguishes an intentional teardown from a dropped socket. Without it, calling the disposer would still trigger the reconnect loop.- Full-jitter backoff (
Math.random() * delay) prevents every client from reconnecting in lockstep after a server restart β the same discipline used in implementing exponential backoff for failed chunks. attempt = 0on a healthy message resets the backoff so a long-lived connection that blips once does not start its next retry at 30 seconds.onerrorfunnels intoclosebecause WebSocket errors do not carry useful detail in the browser; treating every error as a close keeps one recovery path.
The diagram contrasts the two connection lifecycles.
Configuration gotchas
WebSocket 1006 abnormal closure right after connect. A proxy is not forwarding the Upgrade: websocket and Connection: Upgrade headers. Configure the edge to pass them through (and to allow long idle timeouts) before blaming the app.
SSE stalls behind a buffering proxy. Nginx buffers text/event-stream by default, so events arrive only when the buffer flushes. Send X-Accel-Buffering: no and disable gzip on the stream β covered in detail in streaming upload progress with server-sent events.
WebSocket reconnect storm after deploy. Every clientβs onclose fires simultaneously; without jitter they reconnect in a synchronized wave and knock the server over again. The full-jitter delay in the sample is mandatory, not optional.
Idle WebSocket dropped by a load balancer. Many balancers cut idle sockets at 60 seconds. Send an application-level ping every 30 seconds (or rely on protocol-level pings) so a progress stream that pauses does not get severed.
Verification
Probe each endpoint to confirm the transport actually upgrades or streams:
# SSE: must return 200 and text/event-stream
curl -sS -I -H "Accept: text/event-stream" https://api.example.com/api/jobs/job_1/events \
| grep -i content-type
# Expected: content-type: text/event-stream
# WebSocket: handshake must return 101 Switching Protocols
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 1
# Expected: HTTP/1.1 101 Switching Protocols
FAQ
What is the single best default for upload progress?
SSE. Progress is one-directional, and EventSource gives you reconnection and event replay without writing any recovery code. Only switch to WebSockets when the client genuinely needs to send messages back on the same channel.
Does HTTP/2 change the recommendation?
It strengthens the case for SSE: HTTP/2 multiplexes streams, so the old ~6-connections-per-host limit that hurt SSE on HTTP/1.1 disappears. WebSockets still use one dedicated TCP connection each.
Can WebSockets send the file bytes too?
They can carry binary frames, so technically yes, but resumable chunked HTTP uploads (or the tus protocol) are far better suited to large files because they recover per chunk. Keep the WebSocket for control messages.
How do I authenticate either one?
Neither EventSource nor the browser WebSocket lets you set custom request headers. Use a cookie sent at the handshake, or a short-lived token in the URL that you rotate, and pair it with your backend validation and cloud storage architecture.