Real-time features almost always start with the same question: WebSockets or Server-Sent Events? Both exist to solve the same problem — polling is wasteful and slow — but they solve it with genuinely different machinery, and the difference shows up the moment you need to scale past a demo.
The short answer is that the direction of your data decides it, and almost nothing else does. The long answer is why that’s true, and what it costs you when you get it wrong.
Two mechanisms, briefly
Server-Sent Events are one HTTP response that never ends. The client makes an
ordinary GET, the server replies Content-Type: text/event-stream and then simply
never closes the body, writing data: lines down it as things happen. The browser’s
EventSource API parses
those lines into events and — this is the part people undervalue — reconnects on its own
when the connection drops, telling the server where it left off. What SSE cannot do is
carry anything upward. It is strictly server-to-client.
WebSockets are a bidirectional byte channel. The client sends an HTTP request with
Upgrade: websocket, the server answers 101 Switching Protocols, and from that point
the connection is no longer HTTP at all — it is a framed, full-duplex pipe defined by
RFC 6455. Either side may send at any
time, with no request to answer.
The axes that actually decide it
| Question | Polling | SSE | WebSockets |
|---|---|---|---|
| Client can send on the same channel | yesevery request is upward | noserver to client only | yesfull duplex, any time |
| Your infrastructure already handles it | yesit is just requests | yesit is one long GET | with workproxy config, timeouts, sticky routing |
| Reconnection is built in | yesnothing to reconnect | yesEventSource retries itself | noyou write backoff and jitter |
| Replay after a gap is built in | with worknext poll gets current state | yesLast-Event-ID, if you buffer | noyour own sequence numbers |
| Latency from event to client | noup to one poll interval | yesimmediate | yesimmediate |
| Survives the six-connection limit | yesconnections are short | with workonly over HTTP/2 | yeslimit does not apply |
| Carries binary without re-encoding | yesordinary response bodies | notext only, so base64 | yesbinary frames |
| Works from a non-browser client | yesany HTTP client | with worktrivial to parse, no built-in API | yeslibraries everywhere |
Read the “your infrastructure already handles it” row before the others. That is where the real cost difference lives, and it is not about the protocols being fast or slow.
Why SSE is cheaper to operate
Because to everything between your client and your server, an SSE stream is just a slow
GET.
Your load balancer already knows how to route a GET. Your reverse proxy already knows
how to stream a response body. Your access logs already have a line for it. Your auth
middleware already reads the Cookie header on it, because it is a normal HTTP request
with normal headers. Your CDN will pass it through. HTTP/2 will multiplex it alongside
your other requests on the same connection.
A WebSocket asks more of every one of those. The upgrade must be forwarded rather than buffered, which means proxy configuration. Idle timeouts must be raised or your connections die at 60 seconds. Load balancers need to understand that this connection is long-lived and stateful, which usually means sticky routing. Observability tooling built around request/response has nothing useful to say about a socket that has been open for four hours.
None of that is hard. All of it is work you don’t do if a one-way stream was all you needed.
The HTTP/1.1 trap that everyone hits once
There is one specific, painful reason SSE gets abandoned by teams who then blame the protocol: browsers cap HTTP/1.1 connections at about six per origin, and an open SSE stream occupies one of those six for its entire life.
Open your app in three tabs and you have consumed three of six. Open it in six and the seventh request — any request, an image, a form post, the page reload — queues indefinitely behind streams that by design never end. The app appears to hang, and nothing in the stack reports an error, because nothing has gone wrong.
Over HTTP/2 the limit effectively disappears: streams are multiplexed onto one connection, with a default concurrency around 100 and no per-stream connection cost. So the trap has a one-line fix — serve over HTTP/2 — and the trap is only lethal on the plain-HTTP local dev server where nobody thinks to check.
WebSockets never had this problem: the connection limit does not apply to them.
What the wire actually looks like
SSE’s format is small enough to read in full, which is a real advantage when you are
debugging at 2 a.m. with curl.
The whole SSE wire format
- event:Dispatches as a named event, so the client uses addEventListener('price') instead of onmessage.
- id:The browser remembers the last id and sends it as Last-Event-ID when it reconnects — replay for free, if the server keeps a buffer.
- retry:Overrides the browser's reconnect delay. This is how you tell a stampede of clients to back off after an outage.
- : commentA comment line is ignored by the parser but resets every idle timeout between you and the client. Send one every 15–30 seconds.
Two fields carry most of the value. id: is what makes reconnection nearly free — the
browser stores the last id it saw and sends it back as Last-Event-ID on reconnect, so a
server that keeps a short buffer of recent events can replay the gap without you writing
any resumption logic. retry: lets the server dictate the reconnect delay, which is how
you tell a stampede of clients to back off after an outage.
A WebSocket gives you neither and cannot: it is a byte pipe, so message ids, sequence numbers, replay-on-reconnect and backoff are all yours to design. That is the honest trade — total freedom over the message format, in exchange for having to invent the delivery semantics you get for free from SSE.
The things both of them will do to you
Whichever you choose, four problems arrive on the first day of production and are not protocol-specific:
Reconnection is not optional. Mobile networks drop connections constantly. EventSource
handles this for you; with a WebSocket you write it, with exponential backoff and jitter,
or you build a synchronised thundering herd that takes down your own server after every
blip.
Gaps need a replay story. A client that was offline for thirty seconds missed thirty seconds of events. Either it can ask for them (a sequence number and a bounded server-side buffer) or your UI is silently wrong until the next full refresh. Decide which, on purpose.
Backpressure is real. A server that produces 1,000 events a second for a client that can absorb 100 will buffer 900 a second somewhere. Either you coalesce (send state, not deltas), sample, or drop — and if you pick none of those, the thing you have actually chosen is unbounded memory growth on the server.
Heartbeats keep the corpse from looking alive. A TCP connection through a NAT or a
proxy can be dead for minutes while both endpoints still believe it is open. WebSockets
have protocol-level ping/pong frames for this; SSE convention is a comment line
(: keep-alive) every 15–30 seconds, which also stops intermediaries from timing out an
idle stream.
So which one?
Pick a feature, get an answer
—
- Direction of traffic
- —
- Why
- —
- What you still have to build
- —
The decision reduces to one question — does the client ever need to send something on this channel? — plus one veto.
If the answer is no, use SSE. Live scores, notification feeds, build logs, an LLM streaming tokens, progress on a long job, a dashboard that updates itself: all one-way, all strictly simpler on SSE, and the streaming-token case is why SSE has had a conspicuous renaissance.
If the answer is yes and the client’s messages are frequent and latency-sensitive — chat, multiplayer, collaborative cursors, anything where a human is waiting on the round trip — use WebSockets.
If the answer is yes but the client sends rarely, do the boring thing: SSE downward and
ordinary POST requests upward. This combination is underrated to the point of being
forgotten. It is two well-understood HTTP mechanisms, it needs no sticky sessions, it
scales exactly like the rest of your app, and it is correct for a surprising share of
“we need WebSockets” features. A POST costs one round trip that a socket would have
saved; if that round trip is not on a human’s critical path, you have paid nothing.
The veto: if you have a hard requirement for binary frames, or you are talking to a
non-browser client, or you need to interoperate with something that already speaks
WebSockets — that decides it regardless of direction. SSE is text-only (binary means
base64 and a third more bytes) and EventSource is a browser API.
The short version
SSE is a GET that never finishes. WebSockets are a socket that stopped being HTTP after
the handshake. The first is one-way and inherits your entire existing HTTP stack including
automatic reconnection and replay-by-event-id; the second is bidirectional and asks you to
build the delivery semantics, the proxy configuration and the sticky routing yourself.
Pick by direction. One-way means SSE, and serve it over HTTP/2 so the six-connection
limit never bites you. Genuinely interactive means WebSockets. Occasional upward traffic
means SSE plus POST, which is the answer most often skipped over for being
insufficiently exciting. And whichever you pick, budget for reconnection, replay,
backpressure and heartbeats on day one, because those four are the actual work and
neither protocol does them for you.