Get E-Book
Realtime & Streaming APIs

Server-Sent Events and Long Polling

Ishtmeet Singh @ishtms/June 10, 2026/48 min read
#nodejs#realtime#sse#long-polling#http

Most HTTP handlers call res.end() the moment they have a body to send. Skip that call, keep writing, and the same res.write() you reach for every day becomes a realtime feed.

js
import http from 'node:http';

http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
  });
  res.write('data: ready\n\n');
}).listen(3000);

That handler sends one Server-Sent Events response. Server-Sent Events, usually shortened to SSE, is an HTTP response format for pushing updates from a server down to a client. The client makes an ordinary HTTP request, the server replies with a text/event-stream content type, and from there it keeps the response body open and writes event blocks as plain text for as long as the connection survives. The thing carrying your realtime data is that one open response, nothing more.

Long polling reuses those same request and response objects but changes how long they stay alive. The client sends a request asking for anything new since last time. When the server already has updates ready, it replies straight away and the exchange ends like a normal request. When the server has nothing yet, it holds the request open for a bounded stretch of time, and a request held open like that is what I'll call a hanging request. As soon as an update appears, the server writes it, ends the response, and the client immediately sends its next poll.

WebSocket, SSE, and long polling all get realtime updates to the client. They differ in how the server keeps that going.

text
WebSocket:
  HTTP upgrade -> framed bidirectional connection

SSE:
  HTTP request -> one long response stream

Long polling:
  HTTP request -> held response -> repeat

Reach for SSE when the server does most of the talking, pushing updates down to a browser while the browser sends its own commands back through normal HTTP routes. Long polling makes sense when something in your stack, the clients, the infrastructure, or an operations rule, would rather deal with short request and response exchanges than a connection held open for an hour. WebSocket is the full-duplex option from the previous subchapter, for when both sides need to talk at any time.

Almost everything in this chapter comes back to one practical split. SSE keeps a single response alive and pushes many events down it. Long polling sends many separate responses, and each one opens with a bounded wait.

Running realtime over plain HTTP leaves your normal request handling in place. The request still arrives with a method, a URL, headers, and cookies, still runs through route matching and auth checks, still sits inside whatever lifecycle your framework wraps around handlers. The response is the same object you always send back, with a status, headers, body bytes, timeout behavior, and a close event. The realtime behavior begins at one exact moment, when your handler holds the response open past the point it would normally end.

Inside Node, both transports travel across the same objects from the HTTP chapters. An SSE route writes several body chunks into one ServerResponse. A long-poll route writes at most one body into a ServerResponse, though it might hold that body back for many seconds first. While the client waits, your process keeps real resources open, file descriptors, timers, heap objects, and socket buffers.

Traffic in the other direction, client to server, stays ordinary HTTP in both designs. A user clicks a button and the browser sends POST /actions. Later, when the server has something to report, it arrives over the SSE stream or on the next poll response. Splitting it this way lets your normal API routes keep handling validation, idempotency, and logging the way they already do, which leaves the realtime endpoint narrow enough to do one thing, push updates out.

Failures show up differently in each. When SSE breaks, what broke is a single long response, so the client recovers by letting EventSource reconnect and send the Last-Event-ID header back. When long polling breaks, what broke is one request that errored or timed out, so the client recovers on its next poll, carrying the previous cursor forward. The data you resend can be identical, but the retry happens in a different place.

Your infrastructure notices the difference too. An SSE response can stay open for hours when every layer between client and server allows it. A long-poll cycle finishes over and over, so every access log, request counter, rate limiter, and gateway quota records far more requests. That accounting pushes real decisions. A small internal dashboard often picks SSE to avoid the churn, and a locked-down enterprise proxy often handles long polling better because every response completing is exactly what it expects.

Opening An SSE Response

Every SSE endpoint begins life as a plain Node HTTP handler. Node builds an IncomingMessage and a ServerResponse through the route mechanics from the HTTP lifecycle chapter, the same as any route. Your handler sets the status, sets the headers, commits them, and then holds back the one call that would finish things, res.end().

js
res.statusCode = 200;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.flushHeaders();

text/event-stream is the media type that tells an EventSource client to read the body using the SSE rules. Cache-Control: no-cache makes caches revalidate rather than serve the response as something they stored. no-transform asks any intermediary to leave the byte stream alone and not rewrite the body. A few deployments also want proxy-specific buffering controls, but those live in proxy configuration and have nothing to do with the SSE format itself.

flushHeaders() actually earns its keep in Node, because a ServerResponse will sit on its headers until body data shows up. Flushing sends the client the status and media type before the first event lands. Writing an event right away would commit the headers too, through the write path, but calling flush yourself makes the connection state visible the instant the handler runs.

Committing the headers also decides how you can report errors from here on. Before flushHeaders() runs, or before the first body write, the route can still reject the request with 401, 403, 404, or any normal status code. Once the headers commit, the response is already an event stream, so any error after that point has to take the form of an SSE event, a closed connection, or a log line on the server. You can no longer send a clean 500, because that status went out the door with the headers.

So validation has to move earlier than people expect. You authenticate the request, authorize it, check the query parameters, and work out which channel or topic this client is allowed to read, and only after all of that do you open the stream. A handler that flushes first and checks auth afterward has already told the browser the stream exists.

The method is usually GET, since EventSource opens a GET request and nothing else. Query parameters tend to carry subscription filters, cursor hints, or a short-lived stream token. The body comes in empty. What you actually control here is how long the response stays open.

You will see example code that sets Connection: keep-alive. Node already manages connection metadata from the HTTP response state, and hop-by-hop headers apply to the immediate peer anyway. The piece of SSE that genuinely persists is the open response body carrying text/event-stream. The connection header is only hop metadata, and once a reverse proxy sits in front of you, that proxy manages its own downstream connection metadata regardless.

The endpoint skips Content-Length and writes a series of UTF-8 text chunks instead. A single chunk might hold one event block, several event blocks, a comment heartbeat, or just part of a larger block. The EventSource parser works line by line, so where one event ends and the next begins comes from the text format, not from TCP packets or from how you split your res.write() calls.

Your handler also needs a cleanup path. The client can close the tab, a proxy can drop the connection, a mobile network can disappear, and Node surfaces every one of those teardowns through the close events on the request and response.

js
const timer = setInterval(() => {
  res.write(': ping\n\n');
}, 15_000);

res.on('close', () => clearInterval(timer));

That : ping line is an SSE comment heartbeat. The browser throws it away instead of firing an event, but the bytes still travel down the HTTP response, and that traffic is the point. Heartbeats stop idle timers from deciding the connection has gone silent. Set the interval from timeouts you have actually measured in your deployment. A proxy with a 60 second idle timeout needs traffic comfortably inside that window.

The response is still a Writable stream. res.write() hands you the same backpressure signal from the streams chapter, where a false return means Node queued the data in user memory because the lower write path only accepted part of it. Realtime send queues and drop policy come up in Subchapter 3, so the rule to hold onto right now is small. An SSE endpoint is a writable stream, and each connected client has its own writable state to track.

A first endpoint that works can be this small.

js
http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/event-stream' });
  const id = Date.now();
  res.write(`id: ${id}\n`);
  res.write('event: server.ready\n');
  res.write('data: {"ok":true}\n\n');
}).listen(3000);

The response stays open because the handler writes, then returns, while res.end() is still pending. Node holds the socket, the response object, and the outgoing message state alive after the JavaScript call stack has cleared.

A response can stay fully alive while none of your JavaScript runs against it, and that is the state people forget exists.

The Event Stream Format

The body of an SSE response is line oriented, built out of event blocks, where one event block is just the group of lines that turns into a single browser event. A blank line is what ends each block.

text
id: 42
event: order.updated
data: {"id":"ord_7","status":"paid"}
One SSE event block with the id, event, and data fields stacked above the blank line that triggers dispatch, and a comment line shown as a heartbeat.
One event block carries the id, event, and data fields, and the trailing blank line is what makes the browser fire the event. A line that starts with a colon is a comment the parser ignores, which servers send as heartbeat bytes.

That blank line is part of the protocol, not a formatting nicety. The browser dispatches the event the instant it receives the empty line. Write data: hello\n and then pause, and you have sent only half an event block, so the browser holds the data in a buffer and the message event waits until the block terminator finally arrives.

Each line inside a block is a field. Everything before the first colon is the field name, everything after it is the value, and one leading space gets stripped from the value when it is there. Field names are case-sensitive, and the client quietly ignores any field name it does not recognize.

There are only a few fields you use day to day.

text
event: name used as the browser event type
data: text appended to the event data buffer
id: value stored as the last event ID
retry: reconnection delay hint in milliseconds

The data: field carries the payload. When one block has several data: lines, the browser joins them with newline characters before it dispatches the event.

text
data: line one
data: line two

The browser hands your code event.data as line one\nline two. For JSON payloads, most servers write a single data: line holding one JSON string. For plain text that might contain line breaks, you write one data: line per line of text.

js
for (const line of text.split('\n')) {
  res.write(`data: ${line}\n`);
}
res.write('\n');

That loop produces one event block from a string payload. The final blank line closes the block, and if the server forgets it, the client is left holding an incomplete event.

The event: field sets the browser event type. Leave event: out and EventSource dispatches a plain message event. Send event: order.updated and your browser code can listen for that named event directly.

js
const source = new EventSource('/events');

source.addEventListener('order.updated', event => {
  console.log(JSON.parse(event.data));
});

EventSource is the browser API object that opens the SSE request, parses the text/event-stream body, fires message events, and reconnects after a recoverable connection loss. Its constructor takes a URL and nothing else. From that point on, the browser runs the HTTP client side for you.

The id: field sets the event ID that the EventSource object stores. To the server, that value is an opaque string. Plenty of servers use a steadily increasing number because it makes replay easy, but the protocol itself treats it as text. On the next reconnect, the browser sends it straight back in the Last-Event-ID request header.

The retry: field sets the SSE retry delay, a hint the server passes to the client inside the stream. A valid value is all digits, read as milliseconds, and it sets how long EventSource waits before reconnecting on this connection. The fuller story of reconnect policy, jitter, token refresh, and presence recovery waits for Subchapter 4.

Comments begin with a colon.

text
: still here

The EventSource parser ignores comment lines completely. Servers lean on them as heartbeat bytes because they push through the same HTTP response path that real events use, while leaving the browser's application event handlers untouched.

Here is a small event writer that sends the usual fields.

js
function sendEvent(res, event) {
  res.write(`id: ${event.id}\n`);
  res.write(`event: ${event.type}\n`);
  res.write(`data: ${JSON.stringify(event.data)}\n\n`);
}

That helper leans on JSON.stringify() to produce one JSON text value. Validating the payload and naming the event are still your application's job. SSE only defines the text framing and how EventSource delivers it.

One res.write() call can send the whole block, or several calls can send it piece by piece. EventSource only sees the bytes once the HTTP layer has delivered them, and the only things it reads are the lines and the blank line at the end.

js
const block = [
  `id: ${event.id}`,
  `event: ${event.type}`,
  `data: ${JSON.stringify(event.data)}`,
  '',
].join('\n');
res.write(block + '\n');

That version assembles the whole block first and writes it in one call. The array carries one empty string at the end, so the joined result finishes with a blank event terminator after the final \n. Either helper style is fine, because what the browser parses is the bytes on the wire and nothing else.

The parser has a few corners you should know about. An id line with an empty value clears the stored last event ID, while a data line with an empty value adds an empty string to the data. A block that holds only a comment turns into a parser step that does nothing. A stream that ends mid-block throws that pending block away, because no blank line ever completed it.

Inside, the client parser keeps three things, a data buffer and an event type buffer for the current block, plus a last-event-ID buffer that lives at the stream level. A data: field appends its value and a line feed to the data buffer, an event: field overwrites the event type buffer, and an id: field overwrites the stream-level last-event-ID buffer whenever the value is valid. A blank line then dispatches from those buffers, trims the trailing line feed off the data buffer, copies the last-event-ID buffer into the stored ID on the EventSource object, and clears the data and event type buffers for the next block.

That state machine explains a handful of results that look strange at first. Two data: lines become one event with a newline sitting inside event.data. A comment heartbeat leaves your application data alone, since all it produces is an ignored line. A block with id: 7 and an empty data buffer can move the stored ID forward while never delivering a message at all. A retry: field changes the reconnect delay, and then the parser goes right on reading later blocks as usual.

Your write helpers should keep these parser rules in plain sight. When you bury SSE inside a generic send-JSON function, it gets easy to drop the blank line, swap event: and data:, or leave id: off an event you meant to make replayable. A helper can be small and still write each protocol field on purpose.

The SSE grammar really is this small. The behavior that actually challenges you in production comes from how long the response stays alive and how carefully the server hands out IDs.

What Node Keeps Alive

Your handler function returns while the connection it opened is still running. That gap, between your code finishing and the connection ending, is what this section is about.

For one SSE request, Node is holding an accepted TCP socket from the networking layer, an HTTP parser bound to that socket, an IncomingMessage for the request, and a ServerResponse for the response. The request head is already parsed and the route has already committed its headers. From there, the response object stays wired to the socket's outgoing HTTP state until something ends it, a call to res.end(), the peer closing, a timeout tearing the connection down, or code destroying the response.

That response is the live HTTP exchange on the socket. Under HTTP/1.1, a single connection serves responses in request order, so an SSE response that stays open holds onto that exchange the entire time, and the browser usually gives the whole connection over to the EventSource request. HTTP/2 multiplexes at the stream level instead, but its stream limits and flow control come up in Chapter 11.

By the time your SSE handler runs, the HTTP parser has already finished the request head. A normal EventSource GET carries an empty body, so the readable side of IncomingMessage has almost nothing left to do. The long-lived state collects on the response side, where the response object holds header state, writable stream state, event listeners, and a reference to the socket for the entire life of the stream.

response.headersSent flips to true once the headers commit. response.writableEnded flips once res.end() has been called, and response.writableFinished flips only after the data has flushed to the underlying system. Each flag marks a different moment, and an SSE response spends most of its life with headers sent true, writable ended false, and writable finished false.

Each res.write() call takes a string or bytes. For a string, Node encodes the chunk as UTF-8 by default. The chunk moves into the OutgoingMessage write path, then to the socket's writable side, then through libuv, then into the kernel send buffer. A true return means the data made it through Node's user-memory queue and into the lower buffer path for now. A false return means Node had to queue the data in user memory and will emit drain later, once the buffer level drops.

That backpressure signal is there even for a three-line SSE event. One slow client is enough to make res.write() return false. Ten thousand slow clients give you ten thousand separate spots where the process is sitting on pending response bytes. Subchapter 3 builds send queues and an overflow policy on top of that. The thing to carry forward now is simpler, that every SSE client is a live ServerResponse, and any live response can start buffering.

The response object also gives you a direct close signal after the handler returns. res.on('close') fires when the response completes, or when the underlying connection ends before it could complete. req.on('close') often fires on the same teardown path for this GET, but the response event lines up directly with the open stream getting cleaned up. Whichever you use, the cleanup has to remove the client from any in-memory registry and clear its heartbeat timers.

A timer is a real handle the runtime holds for you. A heartbeat setInterval() keeps its callback scheduled. If the client vanishes and that interval stays put, the process hangs onto the response closure and keeps writing to a connection that is already gone. It starts as a small memory leak and grows into descriptor churn once reconnects begin creating replacement connections.

Long polling keeps a different set of state alive. A poll request reaches the route and the route parses the cursor out of it. When events are ready, the route sends a response and ends it. When no events are ready, the route stores a callback or a small record that stands in for the waiter, and that record points at the ServerResponse, a timeout handle, and usually the requested cursor or the subscriber's identity.

While the request hangs, the socket stays open, the response stays unwritten or only half written, and the timer running against it is the request timeout your application picked. When an event arrives, your code finds the waiter, writes the response, and ends it. When the timer fires first, it sends an empty response and ends. When the client closes before either of those, cleanup removes the waiter and clears the timer.

That means a long-poll endpoint has three completion paths.

text
event arrives -> response with events -> end
timer fires -> empty response -> end
client closes -> cleanup -> skip response write
Three long-poll completion paths, event arrives, timer fires, and client closes, all feeding one finalizer that clears the timer, removes the waiter, and ends the response.
The event path, the timer path, and the client-close path all run the same finalizer, which clears the timer, removes the waiter from the registry, and writes the response at most once. Routing every outcome through one finalizer is what stops a double response end and leaked waiters.

All three paths have to land on one finalization function. Without that, one path clears the timer while another leaves the waiter registered, or a path writes a response after the socket already closed, or the event path tries to answer the same response a second time.

The Node HTTP server brings its own timeout settings as well. In current Node v24, server.timeout defaults to no inactivity timeout, while server.keepAliveTimeout kicks in after a response finishes and the socket is waiting for the next request. On top of that, frameworks, reverse proxies, and platform routers add timers of their own. SSE and long polling both need a deliberate timer plan, because the route is holding an exchange open on purpose.

For SSE, the usual plan is to stay open until either side closes, sending heartbeat bytes often enough to stay under every idle timeout in the path. For long polling, the plan is to hold for a shorter time than any upstream timeout, then answer empty and let the client poll again. That second plan is the reason long polling survives request and response infrastructure, since every request does eventually finish.

The state you are holding is ordinary JavaScript state plus socket state, and you can put numbers on it. Count your open SSE responses, your hanging poll requests, your timeouts, your close events, and your writes that come back false. Those counters tell you whether the transport is healthy well before later chapters bring in brokers, backplanes, and horizontal fanout.

Your memory accounting should cover application registries too. An SSE server usually keeps a record per connected client, holding the response object, the subscriber ID, the channel list, the last cursor it sent, the heartbeat timer, and sometimes a small pending-write flag. A long-poll server usually keeps a record per waiter, holding the response object, the requested cursor, the timeout handle, and the subject or channel. These are ordinary objects, so when they leak, they show up in heap snapshots as reachable closures and maps.

Cleanup should run exactly once, and a small finalizer makes that easy to guarantee.

js
function closeClient(client) {
  clearInterval(client.heartbeat);
  clients.delete(client);
}

Call that finalizer from res.on('close'), from shutdown code, and from any route-specific rejection path that runs after the stream has opened. Later you can have it do more, like dropping channel membership and publishing presence updates, but even this small version already covers heap and timer state.

EventSource Reconnection

Reconnecting after a drop is something SSE handles for you, up to a point.

An EventSource connection can drop for plenty of reasons, a server restart, a proxy timeout, a network change, a browser lifecycle event. When the closure is recoverable, the browser waits out the current retry delay and opens the URL again on its own. If the previous stream had sent an id: field, that reconnect request carries the value back in Last-Event-ID.

http
GET /events HTTP/1.1
Host: api.example.com
Last-Event-ID: 42

Last-Event-ID is the request header that reports the last event ID the EventSource object has stored, and the server is free to treat it as a resume point. The browser keeps track of stream IDs and sends back the most recent one, while the actual data model stays on the server.

Behind that ID, the server needs an event cursor. An event cursor is the server-side position that says where an event sits in the ordered sequence of updates for this stream. It might be an integer, a timestamp paired with a sequence number, a database offset, or some other compact token. Whatever form it takes, the server has to be able to compare it against its own event history, even though the client only ever treats it as opaque.

js
const last = req.headers['last-event-id'];
const cursor = last === undefined ? 0 : Number(last);

for (const event of eventsAfter(cursor)) {
  sendEvent(res, event);
}

That snippet shows the rough form, not the full policy. Real code validates the header, rejects or repairs a malformed cursor, and limits the history to the authenticated subject. Authentication and token refresh come later in this chapter. The only thing on display here is the cursor comparison that drives replay.

The failure sequence runs in fixed steps.

text
server writes id: 42
browser dispatches event 42
connection closes
browser reconnects with Last-Event-ID: 42
server sends events after 42
EventSource reconnect timeline where the browser dispatches event 42 and stores the id, the connection drops, the browser waits the retry delay, then reconnects sending Last-Event-ID 42 so the server replays only later events.
After the browser dispatches event 42 it stores that id, and when the connection drops it waits the retry delay and reconnects with Last-Event-ID 42. The server then replays only events after 42, or sends a state.reset event when cursor 42 is older than the retained history.

Write the event ID before the data: block finishes. The browser only updates its stored last event ID when it dispatches a completed block. If the connection dies partway through a block, the browser drops the incomplete event and holds onto the previous stored ID, and that turns out to help you. On reconnect, the server can resend that partial event, because the ID the client reports is still the earlier one.

An ID counts only once its event is committed. If the server writes an id: field and then crashes before the data: block ends, the browser stays on the older ID. If the server writes the full block and the browser dispatches it, the browser moves forward. From the client's side, the blank line is the commit point. The server's own commit point depends on where it records history, which is the reason history should be recorded before the event goes out to any live response.

The usual order is this.

text
record event in history
write id/event/data block
browser dispatches after blank line
browser stores id for reconnect

That order is what makes replay work after a failed write. If res.write() fails because the connection already closed, the event is still sitting in history. The client reconnects with the previous ID and gets the event a second time.

retry: changes the retry delay.

text
retry: 5000
data: maintenance window soon

The value is in milliseconds, and it updates the EventSource reconnection time. Your application retry policy is a separate thing, and so is the server-side resume window. A client can reconnect fast and still miss data, if the server already threw away the history it would have needed.

Choosing an event ID takes some thought. A bare timestamp can collide when two events land in the same millisecond. An incrementing integer is simple within one process, then gets awkward once several processes share the sequence. A database offset works well when the source of truth already stores ordered records, and a compound cursor can pack both a partition and an offset into one value. The right format depends on how your producer is built, but the SSE endpoint asks only one thing of it. eventsAfter(cursor) has to return every event after that cursor, in order, for as long as the replay window says it will.

In most Node services the replay window is finite. Keeping every event forever inside the process turns into a durability system, which is Chapter 21's territory. For this subchapter, assume an in-memory history bounded by count or by time. When the client's cursor is older than the history you still hold, the server has to answer at the application level. It can send a reset event, return an error, or tell the client to reload its state through a normal HTTP endpoint.

Handle that case on purpose. A silent gap leaves the client short on data with nothing to tell it so.

js
if (cursor < oldestCursor()) {
  sendEvent(res, { id: nextId(), type: 'state.reset', data: {} });
  return;
}

The reset event tells the client to go fetch a fresh snapshot through a normal route. The SSE stream stays an update path and only that. Snapshot loading, cache freshness, and reconciling data come up in later data chapters, so let this endpoint stay narrow. It sends ordered updates, and it notices when its replay window has run out.

Long Polling Request Flow

With long polling, the waiting happens inside a single HTTP request, and that request has a time limit.

A poll request is the client asking for whatever has happened since a cursor it already knows. The server looks at the events it has. If at least one is ready, it answers immediately. If nothing is ready, it leaves the response pending, and that pending response is the hanging request.

text
client -> GET /poll?after=42
server -> empty history, hold request
server -> event 43 arrives
server -> 200 [{"id":43,...}]
client -> GET /poll?after=43
One long-poll cycle where the server holds an empty poll, a late event 43 completes the response, the client repolls with the advanced cursor, and a quiet cycle returns an empty result when the timer fires.
The server holds the poll while history is empty, then answers once when event 43 arrives, and the client immediately repolls with the advanced cursor. A cycle with no event returns an empty result when the 25 second timer fires, and the client repolls with the same cursor.

The request timeout is the longest the server will keep that request open. It is an application limit, and you normally set it under the proxy and platform request timeouts. A hold of 20 to 30 seconds is common, since that keeps intermediaries from deciding the request has stalled out. The exact number comes from your own deployment constraints.

The response structure is up to you. Plenty of APIs return 200 with an array, where an empty array means the hold timed out without anything to send. Others return 204 No Content for that empty timeout. A JSON array tends to make client code smaller, because then every successful poll parses the same structure.

js
function sendPoll(res, events) {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(events));
}

That helper sends a finite HTTP response and ends it. The cursor for the next request comes from the last event in the array, and when the array is empty, the client keeps its current cursor and polls again.

The endpoint starts with a cursor and an immediate check.

js
if (url.pathname === '/poll') {
  const after = Number(url.searchParams.get('after') ?? 0);
  const events = eventsAfter(after);
  if (events.length) return sendPoll(res, events);
  return holdPoll(req, res, after);
}

That up-front check keeps you from adding latency for no reason. When the server already has events after the requested cursor, it sends them in the current response, and the hanging path only kicks in when the response would otherwise go out empty.

The held path runs through one finalizer.

js
function holdPoll(req, res, after) {
  const timer = setTimeout(() => finish([]), 25_000);
  function finish(events) {
    clearTimeout(timer);
    waiters.delete(finish);
    sendPoll(res, events);
  }
  waiters.add(finish);
}

That function puts finish into a waiter registry. When an event arrives, publisher code calls each waiter with the events it has. If the timer goes off first, the response ends with an empty list. Either way the response closes, and the client sends another poll.

Most real registries are keyed. A single global Set sends every event to every waiter, which is fine for a demo and falls apart the moment you have more than one stream. A keyed registry stores waiters by channel, by account, by document ID, or by whatever subject the route exposes.

js
function waitersFor(key) {
  let set = waitersByKey.get(key);
  if (set === undefined) waitersByKey.set(key, set = new Set());
  return set;
}

That helper is still plain local process state, and cross-process fanout waits for Subchapter 5. The thing to hold onto here is that the waiter registry is part of the transport itself, and its key has to line up with the route's authorization scope.

Publishing into that registry is a short synchronous path in a one-process server.

js
function publish(key, event) {
  const set = waitersByKey.get(key);
  if (set === undefined) return;
  waitersByKey.delete(key);
  for (const finish of set) finish([event]);
}

Deleting the set before you call the waiters keeps reentrant code from finding a group that has already finished. Each waiter answers exactly one HTTP response, and if the client still has nothing waiting for it, a later poll creates a fresh waiter.

Batching is an application choice too. A poll response can carry one event, or every event available after the cursor. Small batches reduce request churn during bursts. Make them too large and a single response becomes slow to parse and pushes client state well behind the live stream. A bounded batch size gives the server a way to control this. Send up to N events, return the last cursor in that batch, and let the client poll for more.

js
function eventsAfter(id, limit = 100) {
  return history
    .filter(event => event.id > id)
    .slice(0, limit);
}

That function is fine for a small in-memory example. Larger histories want indexed storage, and durable event logs with broker-backed replay come in Chapter 21.

One thing is still missing, cleanup when the client disconnects.

js
req.on('close', () => {
  clearTimeout(timer);
  waiters.delete(finish);
});

That cleanup goes inside holdPoll(), right next to the timer. A hanging request that stays registered becomes a retained response object, and during a reconnect storm the leak grows quickly, since every abandoned poll adds another waiter.

Long polling clients usually run a tight async loop.

js
let after = 0;
for (;;) {
  const res = await fetch(`/poll?after=${after}`);
  for (const event of await res.json()) after = event.id;
}

That browser loop only sends the next request after it has finished with the previous response. Production client code piles on aborts, visibility handling, auth refresh, and a retry delay after errors, but those policies come later. The transport rule at this stage is just the repeated exchange, one request waiting, one response answering, then the client going again with a new cursor.

Long polling can use ordinary JSON responses and normal status codes, and it passes through most middleware and API gateways as plain HTTP. The observability feels familiar as well. Request duration includes the time the server spent waiting, response size is finite, and every held request finishes eventually. What you pay for that is repeated request overhead and more churn through routing, validation, auth, logs, and proxies.

SSE pays that routing and validation overhead one time per connection. Long polling pays it again on every single poll cycle.

Concurrency control comes up quickly. A single browser tab should usually keep just one outstanding poll for a given stream. Two outstanding polls carrying the same cursor can both pick up the same event batch, which an idempotent client might tolerate, though it still means extra load and duplicate processing. A client loop that waits for the previous response before firing the next request keeps the transport straightforward.

Your server code can enforce the same rule per subject. If a second poll shows up for a stream while the first is still hanging, the server can close the old one with an empty response and keep the newer poll, or it can reject the second one with 409. Which you choose depends on your API contract, but the mechanism is plain registry replacement.

js
const old = activePolls.get(key);
if (old) old.finish([]);
activePolls.set(key, { finish });

That code keeps a single active waiter per key. It also means finish() has to delete its own registry entry, or the map will hang onto completed polls.

Cursors And Missed Events

Whether a client can recover after a disconnect comes down to its cursor.

An event cursor identifies one produced event. A poll cursor is the value a long-poll client sends to ask for anything later than it, and in SSE the Last-Event-ID header plays exactly that part during reconnect. In both transports, the server compares the client's cursor against the event history it has kept.

A replayable event is one the server can send a second time after the first attempt. Replay needs two things, a payload the server kept and a cursor that still points at it. A message that lives only in a local variable during one res.write() call is unrecoverable once that write finishes.

A minimal in-memory history needs very little code.

js
const history = [];
let next = 1;

function record(type, data) {
  const event = { id: next++, type, data };
  history.push(event);
  if (history.length > 1000) history.shift();
  return event;
}

That history keeps the newest thousand events, and id serves as the event cursor. The server answers eventsAfter(42) by filtering for events whose id is greater than 42. Once event 42 ages out of the array, a client still asking for anything after 12 is holding a stale cursor.

A stale cursor has to have a defined outcome.

js
function eventsAfter(id) {
  if (history.length && id < history[0].id - 1) return null;
  return history.filter(event => event.id > id);
}

Here null means the cursor is older than the window you retain, while an empty array means the cursor is current and nothing new has arrived yet. Keep those two apart, because a stale cursor is a recovery problem while an empty result is ordinary waiting.

For SSE, a stale Last-Event-ID can become a named reset event before the stream continues.

js
const events = eventsAfter(cursor);
if (events === null) {
  sendEvent(res, record('state.reset', {}));
} else {
  for (const event of events) sendEvent(res, event);
}

For long polling, a stale after cursor can turn into a 409 Conflict, a 410 Gone, or a JSON response telling the client to reload its state. Which status you pick is part of your API contract, but the server mechanic is the same in each case. The stale-cursor check has to run before the endpoint settles in to wait for new events.

Scoping the cursor takes the same attention. When one process serves many users or tenants, a single global integer can still represent global order, but eventsAfter() has to filter by authorization scope before it returns anything. One tenant should never see another tenant's event count. A global, visible cursor can leak how much traffic the whole system is carrying. Opaque cursor tokens cut that leak down, though they ask for server-side validation or signed encoding in return. The broader auth policy lives in the security chapters. The rule for right now is small, that a cursor is user input, and you validate it as carefully as you would any other request parameter or header.

Where you store the cursor also decides the delivery semantics. A cursor kept only in the browser disappears when the tab resets. Move it into local storage and it survives reloads, though it can outlive the server's replay window. Store it server-side per user and you can resume across devices, at the cost of server state you now have to expire and protect. Either way, SSE and long polling stay the same transports. The thing that shifts is the recovery behavior wrapped around them.

The same goes for payloads. A replayable event should carry either enough data for the client to apply the update directly, or enough identity for it to refetch the affected resource. A payload that says only that something changed forces every client into a wide refetch after each reconnect, and one stuffed with private fields can leak data through logs or browser storage. The transport sends whatever bytes you hand it. Whether replay stays cheap or turns noisy is set by the event contract.

Long polling carries one extra race. The server checks history, finds nothing, and gets ready to store the hanging request. An event can arrive in the gap between those two steps. If the code registers the waiter only after the event notification has already fired, the request hangs until timeout even though a matching event exists.

Closing that race means making the check and the register one atomic step against your local event registry. In a single Node process, that comes down to keeping both operations in one synchronous turn, with no await between them.

js
const events = eventsAfter(after);
if (events.length) return sendPoll(res, events);

waiters.add(finish);

Nothing async sits between the history check and the waiter registration. Once your event source runs through a database or broker, the race moves into that system's delivery and acknowledgement model, and Chapter 21 goes into that deeper version.

SSE has a related race at startup. A route can flush headers, register the connection, and then replay history, or it can replay first and register afterward. If a new event arrives between the replay and the registration, the client misses it. In one process the fix is the same idea as before. Register the client and capture the cursor position in one synchronous path, then replay from history starting at that position. For durable sources, follow whatever offset rules that source defines.

The transport gives you a way to deliver events. How well a client recovers once that delivery breaks comes from your cursor storage and the size of your replay window.

Testing The Path

Most SSE bugs are visible in the raw bytes.

curl can show the stream at the raw HTTP layer.

bash
curl -N http://localhost:3000/events

-N tells curl to print bytes as they arrive instead of buffering them. A valid event prints as its fields followed by a blank line, and a heartbeat prints as a colon line followed by a blank line. If the server writes an event and the terminal stays empty, the cause is usually a buffering proxy, compression middleware, or an event block that is still waiting for its blank line.

Your first local check should be the simplest one possible.

text
id: 1
event: server.ready
data: {"ok":true}

Then close the client and watch what the server does. The open-client count should drop, the heartbeat timer count should drop, and the response should leave any channel registry. A reconnect after that should create exactly one replacement record, no more.

Long polling needs a different check. Start one request and let it hang.

bash
curl -i 'http://localhost:3000/poll?after=0'

In another terminal, publish one event through whatever local trigger the example server gives you. The hanging request should finish with a finite JSON response. A second request carrying the returned cursor should either hang again or come back with the next batch. Waiting past the request timeout should return whatever empty-result structure the API settled on.

Test the timeout path as hard as the event path. A long-poll implementation that only works when an event arrives will leak waiters during quiet periods. A route that only handles the timeout can answer too late under load, if the publish callbacks do too much work before they get to finish().

Proxy tests have to run against the deployed path. A direct localhost request proves the Node handler writes valid SSE blocks. It does not prove the public route flushes those blocks all the way through the proxy, gateway, CDN, or platform router. For SSE, measure how long an event takes to arrive through the public URL. For long polling, confirm the public path allows the full hold duration and returns the empty response before the platform timeout cuts in.

Proxy And Browser Boundaries

To your Node handler, SSE and long polling are nothing special. The layers sitting around that handler can treat them very differently.

A reverse proxy can buffer response bytes. For normal responses that buffering helps, since it can combine tiny writes and apply transformations on the way out. For SSE, the same buffering can hold events back until the proxy flushes a larger chunk, so the Node process writes the event and the browser still sees nothing. A test against localhost misses this completely, because the proxy layer is not in the path.

A couple of headers state your intent here.

js
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Content-Type', 'text/event-stream');

Some proxies still need their own configuration or vendor-specific headers before they stop buffering event streams. Leave that in deployment config. The part of SSE that travels everywhere is the media type, the line format, and the open response.

Compression fails in much the same way. Gzip or Brotli middleware may gather output before it produces compressed bytes. For small SSE events, that holds delivery back until enough input builds up. It also spends CPU on a stream that is mostly tiny JSON updates and heartbeat comments. Plenty of SSE routes turn compression off on purpose. If you leave it on, measure event arrival time through the same proxy chain you run in production.

Idle timeouts are the point the two transports part ways. SSE puts bytes on the connection with comment heartbeats before an idle timer can fire, and long polling sets a request timeout under whatever the infrastructure allows. Either way, write the numbers down next to the route. If the proxy closes idle responses at 60 seconds, a 15 second SSE heartbeat and a 25 second long-poll hold both fit with room to spare. If a platform router kills any request at 30 seconds, a 45 second long poll fails at that limit every time.

Give Node timers and proxy timers separate names in config. SSE_HEARTBEAT_MS is the bytes written to an open response. LONG_POLL_TIMEOUT_MS is when the server answers an empty poll. The proxy read timeout, the gateway request timeout, and the browser reconnect delay are all different values, and folding them into one TIMEOUT_MS makes the route hard to reason about during an incident.

Browser connection limits come into play as well. EventSource opens an HTTP connection the browser manages, and browsers cap how many connections they will open per origin, especially under HTTP/1.1. The exact cap depends on the browser and the protocol. Multiple tabs, multiple EventSource objects, and other requests to the same origin all compete for that client-side connection capacity. HTTP/2 changes the client side through streams, but the stream limits come up in Chapter 11.

Long polling runs into those limits differently. Each request completes, and then the client opens the next one. The browser still holds an active request during the hang, but the slot keeps turning over instead of staying pinned. Under high fanout, that turnover means more server request churn and more access logs. Under low event rates, it can actually pass through conservative proxies more easily, since every response ends on schedule.

Body middleware on the request and response can get in the way too. Compression middleware buffers output while it waits for enough bytes, JSON response helpers sometimes call res.end() for you, and framework timeout plugins often assume every route finishes inside a normal API latency budget. SSE endpoints frequently need a narrow path around all of that. Set the headers yourself, flush, write event blocks, and handle the cleanup yourself. Long polling can usually stay inside ordinary response helpers, since each response is finite, though the hanging period still needs its own route-specific timeout.

Caching rules call for the same route-level attention. Treat event streams as live responses, and remember that long-poll responses can carry updates meant for one user. Configure shared caches so they never keep either response as something they can hand to another client. Use cache headers that say exactly that, then check the proxy behavior with a client that connects through the deployed path.

The last one is shutdown. During a deploy, the server should stop accepting new realtime requests, close or drain the existing responses according to your policy, and leave clients a clear way to reconnect. Production shutdown comes later, but the route can already have the right structure in place. Every open SSE connection already has a cleanup handle, and every hanging long-poll request already has its one finalizer.

Choosing The Transport

The first question is usually which way the data mostly flows.

SSE moves events from server to client over one HTTP response, and the client sends its commands back through normal requests like POST /actions. That arrangement works nicely for dashboards, notifications, progress streams, job status, and audit feeds, anywhere the server does most of the updating and the client's messages are just ordinary API calls.

Long polling moves the same server-to-client updates through repeated responses instead. It pays off when clients or infrastructure push back on long-lived streaming responses, when you are dealing with older environments, or when each update batch fits cleanly into the API response contracts you already have. As a bonus, operations teams get the request-completion behavior they are used to seeing.

WebSocket gives you one bidirectional framed connection. Reach for it when the client sends frequent low-latency messages, when both sides need to speak whenever they want, or when the application already relies on the WebSocket frame-level behavior from the previous subchapter.

Look at payloads separately. SSE is text, so JSON over the data: field is the common path and works well, but binary payloads need encoding or a different transport altogether. Long polling can return JSON, newline-delimited JSON, or any normal HTTP body you like. WebSocket carries both text and binary messages once the upgrade is done.

What each one costs to run is different. SSE holds one response per client and pushes many events through it. Long polling holds one request per client during the quiet stretches and spins up a new request every cycle. WebSocket holds one upgraded connection per client and brings along its own framing, ping/pong, close codes, and library behavior.

Recovery works differently in each as well. SSE hands you Last-Event-ID as a reconnect signal you get for free. Long polling sends the cursor as an explicit query parameter or header on every poll. WebSocket apps usually define a resume message or reconnect handshake of their own. The same cursor idea runs underneath all three, but each one carries it in a different place.

Latency is easy to get optimistic about, so look at it honestly. SSE can deliver an event the instant the server writes it and every layer in between flushes. Long polling can also deliver right away, as long as a request is already hanging when the event lands. The extra latency shows up between responses, in the window after the client receives one, parses it, advances the cursor, and sends the next request. With a tight loop that window is small. It widens with mobile browsers, background tabs, and client-side retry delays.

Capacity planning grows out of the state you hold. With SSE, that is open responses and write pressure. With long polling, it is hanging requests per second, the average hold time, and request churn. With WebSocket, it is upgraded sockets, frame parsing, ping/pong, and send queues. The next subchapters bring in slow-client policy and fanout, but this first choice already sets which counters you end up watching.

Most of the time the answer is unremarkable. Use SSE for one-way browser updates with replayable IDs, long polling for HTTP-only compatibility with bounded waits, and WebSocket for full-duplex sessions. Picking one of the three takes a few minutes. The cleanup, the timeouts, the buffering, the cursor windows, and the slow clients are what you will actually spend your time on.