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

  1. A backend that can expose either a text/event-stream endpoint or a WebSocket upgrade endpoint.
  2. Knowledge of your edge: HTTP/1.1 vs HTTP/2, and whether your proxy buffers or terminates idle connections.
  3. 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

  • sseProgress is complete in a handful of lines. EventSource opens, parses named events, and reconnects with Last-Event-ID on its own; the caller only closes on done.
  • wsProgress must implement reconnection manually. The browser’s WebSocket never reconnects, so onclose schedules a retry. This is the single biggest operational difference.
  • closedByCaller distinguishes 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 = 0 on a healthy message resets the backoff so a long-lived connection that blips once does not start its next retry at 30 seconds.
  • onerror funnels into close because 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.

SSE versus WebSocket lifecycle for progress SSE is a one-way stream that reconnects automatically; a WebSocket is bidirectional and requires a manual backoff reconnect loop. SSE (one-way) client server events flow down only auto-reconnect Last-Event-ID replay EventSource, a few lines WebSocket (duplex) client server messages flow both ways manual reconnect you write the backoff text or binary frames
SSE reconnects itself for one-way progress; WebSockets buy duplex at the cost of a hand-written reconnect loop.

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.