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
| Dimension | Server-Sent Events | WebSockets |
|---|---|---|
| Direction | Server to client only | Full bidirectional |
| Protocol | Plain HTTP, text/event-stream | Separate protocol via HTTP Upgrade |
| Reconnection | Automatic, with Last-Event-ID resume | Manual, you build backoff and replay |
| Proxy friendliness | Passes as normal HTTP, watch buffering | Needs Upgrade config on every hop |
| HTTP/2 behaviour | Multiplexes, per-origin cap removed | No built-in multiplexing, own TCP |
| Data payload | UTF-8 text only | Text 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.

