Skip to main content
BlogBackend

Server-Sent Events vs WebSockets for Realtime

When one-way SSE beats a full WebSocket, how reconnection and HTTP/2 change the math, and how I actually choose per use case.

Matthews Wong

Software Engineer

July 20269 min read
Server-Sent Events vs WebSockets for Realtime

Photo by Blackcat via Wikimedia Commons (CC BY-SA 4.0)

Key Takeaway

Use Server-Sent Events when the server only needs to push to the client: it rides plain HTTP, reconnects automatically, and multiplexes cleanly over HTTP/2. Reach for WebSockets when the client must also send a steady stream back, such as chat, collaborative editing, or gaming, where full bidirectional flow justifies the extra protocol.

Every few months a project asks for something realtime and someone immediately reaches for WebSockets. Often that is overkill. A large share of realtime features are one-directional: a dashboard updating, a progress bar for a long job, notifications, live logs, or an AI model streaming tokens. For all of those, Server-Sent Events do the job with far less code and far less operational pain.

I run a few services on a self-hosted VPS behind a reverse proxy, and the choice between these two transports has real consequences for how much reconnection logic I write, how the proxy behaves, and how many connections a browser will even allow. Here is how I reason about it in practice, with the trade-offs that actually matter.

One-way vs bidirectional: the core split

Server-Sent Events are one-directional by design. The client opens a long-lived HTTP request and the server streams a text event stream down it, forever, until someone closes it. The client cannot send data back over the same channel; to talk to the server it makes ordinary HTTP requests alongside the stream. WebSockets, by contrast, upgrade the connection to a full-duplex protocol where both sides push frames whenever they like.

That single distinction decides most cases. If the interaction is genuinely conversational, with the client emitting messages continuously, WebSockets fit naturally. If the client is mostly a listener that occasionally issues a normal request, SSE is the simpler and more honest model. Forcing bidirectional plumbing onto a one-way problem just adds surface area to secure and maintain.

Reconnection: SSE gives it to you for free

This is the SSE feature people underrate most. The browser EventSource API reconnects automatically when the connection drops, with a default retry of about three seconds that the server can change by sending a retry field in the stream. Even better, if you attach an id to each event, the browser stores it and sends a Last-Event-ID header on reconnect, so the server can resume exactly where it left off instead of replaying or dropping messages.

// SSE stream — reconnection and resume are built in
// Client:
const es = new EventSource("/api/updates");
es.onmessage = (e) => console.log("data:", e.data);
es.onerror = () => console.log("reconnecting automatically...");

// Server (Node/Express) response body format:
//   id: 42\n
//   retry: 5000\n
//   data: {"progress":73}\n\n
// On reconnect the browser sends:  Last-Event-ID: 42
res.writeHead(200, {
  "Content-Type": "text/event-stream",
  "Cache-Control": "no-cache",
  "Connection": "keep-alive",
});
res.write(`id: ${seq}\nretry: 5000\ndata: ${JSON.stringify(payload)}\n\n`);

WebSockets have none of this. When a WebSocket closes, the client just sits there; you have to write the reconnect loop, the backoff, and any message-replay logic yourself, or pull in a library that does it. For a lot of teams that hidden cost is the deciding factor: SSE ships resilient behaviour that you would otherwise reimplement, badly, under deadline.

Proxy and HTTP/2 behaviour

Because SSE is just a long HTTP response, it usually passes through existing infrastructure, load balancers, auth middleware, and CDNs, without special treatment. WebSockets need an explicit HTTP Upgrade handshake, and every proxy in the path has to be configured to allow and forward it. That is more moving parts to get right.

SSE has one classic gotcha behind a reverse proxy: response buffering. Nginx buffers proxied responses by default, so it holds your events until the buffer fills instead of flushing each one. You must disable buffering and raise the read timeout, or your realtime stream arrives in silent batches.

# Nginx: SSE endpoint behind a reverse proxy
location /api/updates {
    proxy_pass http://app_upstream;
    proxy_http_version 1.1;
    proxy_set_header Connection "";     # keep upstream alive
    proxy_buffering off;                # critical: flush each event
    proxy_cache off;
    proxy_read_timeout 86400s;          # do not kill idle-ish streams
    add_header X-Accel-Buffering no;    # override buffering explicitly
}

# WebSocket endpoint needs the Upgrade dance instead:
location /ws {
    proxy_pass http://app_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

The old knock against SSE was the HTTP/1.1 connection cap: browsers allow only about six concurrent connections per origin, and each SSE stream eats one, which could starve the rest of the page across multiple tabs. HTTP/2 removes that constraint by multiplexing many streams over a single TCP connection, with a negotiated default around one hundred concurrent streams. So on any modern HTTP/2 deployment, that limitation is effectively gone, which is a big reason SSE has become the default transport for AI SDKs streaming model tokens.

The comparison at a glance

DimensionServer-Sent EventsWebSockets
DirectionServer to client onlyFull bidirectional
ProtocolPlain HTTP, text/event-streamSeparate protocol via HTTP Upgrade
ReconnectionAutomatic, with Last-Event-ID resumeManual, you build backoff and replay
Proxy friendlinessPasses as normal HTTP, watch bufferingNeeds Upgrade config on every hop
HTTP/2 behaviourMultiplexes, per-origin cap removedNo built-in multiplexing, own TCP
Data payloadUTF-8 text onlyText and binary frames

How I choose per use case

  • Live dashboards, metrics, progress bars, notifications, activity feeds: SSE. One-way push, automatic reconnect, nothing to hand-roll.
  • AI and LLM token streaming: SSE. It is the default transport for major AI SDKs precisely because the flow is server to client with clean HTTP/2 multiplexing.
  • Chat, collaborative editing, multiplayer games, live cursors: WebSockets. The client emits a continuous stream, so full duplex earns its keep.
  • Binary or high-frequency client input, like audio or telemetry upload: WebSockets. SSE carries only UTF-8 text down a single direction.
  • Unsure and the client side is mostly reading: start with SSE. It is cheaper to add a plain POST endpoint later than to operate a bidirectional socket you did not need.

A useful hybrid: stream server updates over SSE and send occasional client actions over normal HTTP POST. You get push plus reconnection for free while keeping request handling on familiar, cacheable, auth-friendly HTTP. Only escalate to WebSockets when that client-to-server chatter becomes constant.

The mistake I see most is reaching for the more powerful tool by reflex. WebSockets are genuinely better when you need them, but they carry ongoing costs: reconnection code, proxy configuration, and a protocol that sits outside your normal HTTP tooling. SSE quietly solves the common case. Match the transport to the actual direction of your data, and most realtime features get simpler.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is the main difference between Server-Sent Events and WebSockets?

Server-Sent Events are one-directional: the server streams data to the client over a long-lived HTTP request, and the client cannot send data back over the same channel. WebSockets are bidirectional and full-duplex, so both the client and server can push messages at any time. That direction of data flow is the primary factor in choosing between them.

Do Server-Sent Events reconnect automatically?

Yes. The browser EventSource API reconnects automatically when the connection drops, with a default retry interval of about three seconds that the server can override with a retry field. If each event carries an id, the browser sends a Last-Event-ID header on reconnect so the server can resume from where it stopped. WebSockets have no built-in reconnection, so you must write that logic yourself.

Does the six-connection browser limit still affect SSE?

Only over HTTP/1.1. Browsers cap concurrent connections per origin at around six, and each SSE stream consumes one, which could starve a page with many tabs open. HTTP/2 multiplexes many streams over a single TCP connection, with a negotiated default around one hundred, so the limit is effectively gone on modern HTTP/2 deployments.

Why do SSE streams break behind Nginx or a reverse proxy?

The usual cause is response buffering. Nginx buffers proxied responses by default, holding events until the buffer fills instead of flushing each one immediately. Setting proxy_buffering off, raising proxy_read_timeout, and adding the X-Accel-Buffering no header restores real-time delivery. WebSockets instead require the proxy to forward the HTTP Upgrade handshake.

Should I use SSE or WebSockets for AI token streaming?

Server-Sent Events are the common choice for streaming AI or LLM tokens because the flow is purely server to client and multiplexes cleanly over HTTP/2. Most major AI SDKs default to SSE for exactly this reason. Reach for WebSockets only if the client also needs to stream continuous input back to the server during generation.