Server-sent events, long-poll, and fan-out

Instead of clients nagging “anything new yet?”, the server holds the line open and pushes each event out to everyone at once.

The idea

Imagine a newsroom where every reader keeps phoning in to ask “any breaking news?” — most calls hear “nothing yet.” That’s short-polling: cheap to build, but wasteful, and news is always stale by up to one polling interval.

With long-polling the reader phones in and the newsroom simply doesn’t hang up until there’s actually something to report. With server-sent events (SSE) the reader opens one durable line and the newsroom keeps narrating events down it as they happen — one stream, many readers. Publishing once fans out to every connected subscriber.

See it work

Three subscribers hold an open stream. Publish an event to watch it fan out.

How it works

An SSE endpoint never finishes the response. It sets Content-Type: text/event-stream, then writes one message block at a time. Each block is a few text lines — an id:, optional event:, one or more data: lines — terminated by a blank line. The browser’s EventSource parses this stream and remembers the last id: it saw.

// SSE endpoint (server, Node-ish pseudocode)
res.writeHead(200, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-cache',
  'Connection': 'keep-alive',
});
res.write('retry: 3000\n\n');   // tell client how long to wait before reconnect

function send(id, payload) {
  res.write(`id: ${id}\n`);
  res.write(`event: notice\n`);
  res.write(`data: ${JSON.stringify(payload)}\n\n`);  // blank line ends the block
}

// heartbeat so proxies don't time the idle connection out
setInterval(() => res.write(': keepalive\n\n'), 15000);

When a client reconnects, the browser automatically sends the header Last-Event-ID. The server reads it and replays anything newer from a small buffer, so no events are lost across a blip.

// client (browser)
const es = new EventSource('/feed');
es.addEventListener('notice', (e) => {
  console.log('event', e.lastEventId, JSON.parse(e.data));
});
// on a dropped connection the browser reconnects on its own and sends:
//   Last-Event-ID: 4
// the server then replays event 5, 6, ... from its buffer.

Long-poll is the same idea without a streaming body: the handler blocks until an event arrives or a timeout fires, returns one response, and the client immediately re-requests.

// long-poll handler
async function handle(req, res) {
  const since = Number(req.query.since || 0);
  const event = await waitForEventAfter(since, { timeoutMs: 25000 });
  if (event) res.json({ id: event.id, data: event.data });
  else       res.status(204).end();   // nothing yet — client re-polls
}

Cost / trade-offs

ApproachRequestsLatencyDirectionServer cost
Short-pollOne every interval, mostly emptyUp to one intervalRequest / responseCheap per request, wasteful in bulk
Long-pollOne per event (plus timeouts)Near real-timeRequest / responseHolds a connection while waiting
SSEOne stream, stays openReal-time pushServer → client onlyOne open connection + buffer per subscriber
WebSocketOne upgraded socketReal-time pushBidirectionalOne open socket per subscriber, more infra

The hidden line item for long-poll, SSE, and WebSocket is the same: every subscriber is a connection the server must keep in memory. Ten thousand idle subscribers is ten thousand held connections — pick the model whose memory and proxy behaviour your fleet can actually carry.

Watch out for

Worked example

A live notifications feed powers three open dashboards. Each opens EventSource('/notifications'); the server tracks the trio as connected subscribers and keeps a buffer of the last 50 events keyed by id.

A new alert arrives and is published as event id: 5. The server writes that block once to each of the three streams — a single publish fans out to all three dashboards within milliseconds. Dashboard B’s laptop sleeps and its connection drops after event 4. On wake the browser reconnects automatically and sends Last-Event-ID: 4; the server reads the header, finds events 5 onward still in its buffer, and replays them so B’s badge count matches A and C. No alert is lost, and B never had to ask “what did I miss?”

Check yourself

1. A client’s SSE connection drops, then reconnects. How does it avoid missing events that were published during the gap?

2. Your dashboard needs the client to send messages back to the server too, not just receive. Is SSE the right fit?