Back to Blog

WebSockets, SSE or Polling: Picking the Right Real-Time Transport

WebSockets, SSE or Polling: Picking the Right Real-Time Transport cover image

A dashboard I inherited polled six endpoints every three seconds. With about 200 concurrent users that was 24,000 requests a minute, and roughly 99% of them returned data identical to the previous response. The database was busy. The bill was silly. And the dashboard still felt laggy, because a three-second poll means an average delay of a second and a half.

The fix was not "add WebSockets," which was the first suggestion. Most of those six panels changed a few times an hour. Two of them needed to be genuinely live. Treating those two cases the same was the actual mistake.

Choosing a real-time transport is mostly about being honest about how fresh the data has to be, and in which direction it flows.

The Four Options, Plainly

Polling. The client asks on a timer. Trivial to build, works everywhere, caches well, no persistent state on the server. Wasteful when data rarely changes, and latency is half your interval on average.

Long polling. The client asks, the server holds the request open until something happens or a timeout hits, then the client asks again. Near-instant delivery over plain HTTP. Holds a connection per client, and mostly obsolete now that SSE is universally supported.

Server-Sent Events. One long-lived HTTP connection, server pushes messages down it, client cannot send anything back on it. Plain HTTP, so proxies and load balancers understand it. Automatic reconnection with resume is built into the browser API — you get it for free, which people consistently underestimate.

WebSockets. A full-duplex connection after an HTTP upgrade. Both sides send at any time, low overhead per message, binary support. The most capable and the most operationally demanding.

The Question That Decides It

Forget the feature matrices. Ask: does the client need to send messages at high frequency, or only receive?

If it only receives — notifications, live metrics, order status, progress bars, streaming AI responses, activity feeds — use SSE. It is one endpoint, it works through every proxy that understands HTTP, and reconnection is handled for you. The client can still send data; it just uses a normal POST, which is fine because those are rare.

If both sides send constantly — collaborative editing, chat with typing indicators, multiplayer, live cursors — use WebSockets. You need the duplex channel and the per-message overhead matters.

If the data changes a few times an hour — keep polling. A 30-second poll on a cacheable endpoint is not a problem worth solving, and it will still be working in five years with zero maintenance.

That dashboard ended up with all three: SSE for the two live panels, a 60-second poll for three slow ones, and one panel that turned out to only change on user action and needed nothing at all.

SSE Is Simpler Than People Expect

// Server (Node/Express). Note the headers — all three matter.
app.get("/events", auth, async (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache, no-transform",
    "Connection": "keep-alive",
    "X-Accel-Buffering": "no",          // stops nginx buffering the stream
  });

  const since = req.header("Last-Event-ID");        // browser sends this on resume
  if (since) for (const e of await backlog(since, req.user)) send(e);

  const send = (e: Event) =>
    res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);

  const unsubscribe = bus.subscribe(req.user.tenantId, send);
  const beat = setInterval(() => res.write(": keepalive\n\n"), 25_000);

  req.on("close", () => { clearInterval(beat); unsubscribe(); });
});
// Client. Reconnection is automatic — that is the whole selling point.
const es = new EventSource("/events");
es.addEventListener("order.updated", (e) => apply(JSON.parse(e.data)));

Three details do the heavy lifting. The id: field is what makes Last-Event-ID work, so a client that drops for ten seconds resumes without missing events — and if you do not replay a backlog on reconnect, you have built a system that silently loses messages on every network blip. The heartbeat comment keeps proxies from closing an idle connection. And X-Accel-Buffering is the header people spend an afternoon on when nginx holds their stream in a buffer and nothing arrives until the connection closes.

What Actually Breaks in Production

Connections are stateful and your servers are not. A user connected to instance 3 will not receive an event published by instance 7. You need a shared bus — Redis pub/sub is the usual answer — between your publishers and your connection handlers. This is the thing that works perfectly in development with one process and fails immediately on deploy.

Reconnect storms. Deploy a new version and every client reconnects within a second. Add jitter to the client's retry, and for WebSockets, exponential backoff. Without it, a rolling deploy becomes a self-inflicted load test.

Load balancer timeouts. Most default to closing idle connections after 30-60 seconds. Heartbeats solve it, but you have to know the number your infrastructure uses.

Serverless is a poor fit. Long-lived connections and per-invocation billing with execution limits do not go together. Run this on a real server or a platform designed for it, or use a managed service.

Auth on WebSockets is awkward. The browser API cannot set headers on the handshake, so people put tokens in the query string, where they end up in access logs. Authenticate on the first message after connect instead, and close the socket if it does not arrive quickly.

Fan-out cost. One event to 10,000 connections is 10,000 writes. Above a few thousand concurrent connections, a managed service is usually cheaper than the engineering time to do it well.

Optimistic Updates Beat Faster Transports

The point that gets missed in transport arguments: most perceived latency is on the write path, not the read path.

A user clicks a button, and the interface waits for the round trip before showing anything. No transport fixes that. Applying the change locally and reconciling with the server response makes the app feel instant regardless of what is underneath.

Which is why I would reach for optimistic updates before reaching for WebSockets on a typical CRUD product. The thing that felt slow about that dashboard was not really the three-second poll — it was that every user action had a visible pause before anything happened.

The Order I Would Work In

Poll until it hurts, and be honest about when it actually hurts. Add optimistic updates for anything a user initiates. When you genuinely need push, start with SSE. Move to WebSockets only when the client needs to talk back constantly.

And measure the poll before replacing it. Twenty-four thousand requests a minute sounded like an obvious problem, and it was — but only two of those six panels ever justified a persistent connection.

Related Posts