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

  1. A backend that can expose either a text/event-stream endpoint or a WebSocket upgrade endpoint, with a job identifier the client already knows.
  2. A worker that emits stage events — for example the transcode pipeline described in queueing transcode jobs with SQS and Lambda.
  3. Knowledge of your edge: HTTP/1.1 vs HTTP/2, and whether the proxy buffers responses or cuts idle connections.
  4. Node 20+ and ws@8 if 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.

One progress update on the wire in each transport An SSE frame is line-oriented UTF-8 text terminated by a blank line; a WebSocket frame is a binary header of two to fourteen bytes followed by the payload. One progress update, byte for byte SSE — text/event-stream id: 42 event: progress data: {"stage":"transcoding","percent":62} blank line ends the frame 25 bytes of framing field prefixes plus two newlines UTF-8 only; binary needs base64 id enables Last-Event-ID replay WebSocket — RFC 6455 frame FIN+op mask+len ext len mask key payload 1 byte 1 byte 0/2/8 B 4 bytes n bytes only client to server frames carry the mask key 2 bytes of framing downstream 6 bytes upstream with the mask binary frames, no base64 tax no replay: you design the cursor
SSE pays about 25 bytes of framing per event and buys replay with it; a WebSocket frame is leaner but carries no identity you can resume from.

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.

Timeline of a network drop under each transport After a drop at three seconds, EventSource retries by itself and replays from the last event id, while a WebSocket closes with 1006 and waits for backoff code you wrote. A drop at t = 3 s network drops here SSE streaming auto retry: 3000 ms resumes, replays id 43+ recovery code you write: none WebSocket streaming 1006 your backoff resumes from your own cursor recovery code you write: jitter, cursor, heartbeat, dedupe 0 s 2 s 4 s 6 s 8 s 10 s
Both transports are back within seconds — but only one of them got there without code you have to test.

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.

Per-origin connection budget on HTTP/1.1 versus HTTP/2 On HTTP/1.1 four open SSE streams consume four of six connection slots; on HTTP/2 all streams multiplex over one connection and the cap disappears. HTTP/1.1 — 6 sockets per origin SSE stream — upload tab 1 SSE stream — upload tab 2 SSE stream — upload tab 3 SSE stream — upload tab 4 free for XHR and assets free for XHR and assets a fifth tab blocks until one closes HTTP/2 — one connection, many streams single TLS connection SSE SSE API API the 6-stream cap disappears SETTINGS_MAX_CONCURRENT_STREAMS is the limit a WebSocket still opens its own TCP socket
The classic argument against SSE is an HTTP/1.1 artefact; on HTTP/2 the connection budget stops being a reason to pick WebSockets.

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.
  • finished separates an intentional teardown from a dropped connection. Without it, calling close() still triggers the reconnect path — the single most common bug in hand-written WebSocket clients.
  • ?since=${lastSeq} is the WebSocket equivalent of Last-Event-ID, and you have to invent it. Everything about resumability that EventSource gives away is a design decision here, exactly as it is when persisting upload state in IndexedDB.
  • if (msg.seq <= lastSeq) return makes 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() * ceiling is full jitter, not ceiling — see implementing exponential backoff for failed chunks for why the random floor matters more than the ceiling.
  • attempt = 0 on a message stops a long-lived connection that blipped once an hour ago from starting its next retry at 30 seconds.
  • Close code 1000 means a clean, intended shutdown and is the only code that must not reconnect. 1006 is 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 40004999 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.

Fan-out from a transcode worker to browsers across three app instances The worker publishes stage events to a broker channel, which fans them out to every app instance, and each instance writes to the browsers it currently holds. transcode worker emits stage % Redis pub/sub channel job:8f3a fan-out app-1 SSE writer app-2 SSE writer app-3 SSE writer browser A browser B browser C SSE reconnects land on any instance; a WebSocket handshake pins one for its lifetime
The broker is mandatory either way; sticky routing is the extra bill a WebSocket adds.

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.