Server-Sent Events vs WebSockets for Realtime

Photo by Blackcat via Wikimedia Commons (CC BY-SA 4.0)
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.
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.
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.
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.
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.

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.
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.
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.
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.
| 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 |
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.