Deep dive 4 · 20 min

Polling, SSE and WebSockets

HTTP is request and response, but users expect live updates. Each technique trades simplicity against latency and server cost.

Say it in 30 seconds
  • 20,000 req/s100K clients polling every 5 s
  • ~30 sWebSocket ping interval to catch dead peers
  • ~50K socketsper node; 1M needs about 20 to 29 nodes
Show what to say

“I pick by direction and frequency. Polling for a dashboard that can be seconds stale, SSE for one-way live updates like scores because it's plain HTTP and resumes with Last-Event-ID, and WebSockets when the client sends often too, like chat. Connections always drop, so I reconnect with jittered backoff and design for resume.”

Trade-offs

  • Short polling vs SSE: polling is trivial but delay averages half the interval, with many empty requests; SSE is real time on one open stream.
  • SSE vs WebSocket: SSE is plain HTTP with built-in reconnect but one-way and text only; WebSocket is two-way but stateful, with resume yours to write.
  • Pub/sub backplane vs sticky sessions: publishing to user:bob lets any node reach any user and reconnects land anywhere, but you run the backplane.

Bring it up before they ask

Reconnecting on a fixed delay with no jitter: after a deploy drain, 50K clients return in the same second and flatten the other nodes.

New to the words? Read the one-minute primer

0 of 0 sections ticked
30-second answer

Pick by direction and frequency: for slightly stale dashboards, for one-way live updates, when the client talks back often. Connections drop, so reconnect with .

Why it matters

HTTP waits to be asked. Live scores and chat need the server to speak first.

Read more · 44 words

It is like checking your mailbox every five minutes versus a doorbell. Each workaround trades simplicity against delay and server cost.

The diagram sends three events through each technique. Switch tabs: when does the client learn about event A, and after how many requests?

Press Play to watch it run · 9 steps

Three events happen on the server at t = 1, 3 and 6 seconds. Pick a technique to see how many requests it costs and when the client actually learns about each event.

Four ways to deliver the same three eventsThree events happen on the server at t = 1, 3 and 6 seconds. Pick a technique to see how many requests it costs and when the client actually learns about each event.alt [Short polling]Poll every 5 s. Events at t = 1, 3 and 6 s.GET /updates (t = 0)200 [] nothing newevent A (t = 1 s)event B (t = 3 s)GET /updates (t = 5 s)200 [A, B]: A is 4 s lateevent C (t = 6 s)GET /updates (t = 10 s)200 [C]: 4 s late3 requests, 1 of them empty. Average delay is about half the interval.
  • Client
  • Service
  • Pub/Sub
Text description
  1. Client → Server: GET /updates (t = 0)
  2. Server replies to Client: 200 [] nothing new
  3. Event bus sends (async) to Server: event A (t = 1 s)
  4. Event bus sends (async) to Server: event B (t = 3 s)
  5. Client → Server: GET /updates (t = 5 s)
  6. Server replies to Client: 200 [A, B]: A is 4 s late
  7. Event bus sends (async) to Server: event C (t = 6 s)
  8. Client → Server: GET /updates (t = 10 s)
  9. Server replies to Client: 200 [C]: 4 s late

Pick a technique

Start with plain . Move only when users notice the delay, or the request count hurts.

Read more · 312 words
TechniqueDirectionUse it whenAt 100K clients
Short pollingClient asksSeconds of delay are fine20,000 req/s at 5 s, 3,333 req/s at 30 s
Long pollingClient asks, server holdsNear real time, but streaming is blocked3,333 req/s idle (30 s timeout), one held request each
SSEServer to clientThe server talks, the client rarely does100K open streams: 2 nodes at 50K each, plus headroom
WebSocketBoth waysThe client sends often too100K sockets, plus a pub/sub backplane
WebhookServer to serverAn outside system tells youCosts nothing while idle
ProductPickWhy
Sales dashboardPoll every 30 sSeconds of delay are fine
Order or score alertsSSEServer talks, client listens
ChatWebSocketBoth sides send often
Live cursorsWebSocketMany tiny messages both ways

Four techniques race on one event stream. Raise the poll interval: short polling's delay climbs (about half the interval) as its requests fall.

is clients divided by the interval; the average wait is half the interval.

  • 1,000 dashboards every 30 s: 1,000 ÷ 30 is about 33 requests per second, with a 15 s average delay. Trivial, so keep polling.
  • 100K clients every 5 s: 100,000 ÷ 5 is 20,000 requests per second, with a 2.5 s average delay and mostly empty replies. Switch to .

The cost moves to memory: each open stream holds a socket, and one node holds roughly 50K. That is the planning number used on this page.

Which delivery technique?yesyesyesyesnonononoWebhookShort polling (thedefault)WebSocketServer-Sent EventsLong pollingCan your proxies andload balancer stream along response?Does the client sendmessages often, likechat or cursors?Is a delay of 10 to 30seconds fine?Is the receiver anotherserver, not a browser orapp?

Is the receiver another server, not a browser or app?

Interactive

Polling race

The same server events, delivered four ways over 60 simulated seconds (50 ms one-way latency). Amber bars are the wait before the client has an event.

Server eventsShort polling12 req, 7 emptyavg 1.90 sLong polling6 req, 1 emptyavg 50 msServer-Sent Events1 reqavg 50 msWebSocket1 reqavg 50 ms0 s10 s20 s30 s40 s50 s60 s
  • event on the server
  • waiting
  • client has it
  • request or connection (grey: empty poll)

5 events in 60 s. Short polling every 5 s: 12 requests (7 came back empty), average delay 1.90 s. Long polling: 6 requests, 50 ms. SSE and WebSocket: one connection each, 50 ms.

Results by technique
TechniqueHTTP requestsEmpty answersAvg delayWorst delay
Short polling (5 s)1271.90 s3.07 s
Long polling6150 ms50 ms
Server-Sent Events1 (stream)050 ms50 ms
WebSocket1 (upgrade)050 ms50 ms

Polling and SSE

Short is a timer and a fetch.

Read more · 510 words

Schedule the next poll after the reply (not with setInterval), pause hidden tabs, and answer unchanged data with 304 Not Modified. It is the default: no new infrastructure, works through every proxy, trivial to debug. The cost: most replies are empty, and the delay is up to a full interval. Start each client at a random offset, or 100,000 tabs scheduled on the clock minute all fire in the same second.

holds the request until there is news or about 30 s pass, then the client re-asks with a cursor (?since=41). Use it for near-real-time delivery when a proxy or legacy client blocks streaming. Idle clients still cost a request every 30 s: 100,000 ÷ 30 is about 3,300 , against 20,000 for 5 s short polling. Every event costs a full request and response, so a busy stream is expensive. Use an async server (Node, Go), not a thread per request: 100K held requests would mean 100K parked threads. Walk the four tabs: an event, a timeout, the gap bug and its fix.

  • The gap: subscribe after reading the backlog and an event in between is lost. Subscribe first, then read, then drop buffered events already in the backlog.
  • The tight loop: instant re-polls after an error flood a down server. Back off with .

is plain HTTP: one long response that streams events. The browser's EventSource parses it, reconnects by itself and sends the last id: it saw as Last-Event-ID, so the server replays what was missed.

Play Live stream, then Drop and resume.

Use it when the server talks and the client mostly listens: scores, order status, progress bars, notifications, live dashboards. It is the right default once polling hurts. One node holds about 50K streams, so 100K viewers need 2 nodes plus headroom. The cost: one-way only (the client sends ordinary requests), text only, and every stream holds a socket. Two limits:

  • Connections: browsers allow about 6 HTTP/1.1 connections per origin, and each SSE stream holds one, so a seventh tab hangs. shares one connection and allows many streams, so serve SSE over HTTP/2.
  • Proxies: some buffer the stream (play Buffering proxy on the diagram), and most close idle connections after about 60 s. Send X-Accel-Buffering: no (nginx) and a : ping every 15 s.

Press Play to watch it run · 4 steps

The client asks with a cursor and the server holds the request until there is news or the timeout passes. The last two tabs show the subscribe-then-read gap bug and its fix.

Long polling: wait for newsThe client asks with a cursor and the server holds the request until there is news or the timeout passes. The last two tabs show the subscribe-then-read gap bug and its fix.alt [Event arrives]GET /events?since=41held open, up to 30 sevent 42 (4 s later)200 [42] at onceGET /events?since=42The client re-asks immediately, with the cursor of the last event it has.
  • Client
  • Service
  • Pub/Sub
Text description
  1. Client → Server: GET /events?since=41
  2. Event bus sends (async) to Server: event 42 (4 s later)
  3. Server replies to Client: 200 [42] at once
  4. Client → Server: GET /events?since=42
id: 42
event: score
data: {"home":2,"away":1}

: ping

Press Play to watch it run · 5 steps

The browser opens one long response. Events flow down it. After a drop, EventSource reconnects with Last-Event-ID and the server replays from an event log. A buffering proxy is the classic way to break it.

One SSE stream through a proxyThe browser opens one long response. Events flow down it. After a drop, EventSource reconnects with Last-Event-ID and the server replays from an event log. A buffering proxy is the classic way to break it.BrowserEventSourceProxynginx / LBSSE node~50K streamsEvent logids 1..N

Tap any box to see what it is and what it talks to.

  • Client
  • Load balancer
  • Service
  • Queue
Open in playground
Text description
  1. EventSource opens GET /stream. It is one ordinary HTTP request that never finishes.
  2. The proxy forwards it. The node answers 200 with Content-Type text/event-stream and keeps the response open.
  3. Event 41 is written as id: 41 and data: ... and flushed at once.
  4. The browser fires a message event. No new request, no new headers.
  5. Every 15 s a : ping comment keeps idle timeouts from closing the stream.

WebSockets and the connection tier

A starts as an HTTP request, then upgrades the same connection into a two-way channel of small messages.

Read more · 808 words

Use it when the client sends often too (chat, cursors, multiplayer) or messages are tiny and frequent. If only the server talks, is simpler. The price is state: every socket is pinned to one node, you write , reconnect and resume yourself, and scaling needs a backplane. Plan for all four before you pick it.

Picture a chat app on a train. The tunnel kills the connection silently, and without a heartbeat the server keeps writing to nobody. Messages with ids let the client resume where it stopped. Play Connect and talk, then Drop and resume.

Authenticating. Browsers can't set an Authorization header on a WebSocket, so authenticate the connect:

  • A session cookie rides the upgrade. Check Origin too.
  • Or a short-lived token in the first message, never in the URL, which lands in logs.
  • Sockets outlive tokens, so re-authenticate before expiry.

Scaling. A connection tier is a fleet of servers whose only job is to hold sockets, so size it by connections and route messages with pub/sub. The same tier serves SSE and WebSockets.

  • Pub/sub backplane. Alice is on node 1, Bob on node 2. Node 1 publishes to user:bob and Bob's node delivers. Any node reaches any user.
  • Heartbeats. Dead peers are silent. Ping every ~30 s and close sockets that miss a pong.
  • Drain on deploy. Let clients reconnect elsewhere with full , or 50K reconnects in one second flatten the other nodes.

Sizing the fleet. Divide sockets by what one node holds, then leave room for a node dying. With a planning figure of 50K sockets per node:

  • 1M sockets ÷ 50K is 20 nodes, but at 100% load a lost node pushes its 50K onto 19 others, which then hold 52.6K each.
  • Plan for about 70% load: 1M ÷ 35K is about 29 nodes, so one failure adds under 2K sockets per survivor.

Why not push one node harder? File descriptors, memory per socket, and the roughly 64K source ports one address can use toward one backend address and port all cap you.

through pub/sub. Publish a message once; each node subscribes to the channels its sockets care about (user:bob, room:7), and hands one copy to each subscribed node. For a room of 10,000 split over two nodes, one publish becomes 2 deliveries and 10,000 local writes, not 10,000 lookups. Use one channel per user for direct messages and one per room for groups. Play Room message, then Node misses it.

Redis pub/sub is at most once. A node that is disconnected for 4 s misses everything published in that window, so keep a log with ids and let clients resume (see What goes wrong). In Redis Cluster, plain pub/sub is broadcast to every node; pub/sub (Redis 7) keeps a channel on one .

. A WebSocket is one connection, so it stays on its node without sticky routing. Stickiness only matters for HTTP long-polling fallbacks (Socket.IO's transport needs it until the upgrade) and for state kept in node memory. Pin by cookie or IP hash only as a stopgap. The better design keeps rooms and sessions in Redis, so any node takes a reconnect and a deploy drains cleanly.

Press Play to watch it run · 8 steps

The upgrade is authenticated once, frames flow both ways, heartbeats catch a silent drop, and the client resumes from its last message id after reconnecting.

A WebSocket from connect to resumeThe upgrade is authenticated once, frames flow both ways, heartbeats catch a silent drop, and the client resumes from its last message id after reconnecting.alt [Connect and talk]GET /chat · Upgrade: websocketcheck cookie and Origin101 Switching ProtocolsSame TCP connection, now two-way framesframe: join room 7frame: msg 88frame: my messageping (every ~30 s)pong
  • Client
  • API gateway
  • Database
Text description
  1. Client → WS gateway: GET /chat · Upgrade: websocket
  2. WS gateway: check cookie and Origin
  3. WS gateway replies to Client: 101 Switching Protocols
  4. Client sends (async) to WS gateway: frame: join room 7
  5. WS gateway sends (async) to Client: frame: msg 88
  6. Client sends (async) to WS gateway: frame: my message
  7. WS gateway sends (async) to Client: ping (every ~30 s)
  8. Client sends (async) to WS gateway: pong

Press Play to watch it run · 4 steps

One message is published once. Redis delivers one copy per gateway node that has subscribers, and each node writes to its own local sockets. Redis pub/sub does not store messages for a node that is offline.

Fan-out to a room of 10,000One message is published once. Redis delivers one copy per gateway node that has subscribers, and each node writes to its own local sockets. Redis pub/sub does not store messages for a node that is offline.publishdeliverdeliverSenderWS node 1Redis pub/subroom:7WS node 2~5K membersWS node 3~5K members10K members

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Pub/Sub
Open in playground
Text description
  1. Alice posts to room 7. Its 10,000 members hold sockets on nodes 2 and 3.
  2. Node 1 publishes once to the channel room:7. It does not look up 10,000 sockets.
  3. Redis delivers one copy to each node subscribed to room:7. That is 2 deliveries, not 10,000.
  4. Each node loops over its own local sockets and writes about 5,000 frames. Total socket writes: 10,000, spread across the fleet.

Quick check1M sockets, 50K per node, 70% target load: how many nodes?

Webhooks

Stripe and GitHub POST to your endpoint when something happens, sometimes twice, and retry until you answer 2xx.

Read more · 207 words

Use it when the receiver is a server and the event happens outside your system: a payment settles, a pull request opens. It costs nothing while idle, unlike the provider every minute. Try all three branches.

  • Check the signature. Recompute the HMAC over the raw body and compare in constant time.
  • Reject old timestamps. Anything older than about 5 minutes is a replay.
  • Acknowledge fast. Enqueue, then return 200 quickly.
  • Process idempotently. A unique constraint on the event id makes a duplicate a no-op.

A can be late or never arrive, so treat it as a hint and keep a way to ask. Stripe retries for up to three days with growing delays; GitHub does not redeliver failed deliveries on its own. Play Endpoint down, retried, then Retries run out.

Run an hourly reconcile job that lists recent events from the provider API and enqueues any you lack. The unique event id keeps it safe to run twice.

Press Play to watch it run · 7 steps

The provider calls your endpoint. You verify the signature, enqueue the event, acknowledge within a second, and process it idempotently, because the provider will retry.

Receiving a webhook reliablyThe provider calls your endpoint. You verify the signature, enqueue the event, acknowledge within a second, and process it idempotently, because the provider will retry.POST /webhook (evt_42)verify HMAC + timestampalt [Valid]enqueue evt_42200 OK in ~50 msdeliver evt_42INSERT processed(evt_42)new: do the work
  • External API
  • API gateway
  • Queue
  • Worker
  • Database
Text description
  1. Provider → Webhook API: POST /webhook (evt_42)
  2. Webhook API: verify HMAC + timestamp
  3. Webhook API sends (async) to Queue: enqueue evt_42
  4. Webhook API replies to Provider: 200 OK in ~50 ms
  5. Queue sends (async) to Worker: deliver evt_42
  6. Worker → Database: INSERT processed(evt_42)
  7. Database replies to Worker: new: do the work

Press Play to watch it run · 5 steps

The provider retries a failed delivery on a schedule. If it gives up, or never retries, a reconcile job polls the provider API and fills the gap.

When a webhook is late or lostThe provider retries a failed delivery on a schedule. If it gives up, or never retries, a reconcile job polls the provider API and fills the gap.POSTenqueuelistenqueueProviderYour endpointQueueReconcile jobhourly

Tap any box to see what it is and what it talks to.

  • External API
  • API gateway
  • Queue
  • Worker
Open in playground
Text description
  1. The provider POSTs evt_42 during your deploy.
  2. 503: the endpoint is down. The provider marks the delivery failed.
  3. It retries with growing delays: seconds, then minutes, then hours. Stripe keeps trying for up to three days.
  4. The endpoint is back. It verifies the signature and enqueues evt_42.
  5. 200 OK in about 50 ms. The provider stops retrying.

What goes wrong

Real-time systems fail at the edges: when many clients drop together, when a middlebox interferes, and when a gap opens between two events.

Read more · 543 words
FailureSceneFix
Reconnect stormA node dies and 50K clients return in one secondFull jitter, wider first window
Proxy bufferingThe SSE feed freezes behind nginxDisable buffering, ping every 15 s
Lost eventsA 6 s drop skips two messagesIds, a log, resume by last id
Slow consumerOne phone on a bad networkCap its queue, then drop or close

A reconnect storm is every dropped client coming back at the same moment. Friday 20:00, kickoff: one node holding 50,000 sockets crashes. Play No , then Full jitter. Without jitter, 50,000 clients retry after 1 s: 50,000 ÷ 19 survivors, about 2,600 handshakes each in one second. Assume a node accepts about 1,000 per second and it falls over, then the timeouts retry in sync and the storm repeats. With full jitter (a random wait between 0 and the cap) over 30 s, the same 50,000 arrive at about 1,700 per second, around 90 per survivor. Attempt 0 of backoffDelay only spreads over its base (1 s), so for a mass drop make the first window wide, or have the server send a hint (retry: in , a close reason in ).

A buffering proxy holds the stream until its buffer fills, so the feed looks frozen, and an idle timeout (often 60 s) then closes it. The fix is three lines of config: X-Accel-Buffering: no on the response, buffering off on the route, and a : ping or WebSocket ping every 15 to 30 s, well under the shortest idle timeout on the path. Check every hop: , , ingress, then app.

An event is lost when it happens while the socket is down and nothing records it. Play No ids: events lost, then Ids and replay. Give every message a monotonically increasing id, append it to a log before you publish it, and have the client send its last id on reconnect. Delivery becomes at least once, so clients by id. SSE does the id and header part for you; with WebSockets you write it.

A slow is a client that cannot drain its socket, such as a phone on a weak network. The server's send buffer for it grows until memory is gone. Cap the queue per socket, then drop old messages or close the socket and let the client resume by id.

Press Play to watch it run · 4 steps

One gateway node holding 50,000 sockets dies. If every client retries after the same delay, the 19 surviving nodes take 50,000 handshakes in one second. Spreading the retries over 30 s makes it about 1,700 per second.

Reconnect storm: with and without jitterOne gateway node holding 50,000 sockets dies. If every client retries after the same delay, the 19 surviving nodes take 50,000 handshakes in one second. Spreading the retries over 30 s makes it about 1,700 per second.50K clientsLoad balancerWS node 150K socketsNodes 2 to 2019 nodes

Tap any box to see what it is and what it talks to.

  • Client
  • Load balancer
  • Service
Open in playground
Text description
  1. Friday 20:00, kickoff. Node 1 crashes and drops all 50,000 of its sockets at the same instant.
  2. Every client waits exactly 1 s, then reconnects. All 50,000 arrive in the same second.
  3. 50,000 handshakes over 19 nodes is about 2,600 each in one second. If a node accepts roughly 1,000 new connections per second (an assumption), it falls behind.
  4. Handshakes time out, so clients retry together again. The storm repeats and can take down the nodes that were healthy.

Press Play to watch it run · 5 steps

A socket drops for six seconds. Without message ids the client never learns what it missed. With an id on every message and a log, it asks for everything after the last id.

Lost events and the fixA socket drops for six seconds. Without message ids the client never learns what it missed. With an id on every message and a log, it asks for everything after the last id.alt [No ids: events lost]msg: helloSocket down for 6 smsg: A (nobody home)msg: B (nobody home)reconnectmsg: CThe client shows hello then C. Nothing tells it A and B exist.
  • Client
  • Service
  • Queue
Text description
  1. Server sends (async) to Client: msg: hello
  2. Server sends (async) to Client: msg: A (nobody home)
  3. Server sends (async) to Client: msg: B (nobody home)
  4. Client → Server: reconnect
  5. Server sends (async) to Client: msg: C

Quick check50K clients retry after exactly 1 s. What fixes the storm?

Code

Short polling

short-polling.js
// Short polling that pauses while the tab is hidden (the source polled forever)
export function startPolling(fn, ms = 5_000) {
  let timer, stopped = false;
  const tick = async () => {
    if (!document.hidden) await fn().catch(() => {});   // one failed poll shouldn't end polling
    if (!stopped) timer = setTimeout(tick, ms);          // schedule after the reply, so polls never overlap
  };
  tick();
  return () => { stopped = true; clearTimeout(timer); };   // also stops a poll that is still in flight
}

Long polling: client

long-poll-client.js
import { backoffDelay } from './backoff.js';

// Long polling client. The source re-polled instantly on errors, so a down server caused a tight loop.
export async function longPoll(onEvents, signal) {
  let since = 0, failures = 0;
  while (!signal.aborted) {
    try {
      const r = await fetch(`/events?since=${since}`, { signal: AbortSignal.any([signal, AbortSignal.timeout(35_000)]) });
      if (r.status === 204) continue;                         // the server waited 30 s with no news: ask again
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      const { events, cursor } = await r.json();
      since = cursor; failures = 0;
      onEvents(events);
    } catch {
      if (signal.aborted) return;
      failures++;
      await new Promise(res => setTimeout(res, backoffDelay(failures)));   // 0–2 s, 0–4 s, … capped at 30 s
    }
  }
}

Long polling: server

long-poll-server.js
// Long polling server: subscribe first, then read the backlog, so no event slips through the gap.
app.get('/events', async (req, res) => {
  const since = Number(req.query.since ?? 0);
  let done = false, early = [];                               // live events that arrive while the backlog loads
  const finish = events => {
    if (done) return;
    done = true;
    clearTimeout(timer);
    unsubscribe();
    if (!events) return;                                      // the client went away: just clean up
    if (!events.length) return res.sendStatus(204);           // no news in 30 s: the client asks again
    res.json({ events, cursor: events.at(-1).id });
  };
  const unsubscribe = bus.subscribe(ev => (early ? early.push(ev) : finish([ev])));   // 1. subscribe…
  const timer = setTimeout(() => finish([]), 30_000);
  res.on('close', () => finish());                            // res, not req: req 'close' fires once the request is read

  const backlog = await eventsAfter(since);                   // 2. …then read what was missed
  const last = backlog.at(-1)?.id ?? since;
  const events = [...backlog, ...early.filter(ev => ev.id > last)];   // an event in both lists is sent once
  early = null;                                               // from now on, answer with the next live event
  if (events.length) finish(events);
});

Server-Sent Events

sse.js
// SSE server with resume (Last-Event-ID) and heartbeats (the source had neither)
app.get('/stream', async (req, res) => {
  res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });  // nginx: don't buffer
  res.flushHeaders();
  res.write(`retry: ${2_000 + Math.floor(Math.random() * 3_000)}\n\n`);   // reconnect delay, jittered per client

  let lastId = Number(req.get('Last-Event-ID') ?? 0);         // EventSource sends it on every reconnect
  const send = ev => {
    if (ev.id <= lastId) return;                              // already sent: replay and live can overlap
    lastId = ev.id;
    res.write(`id: ${ev.id}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
  };
  let pending = [];                                           // live events that arrive during the replay
  const unsubscribe = bus.subscribe(ev => (pending ? pending.push(ev) : send(ev)));   // subscribe first…
  const ping = setInterval(() => res.write(': ping\n\n'), 15_000);   // comment lines keep idle proxies open
  res.on('close', () => { clearInterval(ping); unsubscribe(); });

  (await eventsAfter(lastId)).forEach(send);                  // …replay what was missed while disconnected…
  pending.forEach(send);                                      // …then what arrived meanwhile
  pending = null;                                             // from now on, go live
});

// Browser: EventSource reconnects by itself and sends Last-Event-ID automatically
const es = new EventSource('/stream');
es.addEventListener('score', e => renderScore(JSON.parse(e.data)));

WebSocket heartbeat

ws-heartbeat.js
// Server heartbeat: ping every 30 s and drop peers that didn't answer the previous ping
wss.on('connection', ws => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
  ws.on('message', m => broadcast(m));
});

setInterval(() => {
  wss.clients.forEach(c => {
    if (!c.isAlive) return c.terminate();          // no pong in 30 s: laptop asleep, NAT entry gone, cable pulled
    c.isAlive = false;
    c.ping();
  });
}, 30_000);                                        // shorter than proxy/LB idle timeouts (often 60 s)

WebSocket client with reconnect

import { backoffDelay } from './backoff.js';

// WebSocket client with reconnect + full jitter, so 100K clients don't all reconnect in the same second after a deploy
export function connect(url, onMessage) {
  let ws, timer, attempt = 0, closed = false;
  const open = () => {
    ws = new WebSocket(url);
    ws.onopen = () => { attempt = 0; };
    ws.onmessage = e => onMessage(JSON.parse(e.data));
    ws.onclose = () => {
      if (closed) return;
      timer = setTimeout(open, backoffDelay(attempt++, { baseMs: 500 }));   // 0–0.5 s, 0–1 s, 0–2 s, … up to 30 s
    };
  };
  open();
  return {
    send: data => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify(data)),   // false while reconnecting
    close: () => { closed = true; clearTimeout(timer); ws.close(); },                 // also cancels a pending reconnect
  };
}

Scaling Socket.IO across nodes

socketio-redis.js
// Scaling Socket.IO across nodes: the Redis adapter relays room broadcasts between servers
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pub = createClient({ url: process.env.REDIS_URL }), sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
const io = new Server(httpServer, { adapter: createAdapter(pub, sub) });

io.use(async (socket, next) => {                                   // authenticate before accepting the socket
  try {
    socket.data.userId = await verifyToken(socket.handshake.auth.token);
    next();
  } catch {
    next(new Error('unauthorized'));
  }
});

io.on('connection', socket => {
  socket.join(`user:${socket.data.userId}`);                       // one room per user: all their devices join it
  socket.on('chat', msg => io.to(`user:${msg.to}`).emit('chat', msg));   // reaches the user on any node
});

Verifying webhooks

Read more · 40 words
// Webhooks: verify the HMAC over the raw body, acknowledge fast, process later
import { verifyWebhook } from './webhook-verify.js';

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {   // raw: JSON parsing would change the bytes
  const ok = verifyWebhook({
    rawBody: req.body,
    timestamp: req.get('x-timestamp'),
    signature: req.get('x-signature'),
    secret: process.env.WEBHOOK_SECRET,
  });
  if (!ok) return res.sendStatus(401);
  await queue.publish('webhook', JSON.parse(req.body));       // durable before we ack; the consumer dedupes by event id
  res.sendStatus(200);                                        // if publish throws, the provider gets a 5xx and retries
});

Real-time delivery in the interview

Bring this up the moment a requirement says live, instant, or notify, and say the cheapest option that meets the delay.

Read more · 564 words

Walk through five decisions in order.

  1. Ask how fresh and in which direction

    "Is 10 to 30 seconds fine? Does the client talk back?" The answers pick the technique, so ask them first.

  2. Default to polling, upgrade with a number

    Poll when delay is fine. Switch when the arithmetic says so: 100K clients at 5 s is 20,000 .

  3. Pick SSE or WebSocket by direction

    for server-only push with free resume. when the client sends often. when the receiver is a server.

  4. Scale the connection tier

    About 50K sockets per node, sized at 70% load, with routing and no . 1M sockets is about 29 nodes.

  5. Own the failure modes

    Reconnect storms, lost events and proxy buffering. Say full , message ids with a log, and disabled buffering with pings.

If the interviewer saysYou raise
"Live sports scores for millions"SSE, fan-out through pub/sub, resume with Last-Event-ID
"Chat or collaborative editing"WebSockets with ids, a pub/sub backplane and a log
"A dashboard that updates"Poll every 30 s, add a 304
"A payment provider tells us"A webhook with signature check, queue and reconcile job
"What if a server restarts?"Drain, jittered reconnect, resume by last id

Quick checkWhich technique do you default to when 30 s of delay is fine?

Interview questions

Live sports scores: which technique?

: one-way, plain HTTP, and it resumes with Last-Event-ID. through .

Why does SSE hang behind some proxies?

The proxy buffers the stream. Disable buffering and send : ping .

How do you serve 1M concurrent WebSockets?

At ~50K sockets per node, about 20 nodes plus headroom. Route over pub/sub, and drain on deploy.

SSE or WebSocket for a chat app?

Usually , since messages flow both ways. Give messages ids so clients can resume.

How do you avoid losing messages when a socket drops?

Give every message an id and append it to a log first. The client sends its last id on reconnect and the server replays the gap. by id.

Do WebSockets need sticky sessions?

Not once connected: the socket is one connection. Only long-poll fallbacks and in-memory state need stickiness. Keep state in instead.

How do you notify a mobile user whose app is in the background?

Use push (APNs or FCM), since the OS kills background sockets. On open, reconnect and sync.

Check yourself

Question 1 of 5

Score 2 out of 3 or better to mark this page complete.

1. You need one-way server-to-browser updates with automatic resume after a dropped connection. Which technique?

Next: Rule of thumb: dashboards poll, notifications use SSE, chat uses WebSockets, provider events use webhooks.