Software / Architecture

WebSocket vs SSE vs Long Polling: Choosing Real-Time Communication

/10 min read

WebSocket, SSE (Server-Sent Events), and long polling are three ways to get data from server to client without the client asking each time. Long polling is legacy fallback, SSE is HTTP-native one-way push, and WebSocket is full-duplex over a single upgraded connection — the right choice depends almost entirely on whether you need the client to talk back.

The Real-Time Spectrum

Every HTTP request-response cycle is the client pulling data. Real-time features invert that: the server should be able to push an update the moment something happens. The three techniques differ in two dimensions — direction (one-way vs bidirectional) and mechanism (plain HTTP vs a protocol upgrade).

A useful mental model: ask "does the client need to send data on the same channel?" If updates flow one way and the client replies via ordinary POSTs, you almost certainly do not need WebSockets. If both sides must talk continuously over the same connection — chat typing indicators, collaborative cursors, game state — you do.

Long Polling: Why It Still Exists

Long polling is the client making an ordinary HTTP request the server deliberately does not answer until it has something to say (or a timeout expires). The client then immediately issues the next request. It is polling with the interval squeezed to near zero by holding the connection open.

Its virtues are compatibility and simplicity: it works through every proxy, CDN, load balancer, and corporate firewall ever built, using nothing but HTTP/1.1. For years it powered the original Chat workarounds, notification badges, and game lobbies — and it remains a legitimate fallback when SSE or WebSocket connections are blocked.

The cost is constant reconnects. Every cycle pays a full HTTP handshake, TLS negotiation (unless connections are reused), header overhead, and server-side setup for a response that is often empty. At a handful of clients this is invisible; at tens of thousands of persistent pollers it produces a background thundering herd of connection churn that dominates your infrastructure metrics. Add timeouts from intermediaries that cannot tell a silent long poll from a dead connection, and you inherit the same operational problems as the fancier techniques — with worse efficiency.

SSE: One-Way Push, Plain HTTP

Server-Sent Events is HTTP with Content-Type: text/event-stream: the server writes a stream of UTF-8 text events, the client stays connected and consumes them. The browser EventSource API handles reconnection automatically with backoff, resubscribes correctly, and exposes named events — capabilities WebSocket clients give you only after you write them.

Because it is plain HTTP, SSE plays nicely with the existing ecosystem: cookies and auth work as they do on any request, CORS applies normally, CDNs and load balancers route it without special configuration (just disable response buffering), and HTTP/2 multiplexing lets many event streams share one TCP connection alongside ordinary API calls — eliminating the connection-per-feature problem entirely.

// Next.js route handler — SSE over plain HTTP
export async function GET(req) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      const send = (event, data) => {
        controller.enqueue(
          encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
        );
      };

      send("connected", { ts: Date.now() });

      const unsubscribe = events.subscribe((e) => send(e.type, e.payload));

      // Heartbeat keeps proxies and load balancers from closing the stream
      const heartbeat = setInterval(() => send("ping", {}), 25_000);

      req.signal.addEventListener("abort", () => {
        clearInterval(heartbeat);
        unsubscribe();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",   // disable nginx buffering
    },
  });
}

// Client — EventSource reconnects automatically
const es = new EventSource("/api/events");
es.addEventListener("order.created", (e) => {
  render(JSON.parse(e.data));
});

Two honest constraints. SSE is text-only (UTF-8); binary payloads must be encoded (base64, or a binary framing inside the data field). And each browser tab opens its own stream, so a very large number of concurrent tabs multiplies open connections — mitigated by HTTP/2 but still worth sizing for.

WebSocket: Full-Duplex on One Connection

WebSocket starts as an HTTP request with Upgrade: websocket headers; the server responds 101 Switching Protocols and the same TCP connection now speaks the WebSocket framing protocol — binary or text, bidirectional, with per-message framing that is far lighter than re-sending HTTP headers per update. One handshake, then messages flow both ways for as long as the connection lives.

This is the right tool when the client is a continuous participant: sending chat messages, emitting collaborative edits, streaming game inputs, acknowledging receipts in real time. Libraries ( ws on Node, Socket.IO if you want rooms and fallbacks, or native new WebSocket() in the browser) make the endpoint itself small; the work lives in operations.

import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws, req) => {
  const user = authenticate(req);          // verify cookie/token on upgrade
  if (!user) return ws.close(4001, "unauthorized");

  const channel = subscribe(user.id, (msg) => {
    // Backpressure: skip frames if the socket buffer is already backed up
    if (ws.bufferedAmount > 1_000_000) return;
    ws.send(JSON.stringify(msg));
  });

  ws.on("message", (raw) => {
    const msg = safeParse(raw);            // never trust the client framing
    if (msg) publish(user.id, msg);        // fan-out via Redis pub/sub below
  });

  ws.on("pong", () => markAlive(ws));
  ws.on("close", () => unsubscribe(channel));
});

// Fan-out across many server instances needs a shared bus:
// redis.subscribe("user:" + userId) on every node, forward to local sockets.

WebSockets are a stateful protocol on infrastructure that prefers stateless. That single sentence explains most of their operational cost: load balancers need long idle timeouts (60+ seconds) and, without an external message bus, sticky routing; health checks must account for upgraded connections; rolling deploys must drain sockets gracefully; and horizontal scaling requires a pub/sub bus (Redis, NATS, or a managed service) so a message published on node A reaches a socket connected to node B.

Head-to-Head Comparison

The axes that decide real architecture reviews:

DimensionLong pollingSSEWebSocket
DirectionServer → client (client re-asks)Server → client onlyFull duplex
ProtocolPlain HTTPPlain HTTP (text stream)HTTP upgrade, then ws://
Per-message overheadFull request/response cycleSmall text framingMinimal binary framing
ReconnectionBuilt in (it is the loop)Automatic in EventSourceManual — you write backoff
Proxy/LB friendlinessUniversalHigh — plain HTTP, disable bufferingNeeds upgrade support + long timeouts
Binary dataYes (any response body)No — text only, encode binaryNative
Scale modelConnection churn; stateless OKOpen streams; stateless servers OKStateful sockets + pub/sub bus
Browser supportUniversalAll modern (EventSource)All modern (WebSocket)
Best fitFallback path onlyNotifications, dashboards, streamsChat, collab, games

Pattern Guide: What to Pick for Common Features

Chat and collaboration

WebSocket. Messages, presence, typing indicators, and read receipts all flow both directions continuously, and ordering matters per conversation. SSE can carry the down-stream, with sends over POST — a legitimate simplification for small products — but the moment latency and bidirectional chatter dominate, a real WebSocket channel wins.

Notifications, activity feeds, live dashboards, scores

SSE is usually enough. One-way push of JSON events matches these exactly, EventSource gives you free reconnection, and your servers stay stateless — which means ordinary horizontal scaling with no pub/sub bus required per socket. Trading dashboards, ops monitors, and live-score boards have run on SSE for years for this reason.

LLM token streaming

SSE (or the fetch streaming equivalent). Tokens flow server → client, one direction, text, high frequency — the exact shape SSE was designed for, and what the major AI APIs use for streaming completions. Client inputs go back over ordinary requests.

Multiplayer games and CRDT sync

WebSocket, non-negotiable. Per-frame inputs from every participant, binary payloads, strict latency budgets, and continuous two-way streams. CRDT collaboration editors (Figma-style) have the same profile: local ops must reach the server and remote ops must reach every client within milliseconds over a shared channel.

Background jobs with occasional updates

If the client waits on an async operation (export, upload, render) and only needs progress events, SSE while the job runs — or even polling a status endpoint every few seconds — beats maintaining a persistent socket infrastructure for a feature used by a fraction of users at any moment.

Operational Considerations (All Three)

  • Heartbeats: Send periodic pings (SSE named events or WebSocket ping frames) every 20–30 seconds. They keep idle connections alive through NAT tables, load balancers, and proxies that silently drop silent flows, and they give you liveness detection to reap dead sockets.
  • Backpressure: A slow consumer with a fast producer will exhaust memory. Check ws.bufferedAmount before sending, drop or coalesce stale frames (latest dashboard snapshot beats a queue of 500), and cap per-connection queues.
  • Fan-out via pub/sub: In any multi-instance deployment, publish events to Redis or NATS and have every node deliver to its local connections. Direct in-process buses work at one node and fail silently at two.
  • LB and proxy timeouts: Raise idle timeouts above your heartbeat interval, disable response buffering for SSE ( X-Accel-Buffering: no for nginx, flush per event), and confirm your CDN supports streaming for the paths that need it.
  • Auth and expiry: Authenticate during the handshake or the initial HTTP request (cookies or tokens), then plan for credential expiry — many long-lived connections outlive their tokens. Design re-authentication or graceful close-and-reconnect into the client.
  • Connection limits: Browsers cap concurrent connections per origin (historically six on HTTP/1.1 — largely moot on HTTP/2), and servers cap file descriptors. Budget open connections explicitly: 10,000 idle WebSockets is a number you provision for, not one you discover.

A Pragmatic Fallback Strategy

Real-world clients degrade deliberately: try WebSocket, fall back to SSE for one-way needs, fall back to long polling when even event streams are blocked by a hostile network. This is precisely what Socket.IO formalized (WebSocket with automatic transports fallback), and it is a sound pattern even with hand-rolled clients. The key is designing your event contract above the transport — same JSON envelopes, same event names — so the fallback is a transport swap, not a rewrite.

Also instrument what you ship: track open connections, reconnect rates, per-connection message rates, and dropped-frame counts. Transport problems present as "the app feels laggy" in user reports and nowhere else; metrics are the only way to see them coming.

Conclusion

Long polling earned its retirement: it works everywhere but costs constant connection churn, and should now live only as a fallback. SSE has quietly won the largest category of real-time features — one-way push over plain HTTP, with automatic reconnection and stateless servers — including most notification systems and LLM token streams. WebSocket remains indispensable where the client is a continuous participant over a shared bidirectional channel.

Verdict: default to SSE for server-to-client push; reach for WebSocket when both sides truly talk on the same channel at conversational frequency; keep long polling only as the last rung of a fallback ladder. Whatever you choose, budget for heartbeats, backpressure, pub/sub fan-out, and proxy timeouts — the transport is the easy half.

Key Takeaways

  • Choose by direction: one-way push (notifications, dashboards, token streaming) → SSE; continuous bidirectional traffic (chat, collab, games) → WebSocket.
  • SSE is plain HTTP: EventSource auto-reconnects, cookies and CORS work normally, and servers stay stateless — no pub/sub bus required per connection.
  • WebSockets are stateful on stateless infrastructure: budget long LB timeouts, a Redis/NATS fan-out bus for multi-node, and graceful drain on deploys.
  • Long polling is a fallback, not a default — its constant reconnects dominate infrastructure cost at scale despite universal compatibility.
  • Operational details decide perceived quality: 20–30s heartbeats, backpressure checks, buffered-amount guards, and connection metrics.

FAQ

Is SSE better than WebSocket?

Better for one-way server push, worse for bidirectional traffic. SSE keeps servers stateless, auto-reconnects in the browser, and runs over plain HTTP, which makes it easier to deploy through proxies and load balancers. WebSocket is required when the client must send continuous updates over the same connection — chat, collaboration, multiplayer games.

When should I still use long polling?

Almost never as a primary transport. It remains useful as a last-resort fallback on networks that block WebSocket upgrades and event streams, and in constrained environments where you need guaranteed compatibility with ancient proxies. For anything new, SSE or WebSocket is more efficient and easier to operate.

Does SSE work with HTTP/2 and load balancers?

Yes. HTTP/2 multiplexes many event streams over one TCP connection, which solves the historical per-connection cost. Most load balancers route SSE as ordinary HTTP, provided you raise idle timeouts above your heartbeat interval and disable response buffering (for nginx, send X-Accel-Buffering: no and flush after each event).

How do I scale WebSockets across multiple servers?

Through an external pub/sub bus. Each server instance subscribes to channels in Redis, NATS, or a managed service and delivers messages only to sockets connected locally; publishers broadcast through the bus so a message from node A reaches sockets on node B. Sticky routing alone does not solve cross-node delivery — it only keeps a single client on one node.

What is SSE used for in AI applications?

Streaming LLM completions token by token. The model output flows one direction over a text stream, which matches SSE exactly, and the major AI APIs expose streaming via SSE (often consumed with fetch for header control). User prompts travel back over ordinary POST requests — no bidirectional channel needed.

References

  1. MDN Web Docs. "Server-sent events." developer.mozilla.org/.../Server-sent_events
  2. MDN Web Docs. "The WebSocket API." developer.mozilla.org/.../WebSockets_API
  3. RFC 6455. "The WebSocket Protocol." IETF. rfc-editor.org/rfc/rfc6455
  4. WHATWG. "Server-sent events — EventSource." HTML Living Standard. html.spec.whatwg.org/multipage/server-sent-events.html
  5. Socket.IO. "How it works: transports and fallback." socket.io/docs/v4/how-it-works
  6. Redis. "Pub/Sub." redis.io/docs/latest/develop/pubsub
Summarize with AI
Page