I work on real-time systems, so I get asked this a lot. People usually frame it as REST versus WebSockets and forget there is a third option sitting between them — and that third option is often the one they actually wanted.
Step 1: The question that decides everything
Ask this about each feature:
Who needs to speak, and in which direction?
There are only three answers, and each one has a matching tool:
- Client asks, server answers. → REST
- Server needs to push, client just listens. → SSE
- Both sides talk, constantly. → WebSockets
That is the whole decision. Everything below is detail.
Step 2: REST — the client always starts
With REST the server can never speak first. It waits to be asked, answers, and the connection closes.
Client: GET /orders/123
Server: { "status": "filled" }
[connection closes]
This covers most of any normal app: loading a page, submitting a form, fetching a profile. If the user triggered it, REST is right and nothing else is needed.
Step 3: SSE — the server talks, the client listens
Server-Sent Events is the one people skip. The client opens one normal HTTP connection, the server keeps it open, and pushes messages down it whenever it likes. The client cannot send anything back on that connection — it only listens.
The browser side is genuinely this small:
const events = new EventSource("/api/notifications");
events.onmessage = (event) => {
console.log(JSON.parse(event.data));
};
And the server side, in Express:
app.get("/api/notifications", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const send = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const timer = setInterval(() => send({ time: Date.now() }), 1000);
req.on("close", () => clearInterval(timer));
});
That is the entire protocol — data: something\n\n over a normal HTTP
response that never ends.
Why SSE is nicer than people expect
- Reconnection is automatic. The browser reconnects on its own if the connection drops. With WebSockets you write that yourself, including the backoff.
- It can resume where it left off. Send an
id:with each message and the browser sends back aLast-Event-IDheader when it reconnects, so you can replay what was missed. - It is just HTTP. Your auth cookies, headers, middleware, proxies, logging and load balancers all work unchanged. WebSockets use a different protocol after the upgrade handshake, and some corporate proxies block it.
- It is far less code. No library needed on either side.
Its two real limits
- One direction only. To send something up, you make a normal REST call. That is usually fine — but if the client is sending constantly, it is the wrong tool.
- Text only. No binary frames. For JSON this does not matter.
Step 4: WebSockets — both sides talk
A WebSocket connection stays open and either side can send at any moment.
Client: [connects, stays connected]
Server: { "price": 42150 }
Client: { "subscribe": "ETH-USD" }
Server: { "price": 2280 }
Use this when the client genuinely needs to keep talking too — a chat where people type, a collaborative editor, a game, or a trading client that subscribes and unsubscribes from feeds as the user moves around.
What it costs you:
- You handle reconnection. Connections drop — phones change networks, laptops sleep. You write the retry, the backoff, and the catch-up.
- The server holds state. One open connection per user, which costs memory and makes scaling harder.
- Scaling across instances. If user A is on server 1 and user B on server 2, server 1 must reach B. That usually means a Redis adapter or a broker.
- Authorisation on every message. You authenticate once, then must keep checking this user is still allowed to receive what you are about to send.
That last one bit me. On a trading platform I worked on, the fix was splitting the socket into two namespaces: a public one streaming market data anyone could join, and an authenticated one for a user's own portfolio. Separating them meant a bug in the public feed could never leak someone's private positions.
Step 5: Side by side
REST SSE WebSocket
Direction client→ server→ both ways
Connection per request persistent persistent
Protocol HTTP HTTP ws:// upgrade
Auto-reconnect n/a yes, free you write it
Works through proxies always almost always sometimes blocked
Binary data yes no yes
Code to write least little most
Server memory none one per user one per user
Step 6: Do not skip plain polling
Before any of this, consider just asking again:
setInterval(() => {
fetch("/api/notifications").then(update);
}, 30000);
If your data changes every few minutes and a 30-second delay is fine, poll. It is simpler, survives network drops for free, and needs no extra infrastructure.
Polling becomes wrong when the interval gets short. One user polling every 3 seconds is nothing. Ten thousand users polling every 3 seconds is 3,300 requests per second, nearly all returning "nothing changed". That is when a persistent connection becomes cheaper.
Step 7: What I actually reach for
- REST for everything the user initiates. Most of the app.
- Polling if updates are slow and a delay is acceptable.
- SSE for one-way pushes: notifications, live dashboards, progress bars, activity feeds, streaming an AI response token by token. This is the one people under-use.
- WebSockets only when the client must send continuously too: chat, collaborative editing, games, trading clients.
Two things worth knowing. First, you will use several of these together — that is normal, not a design failure. Second, a useful pattern for any live feature: load the current state over REST first, then apply live updates on top. A user who just reconnected sees correct data immediately instead of waiting for the next event.
Short version: client asks → REST. Server pushes and the client only listens → SSE, and it is simpler than you think. Both sides talking constantly → WebSockets, and accept the reconnection and scaling work that comes with it. If updates are rare, plain polling is still a respectable answer.