Topics
Networking & Protocols

WebSockets vs SSE vs Polling

Three ways to get server events into a browser, what each costs at the proxy and at scale, and a decision rule you can defend in a design interview.

Intermediate·13 min read·Updated Sep 28, 2026

HTTP is request-response: the server can only speak when asked. When the server has news, you need a push channel, and there are three: polling asks again and again, Server-Sent Events (SSE) keeps one HTTP response open and streams events down it, and a WebSocket upgrades the TCP connection into a two-way message pipe. The right one depends on direction, message rate, and what your proxies and hosting can hold open.

Context

The AJAX (asynchronous JavaScript and XML) era (2005) made it normal to fetch data without reloading, and the first "live" features simply fetched on a timer. Gmail's chat (2006) and the "Comet" techniques hung a request open until something happened, which the industry later named long polling. Two standards then arrived almost together: EventSource for server-sent events (specified in HTML5, shipped in browsers around 2010-2011) and the WebSocket protocol (RFC 6455, December 2011). HTTP/2 (2015) removed SSE's biggest limitation, the six-connections-per-host cap, by multiplexing streams over one connection.

You meet all three constantly. A notifications badge that updates every 30 seconds is polling. A GitHub Actions log that scrolls as the job runs, or a ChatGPT answer that appears word by word, is SSE: OpenAI's streaming API returns text/event-stream. Slack, Figma, multiplayer games and trading dashboards are WebSockets, usually behind a library such as Socket.IO, Phoenix Channels or GraphQL subscriptions.

The simplest push you can ship is three lines:

client.js
const es = new EventSource('/events')
es.onmessage = e => console.log('server said:', e.data)
// the browser reconnects by itself if the connection drops
Polling
Client sends a normal request on an interval. Most responses say "nothing new".
Long polling
Client sends a request; server holds it open until there is data or a timeout, then the client immediately sends another.
SSE
Server-Sent Events. One long-lived HTTP GET whose body is a stream of data: lines. Server → client only, text only.
WebSocket
A separate protocol (ws:// / wss://) bootstrapped from an HTTP request via an Upgrade handshake. Full-duplex, text or binary frames.
Fan-out
Delivering one event to many open connections, possibly across many server instances.

Why it matters

Every product eventually needs something live, and the choice is expensive to reverse. Pick WebSockets for a feature that only ever pushes text, and you inherit a stateful tier that cannot run on serverless, needs sticky routing or a pub/sub layer, and breaks behind any proxy that was not configured for Upgrade. Pick polling for a chat, and you get seconds of latency plus a server that spends most of its CPU saying "no news". Pick SSE and forget proxy buffering, and the stream arrives all at once after 60 seconds, looking exactly like a bug in your code.

What actually happens on the wire

PollingclientserverGET /updates → 204 · GET → 204 · GET → 204 · GET → 200 (finally)latency = up to one interval · cost = one full request per checkServer-Sent EventsclientserverGET /events · Accept: text/event-stream → response never ends, chunks arrive as eventsone direction · auto-reconnect with Last-Event-ID · plain HTTP, works through HTTP/2WebSocketclientserverHTTP Upgrade → 101 · then binary/text frames both ways · no reconnect built in
Three connection shapes over time. Polling pays a full request per check; SSE pays one request and streams; a WebSocket pays one handshake and then frames flow both ways.

The WebSocket handshake

  1. 1
    The browser sends an ordinary HTTP/1.1 GET with Connection: Upgrade, Upgrade: websocket and a random Sec-WebSocket-Key. Because it starts as HTTP, it passes through firewalls and the same port 443.
  2. 2
    The server answers 101 Switching Protocols with Sec-WebSocket-Accept, which is SHA-1 of the key plus a fixed GUID (globally unique identifier). That proves a WebSocket-aware server answered, not a cache or an unrelated HTTP server.
  3. 3
    From this byte on, the TCP connection carries WebSocket frames (2-14 bytes of header per message), not HTTP. Every proxy in between must understand the Upgrade or the connection dies.
  4. 4
    Either side sends ping/pong control frames to detect dead peers; either side sends a close frame with a status code to end it. Reconnecting is your job.
handshake.http
GET /chat HTTP/1.1
Host: app.example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Cookie: session=8f3a…            ← the only auth the browser API can send

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

SSE on the server

app/events/route.ts (Next.js route handler)
export async function GET(req: Request) {
  const lastId = Number(req.headers.get('last-event-id') ?? 0)
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    start(controller) {
      const send = (id: number, data: unknown) =>
        controller.enqueue(encoder.encode(
          `id: ${id}\nevent: order\ndata: ${JSON.stringify(data)}\n\n`,
        ))

      // replay anything the client missed while disconnected
      for (const ev of eventsSince(lastId)) send(ev.id, ev.payload)

      const unsubscribe = bus.subscribe(ev => send(ev.id, ev.payload))
      const heartbeat = setInterval(
        () => controller.enqueue(encoder.encode(': ping\n\n')), 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',
      'X-Accel-Buffering': 'no',   // tell Nginx not to buffer this response
    },
  })
}

Three details carry the whole SSE reliability story. Each event can carry an id:; when the browser reconnects (which it does automatically, with a default 3-second delay you can tune via retry:) it sends the last one it saw as Last-Event-ID, so the server can replay the gap. The comment line : ping keeps idle proxies from closing the connection. And no-transform plus X-Accel-Buffering: no stop intermediaries from holding the body until it is "complete", which for a stream is never.

Side by side

PollingSSEWebSocket
Directionclient asksserver → clientboth ways
PayloadanythingUTF-8 text (JSON works)text or binary frames
Latencyup to one intervalimmediateimmediate
Reconnecttrivial, it is just requestsbuilt into the browser, with Last-Event-ID resumeyou write it: backoff, jitter, state resync
Proxies / CDNsplain HTTP, cacheable with ETagplain HTTP; needs buffering disabledneeds Upgrade support end to end
HTTP/2 / 3yesyes, and it lifts the 6-connection limitHTTP/2 via RFC 8441, HTTP/3 via RFC 9220; support is patchy
Authheaders as usualcookies only from EventSource (fetch + ReadableStream for headers)cookies or a ticket in the URL / first message; no custom headers
Serverlessfinefine while the function may stream and stays under its max durationneeds a long-lived process or a managed WS service
Server statenoneone open response per clientone open socket per client, plus routing

Scaling and the operations half

One instance can hold tens of thousands of idle connections; the problem is that the user who should receive a message is connected to a different instance than the one that produced it. The standard answer is not sticky sessions but a pub/sub broker: every instance subscribes to the channels its clients care about, and publishes events to the broker instead of to sockets directly.

POST /messagesany instance
write to DBsource of truth
PUBLISH room:42Redis / NATS / Kafka
each instancesubscribed to room:42
its socketsonly clients in the room
Fan-out across instances. The API that creates the event never touches a socket; it publishes once and every instance with a subscribed client forwards it.
nginx.conf (WebSocket + SSE behind a reverse proxy)
location /ws/ {
  proxy_pass http://app;
  proxy_http_version 1.1;              # Upgrade needs HTTP/1.1 to the upstream
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_read_timeout 3600s;            # default 60s would kill idle sockets
}

location /events {
  proxy_pass http://app;
  proxy_http_version 1.1;
  proxy_set_header Connection "";
  proxy_buffering off;                 # or honour X-Accel-Buffering from the app
  proxy_read_timeout 3600s;
}
  • Idle timeouts are everywhere. AWS ALB (Application Load Balancer) closes idle connections after 60 s by default, Nginx after 60 s, Cloudflare after 100 s. Send a heartbeat under the smallest of them (20-30 s is common) or the client sees random drops.
  • Reconnect with backoff and jitter. When a server restarts, every client loses its socket at once; if they all retry at t+1 s, the replacement gets the same load in a single second. Randomised exponential backoff spreads it.
  • Backpressure. A slow client behind a WebSocket makes the server buffer unsent frames in memory. Watch bufferedAmount (or the library's equivalent), drop or coalesce updates for laggards, and never let one phone on 3G hold a server's heap.
  • Graceful deploys. Send a close frame with a "reconnect" code before the process exits so clients move over instead of timing out.

Choosing

FeaturePickBecause
Notification badge, "3 people are viewing", build statusPolling (with ETag / 304) or SSELow rate, one direction. Polling every 30 s with conditional requests costs almost nothing and caches.
Live log tail, LLM token stream, progress bar, price tickerSSEServer → client, text, immediate. Free reconnection and resume. Works on serverless and through CDNs.
Chat, collaborative editing, multiplayer, live cursorsWebSocketBoth directions at high rate; the client sends as often as it receives. Binary framing helps for cursors and CRDT (conflict-free replicated data type) ops.
You control neither the proxies nor the hostingSSE or pollingPlain HTTP survives corporate proxies, mobile carriers and serverless limits that kill Upgrade.

Pitfalls

  • SSE over HTTP/1.1 and the six-connection limit

    Browsers cap HTTP/1.1 connections per host at six, shared across tabs. A user with several tabs of your app open, each holding an EventSource, silently blocks every other request to that host. Serve SSE over HTTP/2 (streams are multiplexed) or share one connection across tabs via a SharedWorker or BroadcastChannel.

  • A proxy that buffers the stream

    Nginx, some CDNs and compression middleware wait for the response to finish before forwarding it. For SSE that means nothing arrives, then everything arrives on timeout, and it looks like your event bus is broken. Disable buffering and compression for the stream route and send Cache-Control: no-transform.

  • Authenticating a WebSocket with a header

    The browser WebSocket API cannot set custom headers, so a Bearer token has nowhere to go. Rely on the session cookie the handshake carries (and check Origin, or any site can open a socket to you with the user's cookies), or issue a short-lived one-time ticket over HTTPS and send it as the first message.

  • No reconnect strategy, then a deploy

    WebSockets do not reconnect on their own. Without client logic, one restart leaves every user with a dead socket and a stale UI; with naive logic, they all reconnect in the same second and knock over the new instance. Exponential backoff with jitter, plus resync of missed state on reconnect, is part of the feature, not polish.

  • Polling on a fixed interval with no conditional requests

    Ten thousand clients on setInterval(…, 5000) is 2,000 requests per second that mostly return the same JSON. Use ETag so unchanged answers are 304s, back off when the tab is hidden, and add jitter so clients that loaded at the same moment do not poll in lockstep.

Interview questions

Q1Notification badge, chat, and a live stock ticker: which transport for each, and why?

Badge: polling every 30-60 seconds with ETag, or SSE if the rest of the app already has a stream; the rate is tiny and latency does not matter. Ticker: SSE, because it is server-to-client text at high rate and reconnection with resume comes for free. Chat: WebSocket if it is truly interactive at high rate, though SSE for receiving plus POST for sending is a legitimate design that keeps the server stateless and works on serverless.

Q2Walk me through the WebSocket handshake.

The client sends an HTTP GET with Upgrade: websocket, Connection: Upgrade and a random Sec-WebSocket-Key. A WebSocket-aware server replies 101 Switching Protocols with Sec-WebSocket-Accept, the SHA-1 of the key concatenated with a fixed GUID, which proves it understood the request rather than serving a cached page. After the 101 the same TCP connection carries framed messages in both directions, with ping/pong for liveness and a close frame to end it.

Q3How does SSE recover from a dropped connection?

The browser reconnects automatically after the retry delay and sends the last event id it received in a Last-Event-ID header. The server uses that to replay the events the client missed from a short buffer or the database, then resumes live streaming. That is why every event should carry an id and why the server needs some way to look up events since an id.

Q4How do you scale WebSockets across many server instances?

Keep sockets on whichever instance accepted them, but route events through a pub/sub broker such as Redis rather than instance to instance. Each instance subscribes to the channels its connected clients need and forwards published messages to its own sockets. That avoids sticky sessions, lets any instance publish, and makes the socket tier horizontally scalable; the broker becomes the thing to size.

Q5What happens when a load balancer idles out a WebSocket?

The balancer closes the TCP connection, often without a WebSocket close frame, so both sides may not notice until they try to send. The client sees a drop, must reconnect and resync any state it missed; the server discovers a dead socket on the next write or missed pong. Prevention is a heartbeat under the idle timeout, typically 20-30 seconds, and a raised timeout on the balancer.

Q6How do you authenticate a WebSocket connection from a browser?

Through the handshake, since the browser API cannot add headers. The simplest way is the existing session cookie, which the browser sends with the Upgrade request, combined with an Origin check to block cross-site sockets. If the API uses bearer tokens, issue a short-lived single-use ticket over an authenticated HTTP call and present it in the URL or first message; never put the long-lived token itself in the URL where it lands in logs.

Q7Long polling versus SSE: is there still a reason to long-poll?

Rarely. Long polling gives similar latency but pays a full request, headers and a new server handler per event, and needs client code for the loop. SSE is a single request with browser-native reconnection. The remaining case is an environment that cannot stream responses at all, such as an old proxy that buffers, or clients without EventSource, where long polling degrades gracefully.

Key takeaways
  • Choose by direction and rate: server → client text is SSE; two-way, high-rate or binary is WebSocket; low-rate status is polling with ETag.
  • SSE is plain HTTP: browser-native reconnection, Last-Event-ID resume, works on serverless and through CDNs once buffering is off. Serve it over HTTP/2.
  • A WebSocket is a protocol upgrade on one TCP connection; every proxy must support Upgrade, and reconnection, heartbeats and resync are your code.
  • Scale push with a pub/sub broker, not sticky sessions: publish once, every instance forwards to its own subscribed sockets.
  • Heartbeat under the smallest idle timeout in the path, back off with jitter on reconnect, and watch buffered bytes per slow client.
  • Escalate polling → SSE → WebSocket only when the requirement forces it. Most features stop at SSE; even chat can be SSE down and POST up.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free