logo

Imad Attif

WebSockets vs SSE vs Polling: How to Stream Data in Real Time

Imad Attif, Sr. Frontend Engineer

14 min read

Sep 10, 2025

Every app eventually needs data that moves on its own: chat messages arriving, an order status updating, an AI response typing itself out. And every team facing it runs into the same menu of options: polling, Server-Sent Events (SSE), WebSockets, and plain HTTP streaming, usually accompanied by advice that just says "WebSockets are real-time" and stops.

This guide explains how each technique actually works, what each one costs (including the cost nobody mentions: your users' phone batteries), and a decision rule that covers nearly every case. It's informed by how production apps genuinely do this; the case study at the end runs two of these techniques side by side in one codebase, each chosen for a reason.

The short version:

  • Polling asks the server "anything new?" on a timer. Trivial to build, fine for simple desktop dashboards, wasteful everywhere else.
  • SSE holds one HTTP response open and lets the server push text whenever it wants. Cheap, scales well, reconnects automatically, but it's one-way.
  • HTTP streaming is the same one-way idea applied to a single response: the answer arrives in chunks you render as they come. It's how AI chat interfaces work.
  • WebSockets upgrade the connection to a raw two-way channel. The most capable option and by far the most expensive to run properly.

The rule of thumb we'll justify: push to receive, POST to send, and reach for WebSockets only when you truly need both directions at low latency.

The problem: HTTP wants to hang up

HTTP's native shape is request-response: the client asks, the server answers, the conversation ends. Nothing in that shape lets a server say "hey, something just happened." Every technique in this post is a different way of bending HTTP (or escaping it) to get server-initiated updates:

  1. Ask repeatedly (polling).
  2. Ask once and never let the response finish (SSE, HTTP streaming).
  3. Replace the protocol mid-connection (WebSockets).

That's really the whole taxonomy. Now the details, cheapest first.

Polling: just keep asking

Polling is a normal GET on an interval:

1setInterval(async () => {2  const res = await fetch('/api/orders?since=' + lastSeenId);3  const fresh = await res.json();4  if (fresh.length) render(fresh);5}, 20_000);

There's nothing to set up on the server; it's a regular endpoint. That's the entire appeal: zero new infrastructure. For an internal admin dashboard on a desktop, where the machine is plugged in and twenty seconds of staleness is fine, polling is genuinely the right amount of engineering.

The costs show up as soon as those conditions break:

  • Every request pays full price. A fresh request means connection work and re-sent headers every time, whether or not there's new data. Most polls return nothing; you're paying round-trips to hear "no news."
  • Latency is the interval. News arrives, on average, half your polling interval late. Tightening the interval multiplies the waste.
  • Mobile pays double. More on this below, but repeatedly opening connections to send requests is close to the worst thing you can do to a phone's battery.

Long polling (the server holds the request open until it has news, then the client immediately re-asks) trades some of the waste for implementation fiddliness. It mattered before SSE and WebSockets were universally supported; today it's mostly a legacy pattern.

Server-Sent Events: one response that never ends

SSE flips the model with a beautifully small trick: the client makes one GET, and the server just never finishes the response. It keeps the connection open and writes a new chunk whenever it has something to say. The browser API is built in:

1const events = new EventSource('/api/notifications');2
3events.onmessage = (e) => {4  const notification = JSON.parse(e.data);5  render(notification);6};

The server side is nearly as small; it's a response with Content-Type: text/event-stream that writes lines shaped like data: ...\n\n:

1// Express-ish sketch2app.get('/api/notifications', (req, res) => {3  res.setHeader('Content-Type', 'text/event-stream');4  res.setHeader('Cache-Control', 'no-cache');5
6  const send = (event) => {7    res.write(`id: ${event.id}\n`);8    res.write(`data: ${JSON.stringify(event)}\n\n`);9  };10
11  const unsubscribe = notifications.subscribe(req.user.id, send);12  req.on('close', unsubscribe);13});

Three properties make SSE the workhorse of real-time reads:

  • Reconnection is built into the protocol. If the connection drops, EventSource reconnects by itself and sends the last event id it saw in a Last-Event-ID header, so the server can resume from where the client left off. You get connection recovery without writing any of it, and it makes horizontal scaling pleasant: any server instance can pick up the resumed stream.
  • It's just HTTP. It flows through proxies, load balancers, and auth middleware like any request. No new protocol to teach your infrastructure.
  • It's receive-only after the handshake, which sounds like a limitation but is exactly what makes it cheap, both on servers and on phone batteries.

The limitations to know: SSE is one-way (client to server goes over normal HTTP requests) and text-only (JSON is fine; binary and media are not, that's WebRTC territory). One operational note: under HTTP/1.1, browsers cap connections per domain at about six, and an SSE stream permanently occupies one, so SSE really wants HTTP/2, where many streams multiplex over a single connection and the cap stops mattering.

HTTP streaming: how AI responses "type"

The ChatGPT-style effect (an answer materializing word by word) is the same never-finish-the-response idea applied to a single request's body. The server sends the response in chunks as the model generates it; the client reads the stream and renders as chunks arrive:

1const res = await fetch('/api/generate', {2  method: 'POST',3  body: JSON.stringify({ prompt }),4});5
6const reader = res.body.getReader();7const decoder = new TextDecoder();8
9while (true) {10  const { done, value } = await reader.read();11  if (done) break;12  answer.textContent += decoder.decode(value, { stream: true });13}

Two things worth understanding here. First, the "typing" is a rendering choice, not the network's granularity: chunks arrive in bursts of tokens, and the UI decides whether to append them instantly or pace them out for effect. Second, SSE and raw streaming are siblings: many AI APIs actually format their streams as SSE events over a POST-initiated stream, because the data: framing gives you clean message boundaries for free. Either way, the transport story is identical: one-way, text, over plain HTTP.

If you're building an AI feature, this is almost always the right transport for the response. You don't need a WebSocket to stream a completion; the response is inherently one-directional.

WebSockets: replacing the protocol

A WebSocket starts as an HTTP request with an Upgrade header. The server agrees, and from that moment the connection stops being HTTP: it becomes a raw, persistent, full-duplex channel where either side can send anything at any time.

1const ws = new WebSocket('wss://example.com/live');2
3ws.addEventListener('message', (e) => {4  applyUpdate(JSON.parse(e.data));5});6
7send.addEventListener('click', () => {8  ws.send(JSON.stringify({ type: 'move', x, y }));   // client pushes too9});

This is the most capable option: lowest latency, both directions, binary supported. The engineering honesty is about what it costs, because WebSockets are less a feature and more an infrastructure commitment:

  • They're stateful. An HTTP request carries everything needed to serve it; a socket accumulates context. When a server instance dies or deploys, every socket it held drops with its state, so you build reconnection logic, state recovery, and usually a persistence layer behind the sockets. (In production this means things like a client-side reconnecting wrapper class, plus server-side session state in Redis.)
  • They fight your HTTP infrastructure. Load balancers need sticky sessions or a pub/sub backplane so a message for a user reaches the instance holding their socket. Every proxy and timeout in the chain needs to understand long-lived connections.
  • Scale is real work. Each open socket holds server memory and file descriptors, and fan-out (one message to 100k sockets) is CPU you provision for. None of this is impossible (large deployments hold hundreds of thousands of concurrent sockets), but compare it with SSE's "it's just HTTP responses" story and the difference in operational weight is enormous.

When they're worth it: genuinely bidirectional, timing-sensitive traffic. Multiplayer games, live trading, collaborative cursors, hardware telemetry with commands going back. The tell is that the client sends frequently and the sends need the same low latency as the receives.

When they're not: chat. It feels like the canonical WebSocket app, but look at the traffic pattern: you receive constantly, you send occasionally, and a 100ms send latency is imperceptible. SSE for the incoming stream plus a plain HTTP POST for sending covers it with a fraction of the infrastructure. (Collaborative editing is a special case: the transport is the easy half; the hard half is the merge logic, CRDTs and friends, which no transport choice solves for you.)

The hidden cost: your user's battery

Here's the dimension most comparisons skip entirely. A phone's radio has two power modes: a cheap receive-only mode and an expensive duplex (send-and-receive) mode. Which mode your transport forces is a real architectural consequence:

  • Polling forces the radio into duplex over and over, once per request, with a fresh connection each time. Worst case.
  • WebSockets hold a duplex-capable connection continuously. An idle open socket can meaningfully drain a phone's battery over a few hours.
  • SSE needs duplex only for the initial handshake, then sits in receive-only mode indefinitely. Cheapest by far.

On desktop, plugged in and stationary, none of this matters, which is why polling remains a legitimate choice there. On mobile (which is most traffic), it can decide the architecture on its own: if users will keep your app open on a phone, a receive-only transport isn't a nice-to-have, it's respect for their battery.

How to choose: a worked decision

Run each data flow through three questions: which direction does it flow, how latency-sensitive is the client-to-server direction, and who's on the other end (desktop or mobile)?

Take a shop admin app. New orders appearing: server-to-client only; SSE (polling is acceptable on desktop). Incoming support messages: same shape, SSE. Sending a reply: client-to-server, no special latency need, so a plain HTTP POST. Nothing in the app justified a socket.

Then the production counter-example: a fintech app I studied runs both WebSockets and SSE, deliberately. WebSockets carry the interactive AI chat (bidirectional by nature: the user interrupts, the agent responds, tools stream status both ways), with a reconnecting client wrapper because sockets drop. SSE carries one-way document streams to a live view. Two flows, two directions, two transports, each the cheapest thing that does its job. That's the pattern to copy: choose per data flow, not per app.

Edge cases that override the defaults: audio, video, or game-state binary at scale wants WebRTC (peer-to-peer, UDP-based, built for media), not any of the above. And if you just need "refresh the dashboard every minute" on desktop, don't let this post talk you out of a 10-line poller.

FAQ

Should I use WebSockets or SSE for streaming LLM responses? SSE or plain HTTP streaming. A model response is one-directional text, which is exactly SSE's shape, and you keep normal HTTP infrastructure, auth, and automatic reconnection. Use WebSockets only when the session is truly interactive both ways, such as an agent the user interrupts mid-response or voice.

Does SSE work over HTTP/1.1? Yes, but each stream occupies one of the roughly six connections browsers allow per domain, which bites as soon as you have a couple of streams and normal requests. Behind HTTP/2 the streams multiplex over one connection and the concern disappears.

Is long polling still relevant? As a fallback for environments that block streaming responses, and inside some older libraries. For new work, SSE dominates it: same one-way semantics, less overhead, native reconnection.

How many WebSocket connections can a server handle? With tuning, a single beefy instance can hold hundreds of thousands; the practical limits are memory per socket, file descriptors, fan-out CPU, and the statefulness (deploys and failures drop every socket on the box). The realistic question isn't the max, it's whether you want to operate that system when a one-way transport would do.

What about WebTransport? A newer API over HTTP/3 offering low-latency streams and datagrams, aimed at the WebSocket-and-beyond use cases. Worth watching, but browser and infrastructure support still trail, and everything in this post about matching direction to transport applies to it unchanged.

Summary

  • All real-time techniques are ways around HTTP's ask-then-hang-up shape: ask repeatedly (polling), never finish the response (SSE, HTTP streaming), or swap protocols (WebSockets).
  • Polling is fine for simple desktop dashboards and honest about being wasteful everywhere else.
  • SSE is the default for server-to-client data: cheap, scales like HTTP, reconnects itself, battery-friendly. Pair it with plain POSTs for the client-to-server direction.
  • HTTP streaming (often SSE-framed) is the right transport for AI responses; the typing effect is a client-side rendering choice.
  • WebSockets buy true bidirectional low latency at the price of stateful, HTTP-hostile infrastructure. Think twice, then think again, then use them where they're genuinely needed: games, trading, live collaboration, interactive agents.
  • On mobile, transports that hold the radio in duplex mode (polling, sockets) tax the battery; receive-only SSE doesn't. For phone-heavy audiences this alone can pick the transport.

Push to receive, POST to send, sockets when timing truly demands both directions. That one sentence resolves almost every real case.