Server-Sent Events and Long Polling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Server-Sent Events (SSE) and HTTP long polling are the two ways to push data from a server to a browser without leaving
ordinary HTTP. SSE is defined by the WHATWG HTML Living Standard (section "Server-sent events") and is exposed in the
browser as the EventSource interface. Long polling is not a standard: it is a technique, documented as best practice in
the informational RFC 6202 (April 2011). This page was checked on 2026-10-24 against the WHATWG HTML Living Standard,
the MDN pages for EventSource and "Using server-sent events" (EventSource is marked Baseline, widely available, with
support in all current browsers since January 2020), RFC 6202, RFC 9113 (HTTP/2) and the nginx 1.x documentation. The
examples were run on Node.js 24.21.0 with no third-party packages.
Of the three applications in this section, Chatterbox is the natural fit: notification counters, "new message in a room
you are not viewing" badges, presence and read-state changes all flow from the server to the browser, while the rare
upstream action (send a message, mark as read) is an ordinary HTTP POST. Huddle can use SSE for lobby and room-list
updates and for "someone is waiting to be admitted" notices, with the media itself carried by WebRTC. Pixel Arena uses
it for lobby, matchmaking and leaderboard feeds, but never for the game loop, whose latency and binary snapshots need
WebSocket or
data channels.
The three techniques side by side
| Long polling | Server-Sent Events | WebSocket | |
|---|---|---|---|
Direction |
server to client per request; the client re-asks |
server to client only |
both directions on one connection |
Browser API |
|
|
|
Wire |
one complete HTTP response per batch of events |
one HTTP response that never ends, |
HTTP upgrade, then frames |
Resume after a drop |
cursor you design ( |
built in: |
you design it (see the Chatterbox |
Reconnect |
your loop |
automatic in |
yours |
Payload |
any (JSON, binary) |
UTF-8 text only |
text or binary |
Custom request headers |
yes ( |
no with |
no |
Proxy friendliness |
best: ordinary requests |
good if buffering is off |
needs upgrade support |
Per-origin limit in HTTP/1.1 |
6 concurrent connections shared with all other requests |
6 per browser and origin, across tabs |
not counted the same way, see the browser WebSocket API |
The site already covers framework-specific SSE implementations, and this page does not repeat them. See Spring MVC SseEmitter and htmx, Node.js WebSockets and SSE, Flask streaming and SSE, Axum SSE and streaming and Tornado streaming, long polling and SSE. What follows is the protocol-level knowledge that applies to all of them.
The EventSource API
const source = new EventSource('/events?rooms=general', { withCredentials: false });
The constructor takes a URL, resolved against the page, and an optional dictionary with one member, withCredentials.
It throws a SyntaxError if the URL cannot be parsed. The request is always a GET, with Accept: text/event-stream.
readyState has three values, exposed as constants on EventSource and on every instance:
| Constant | Value | Meaning |
|---|---|---|
|
0 |
The initial state, and also the state while the browser waits to reconnect after a drop. |
|
1 |
The response arrived with status 200 and |
|
2 |
A fatal failure happened, or |
withCredentials defaults to false. With false, a cross-origin EventSource sends no cookies and ignores any
Set-Cookie. With true, the request uses the credentials mode include, so the server must answer with a specific
Access-Control-Allow-Origin (never *) and Access-Control-Allow-Credentials: true. A same-origin EventSource
always sends the site’s cookies. The property is read-only after construction.
Events. EventSource is an EventTarget:
-
openfires when the response is accepted. -
messagefires for every event that has noevent:field. TheMessageEventcarriesdata(a string, never parsed for you),originandlastEventId. -
Named events: a stream block with
event: mentionfires an event of typemention, which onlyaddEventListener('mention', handler)receives. Settingsource.onmessagedoes not catch it, a frequent surprise. -
errorfires on every failure, including the transient ones that the browser will recover from. ReadreadyStateto tell them apart:CONNECTINGmeans a retry is scheduled,CLOSEDmeans it gave up. The event carries no status code and no message, so the browser console is the only place that shows why.
Methods. close() aborts the request and sets CLOSED. There is no send(), no way to set headers, and no way to
choose the HTTP method or a request body. EventSource is available in dedicated and shared workers as well as in
window contexts, which matters for the connection-sharing technique described below.
When the response is not usable, the browser fails the connection with no retry: any status other than 200, a
Content-Type other than text/event-stream, or a CORS failure. That is why an expired session returning 401 or
302 to a login page ends the stream for good, and the page must notice the CLOSED state and re-authenticate before
creating a new EventSource. A status 204 No Content is the documented way for a server to tell the client to stop
reconnecting, and it too ends in CLOSED.
The text/event-stream format
The response is UTF-8 text, delivered as chunked HTTP/1.1 or as HTTP/2 DATA frames, that never ends. It is a sequence
of blocks, each terminated by a blank line. Each line of a block is field: value.
| Field | Effect |
|---|---|
|
Appends the value, followed by a line feed, to the event’s data buffer. Several |
|
Sets the event type. Without it the type is |
|
Sets the stream’s last event ID. It sticks: later events without an |
|
Sets the reconnection delay in milliseconds, if the value is made only of ASCII digits. Anything else is ignored. |
a line starting with |
A comment. Ignored by the parser, and the standard way to send keep-alives. |
any other field name |
Ignored. |
retry: 3000
: keepalive
id: 41
event: message
data: {"room":"general","from":"ana","text":"hello"}
id: 42
event: mention
data: {"room":"general","from":"bo","text":"@ana lunch?"}
The parsing rules are small but easy to get wrong in a hand-written server or a fetch-based client:
-
Dispatch happens on the blank line. An event with an empty data buffer is dropped, and a block cut off by a connection drop (no final blank line) is discarded. A server that sends
data: x\nand forgets the second\ndelivers nothing until the next event arrives. -
Line endings may be CRLF, LF or a lone CR, and may be mixed. A parser reading chunks must hold back a CR at the end of a chunk, because the LF of a CRLF pair may be in the next chunk.
-
A leading byte order mark (U+FEFF) at the very start of the stream is removed. Anywhere else it is data.
-
After the colon, one single leading space is removed.
data:xanddata: xare the same, anddata: xkeeps one space. A line without a colon is a field name with an empty value, so a baredataline appends an empty line. -
Field names are case sensitive and unknown ones are ignored, so a typo such as
Data:silently loses the payload. -
The
Last-Event-IDvalue is one string. An id may be any text without NUL, LF or CR, but pick something monotonic and short, such as an integer or a ULID.
Resume with Last-Event-ID and automatic reconnect
EventSource reconnects by itself. When the connection drops (a network error, the server closing the response, a
proxy cutting an idle stream), the state becomes CONNECTING, an error event fires, the browser waits and issues the
same GET again. The wait is implementation-defined, a few seconds by default, and the server can set it with
retry:. Browsers may add their own backoff on top. If the last event ID string is not empty, the new request carries it
as a header:
GET /events?rooms=general HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 41
Resume is only as good as the server’s memory. The server must keep a replay buffer or a store from which it can read "everything after id N", and must decide what to do when N is older than what it kept. A robust design:
-
Number events with a per-stream, strictly increasing integer. For Chatterbox this is the room
seq, or a global notification counter, never a timestamp, because timestamps collide. -
Keep the last few hundred events in memory, or read them from the database table that already stores them.
-
When the requested id is older than the buffer, send an application event such as
resetthat tells the client to reload its state from the history endpoint, then continue live. Replaying a partial tail would hide the gap. -
Treat a missing or unknown id as a fresh start. After a server restart that loses the counter, ids go backwards, so persist the counter or prefix ids with an epoch (for example
7-42), otherwise a client holding id 900 would be told nothing is new. -
Browsers cannot send
Last-Event-IDon the first connection of a freshly loaded page, because the id lives in theEventSourceinstance. A page that stores the last id (for example insessionStorage) can pass it as a query parameter, and the example server accepts both forms. -
Delivery is at least once: a drop between the server writing an event and the browser parsing it replays it. Make handlers idempotent by de-duplicating on the id, as the Chatterbox protocol does with message ids, see Message Design and Serialization.
For the heartbeat and reconnect strategy shared with WebSocket clients (jittered backoff, giving up, online/offline events) see Connection Lifecycle, Heartbeats and Reconnection.
The Chatterbox notification feed
The server below is a complete SSE endpoint for Chatterbox notifications, written with node:http only. It
authenticates with the session cookie (or a bearer token, used by the fetch client later), keeps a 500-event replay
buffer, honours Last-Event-ID, filters by room, sends a comment keep-alive, and exposes POST /messages as the upstream
channel. It is a simplified notification feed, not the full Chatterbox WebSocket protocol.
// Chatterbox notification feed over Server-Sent Events, plain node:http, no dependencies.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const PORT = Number(process.env.PORT ?? 8080);
const KEEPALIVE_MS = Number(process.env.KEEPALIVE_MS ?? 15000); // below any proxy idle timeout
const BUFFER_SIZE = 500; // how many events can be replayed
const SECRET = 'change-me'; // placeholder session value
const log = []; // replay buffer: { id, type, data }
let nextId = 1;
const clients = new Set(); // { res, rooms: Set<string> | null }
function format(ev) {
// data is single-line JSON, so one data: line is enough
return `id: ${ev.id}\nevent: ${ev.type}\ndata: ${ev.data}\n\n`;
}
function publish(type, payload) {
const ev = { id: nextId++, type, data: JSON.stringify(payload) };
log.push(ev);
if (log.length > BUFFER_SIZE) log.shift();
for (const c of clients) {
if (!c.rooms || c.rooms.has(payload.room)) c.res.write(format(ev));
}
return ev.id;
}
function authorized(req) {
const bearer = req.headers.authorization === `Bearer ${SECRET}`;
const cookie = (req.headers.cookie ?? '').split(/;\s*/).includes(`session=${SECRET}`);
return bearer || cookie;
}
function readJson(req) {
return new Promise((resolve, reject) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
if (body.length > 16384) reject(new Error('too_large'));
});
req.on('end', () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
});
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Set-Cookie': `session=${SECRET}; HttpOnly; SameSite=Lax; Path=/` });
res.end(await readFile(new URL('./index.html', import.meta.url)));
return;
}
if (req.method === 'GET' && url.pathname === '/events') {
if (!authorized(req)) { res.writeHead(401).end(); return; }
const rooms = url.searchParams.get('rooms');
const client = { res, rooms: rooms ? new Set(rooms.split(',')) : null };
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no', // tell nginx not to buffer this response
});
res.flushHeaders(); // send the headers now so the client fires "open"
res.write('retry: 3000\n\n'); // reconnection delay for this client
// Resume: the header (automatic reconnect) or a query parameter (first load after a page refresh)
const raw = req.headers['last-event-id'] ?? url.searchParams.get('lastEventId');
if (raw) {
const lastId = Number(raw);
const oldest = log.length ? log[0].id : nextId;
if (!Number.isInteger(lastId) || lastId < oldest - 1) {
// the gap is larger than the buffer: tell the client to reload its state
res.write(`event: reset\ndata: ${JSON.stringify({ oldest })}\n\n`);
} else {
for (const ev of log) {
if (ev.id > lastId && (!client.rooms || client.rooms.has(JSON.parse(ev.data).room))) res.write(format(ev));
}
}
}
clients.add(client);
const timer = setInterval(() => res.write(': keepalive\n\n'), KEEPALIVE_MS);
req.on('close', () => { clearInterval(timer); clients.delete(client); });
return;
}
if (req.method === 'POST' && url.pathname === '/messages') {
if (!authorized(req)) { res.writeHead(401).end(); return; }
try {
const { room, text, from } = await readJson(req);
if (!/^[a-z0-9-]{1,64}$/.test(room ?? '') || typeof text !== 'string' || !text) throw new Error('bad_request');
const id = publish('message', { room, from: from ?? 'anonymous', text });
res.writeHead(202, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, id }));
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: false, code: 'bad_request' }));
}
return;
}
res.writeHead(404).end();
});
server.listen(PORT, () => console.log(`Chatterbox SSE feed on http://localhost:${PORT}`));
The browser half is a static page served by the same server, so the cookie is sent automatically:
<!doctype html>
<meta charset="utf-8">
<title>Chatterbox notifications</title>
<h1>Chatterbox notifications</h1>
<p id="status">connecting</p>
<ul id="feed"></ul>
<form id="form">
<input id="text" placeholder="Say something" required>
<button>Send</button>
</form>
<script>
const status = document.querySelector('#status');
const feed = document.querySelector('#feed');
// Same origin, so the HttpOnly session cookie is sent automatically.
const source = new EventSource('/events?rooms=general');
source.addEventListener('open', () => { status.textContent = 'live'; });
source.addEventListener('error', () => {
// readyState CONNECTING (0) means the browser is retrying; CLOSED (2) means it gave up
status.textContent = source.readyState === EventSource.CONNECTING ? 'reconnecting' : 'closed';
});
source.addEventListener('message', (event) => {
const note = JSON.parse(event.data);
const li = document.createElement('li');
li.textContent = `#${event.lastEventId} [${note.room}] ${note.from}: ${note.text}`;
feed.append(li);
});
source.addEventListener('reset', () => {
// the server could not replay the gap: drop local state and reload it from the history API
feed.replaceChildren();
});
document.querySelector('#form').addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.querySelector('#text');
await fetch('/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: 'general', from: 'ana', text: text.value }),
});
text.value = '';
});
</script>
Source: MDN: Using server-sent events
Run it, post three messages and read the stream with curl. The replay request asks for everything after event 1 in the
general room, so the random room message is filtered out. These are the real responses from a run on Node.js 24.21.0:
node sse-server.mjs &
for i in 1 2 3; do
curl -s -X POST -H 'Authorization: Bearer change-me' -H 'Content-Type: application/json' \
-d "{\"room\":\"general\",\"from\":\"ana\",\"text\":\"msg $i\"}" localhost:8080/messages
done
curl -s -N -m 2 -H 'Authorization: Bearer change-me' -H 'Last-Event-ID: 1' 'localhost:8080/events?rooms=general'
{"ok":true,"id":1}
{"ok":true,"id":2}
{"ok":true,"id":3}
retry: 3000
id: 2
event: message
data: {"room":"general","from":"ana","text":"msg 2"}
id: 3
event: message
data: {"room":"general","from":"ana","text":"msg 3"}
: keepalive
Source: MDN: EventSource
A request with a wrong token gets 401. A request whose Last-Event-ID is older than the 500-event buffer receives
event: reset with data: {"oldest":101} instead of a partial replay. In headless Chrome the page above reached the
live state, and a message typed in the form was posted with fetch, broadcast and appended to the list by the
message listener with lastEventId set.
Connection limits: HTTP/1.1 and HTTP/2
Every open SSE stream holds one HTTP connection for as long as the page is open. Over HTTP/1.1 a browser opens at most
about six connections per origin, and MDN states that this limit applies per browser and origin, so it is shared by
every tab. Chrome and Firefox have marked the bug as "won’t fix". Open the feed in six tabs and the seventh tab, and
every ordinary fetch to that origin, queues behind the streams and appears to hang. RFC 6202 already warned about this
in 2011, when HTTP recommended two connections per server and newer browsers allowed six to eight.
Over HTTP/2 (RFC 9113) the browser multiplexes all requests to an origin on one TCP connection, and the limit becomes
the number of concurrent streams, negotiated through SETTINGS_MAX_CONCURRENT_STREAMS. MDN gives a default of 100, and
servers commonly advertise 100 or more. So the practical rules are:
-
Serve SSE over HTTP/2 (or HTTP/3) in production. Terminate TLS at a proxy that speaks HTTP/2 to browsers even if it speaks HTTP/1.1 to your Node.js, Flask or Spring process, as in the sketch below.
-
During development, a plain
http://localhostserver speaks HTTP/1.1, so the limit appears there and not in production. Test with at least seven tabs. -
If you must support HTTP/1.1 clients, multiplex: one stream per page carrying all channels, as the example does with the
roomsquery, instead of oneEventSourceper room. -
To share one stream among tabs, open the
EventSourcein aSharedWorker, or elect a leader tab with the Web Locks API and relay events withBroadcastChannel. Do not shard across sub-domains, which only hides the problem.
Proxies, buffering and timeouts
SSE only works if every hop forwards bytes as they are written. Three things break it:
Response buffering. A reverse proxy may collect the response in memory or on disk until it is complete or a buffer is
full, which for a never-ending response means the browser sees nothing. nginx buffers proxied responses by default. The
application can switch buffering off for one response with the header X-Accel-Buffering: no, which the nginx
documentation describes for proxy_buffering, or the location can disable it. On the application side, call the
framework’s flush after each event and send the headers immediately (res.flushHeaders() in Node.js) so that open
fires without waiting for the first event. Also avoid response compression on the stream: gzip needs a full block
before it emits bytes. Cache-Control: no-cache, no-transform asks intermediaries not to cache or transform the body.
location /events {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection ""; # keep the upstream connection reusable
proxy_buffering off; # the application also sends X-Accel-Buffering: no
proxy_cache off;
gzip off;
proxy_read_timeout 1h; # default is 60 s; see below
}
Idle timeouts. A proxy, load balancer or NAT drops a connection that carries no bytes for too long. nginx’s
proxy_read_timeout defaults to 60 seconds, and AWS Application Load Balancers default to 60 seconds too. A quiet
notification stream goes silent for minutes, so the server sends a comment line (: keepalive) well below the shortest
timeout in the path, 15 to 25 seconds is typical. The comment is invisible to EventSource handlers. On the client, a
watchdog that reconnects when nothing, not even a comment, arrived for two intervals catches half-open TCP connections
that never raise error; EventSource has no such watchdog, which is one more reason to consider fetch streaming.
Intermediaries that re-chunk. RFC 6202 notes that proxies may split or merge HTTP chunks, so an event must never rely on arriving in one read. The blank-line framing of SSE exists for that reason, and the fetch parser later keeps an unterminated tail in its buffer.
Corporate proxies that do not understand streaming responses may hold the whole response. This is one of the real
reasons to fall back to long polling: each response is complete and ends quickly, which every HTTP intermediary
supports. Details of load balancers and sticky sessions are in
Proxies, Load Balancers and Deployment, and
scaling the fan-out to several server processes is in Scaling WebSockets:
the same Redis pub/sub or Streams backplane feeds SSE endpoints, and a Redis Stream id works naturally as the SSE id.
Authentication: no custom headers with EventSource
EventSource cannot set request headers, so the Authorization header is impossible. The options are:
-
A session cookie (same-origin, or cross-origin with
withCredentials: trueand the CORS credentials headers). It is the simplest and works withHttpOnly. Because the cookie is sent automatically, the endpoint must not change state on aGET, and for cross-origin use it must answer CORS only for an allow-list of origins: any page can open anEventSourceto your origin, and only the CORS response decides whether it may read the stream. -
A short-lived one-time ticket in the query string (
/events?ticket=...), obtained from an authenticatedPOSTand valid for seconds and for a single use. Query strings end up in access logs, so never put a long-lived token there. This is the same trade-off described for WebSocket in Authentication and Authorization. -
fetchstreaming, below, which accepts any header.
A browser tab that outlives its session is the common failure. The next reconnect gets 401, which ends the
EventSource for good (state CLOSED), and the page must show a sign-in prompt, then create a new instance.
fetch-based streaming as an EventSource replacement
fetch returns the response body as a ReadableStream, so a client can consume text/event-stream itself. That buys
custom headers (Authorization), any HTTP method and a request body (for example a POST with a filter), and full control
of reconnect and timeouts. The cost is that the parser and the reconnect loop are yours, including Last-Event-ID, which
is just another header you set. The client below implements the parsing rules from the previous sections (BOM, CR/LF/CRLF,
multi-line data, comments, retry:, sticky ids), a reconnect loop with jittered exponential backoff, an idle watchdog,
and stops for good on 401 or 403. It runs unchanged in Node.js and in browsers.
// SSE over fetch(): custom headers, reconnect with Last-Event-ID. Runs in Node 18+ and in modern browsers.
export class SseParser {
#buf = '';
#first = true;
#type = '';
#data = [];
#id = ''; // last event ID, persists across events
retry = null;
feed(chunk) {
this.#buf += chunk;
if (this.#first && this.#buf.length > 0) {
if (this.#buf[0] === '') this.#buf = this.#buf.slice(1); // one leading BOM
this.#first = false;
}
const events = [];
const re = /\r\n|\r|\n/g;
let start = 0;
let m;
while ((m = re.exec(this.#buf)) !== null) {
// a lone CR at the very end may be the first half of CRLF: wait for the next chunk
if (m[0] === '\r' && m.index === this.#buf.length - 1) break;
const line = this.#buf.slice(start, m.index);
start = m.index + m[0].length;
const ev = this.#line(line);
if (ev) events.push(ev);
}
this.#buf = this.#buf.slice(start); // an unterminated tail stays buffered
return events;
}
#line(line) {
if (line === '') { // blank line: dispatch
if (this.#data.length === 0) { this.#type = ''; return null; }
const ev = { type: this.#type || 'message', data: this.#data.join('\n'), lastEventId: this.#id };
this.#type = ''; this.#data = [];
return ev;
}
if (line.startsWith(':')) return null; // comment
const i = line.indexOf(':');
const field = i === -1 ? line : line.slice(0, i);
let value = i === -1 ? '' : line.slice(i + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') this.#type = value;
else if (field === 'data') this.#data.push(value);
else if (field === 'id' && !value.includes('\0')) this.#id = value;
else if (field === 'retry' && /^\d+$/.test(value)) this.retry = Number(value);
return null;
}
}
export function subscribe(url, { token, onEvent, onState = () => {}, idleMs = 45000, signal }) {
let lastId = '';
let retryMs = 3000;
let failures = 0;
const run = async () => {
while (!signal?.aborted) {
const ac = new AbortController();
const stop = () => ac.abort();
signal?.addEventListener('abort', stop, { once: true });
let idle;
const arm = () => { clearTimeout(idle); idle = setTimeout(stop, idleMs); }; // watchdog for silent drops
try {
arm();
const res = await fetch(url, {
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`, // impossible with EventSource
...(lastId && { 'Last-Event-ID': lastId }),
},
cache: 'no-store',
signal: ac.signal,
});
if (res.status === 401 || res.status === 403) { onState('unauthorized'); return; } // do not retry
if (!res.ok || !res.headers.get('content-type')?.startsWith('text/event-stream')) throw new Error(`HTTP ${res.status}`);
onState('open');
failures = 0;
const parser = new SseParser();
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
for (;;) {
const { value, done } = await reader.read();
if (done) break;
arm();
for (const ev of parser.feed(decoder.decode(value, { stream: true }))) {
if (ev.lastEventId) lastId = ev.lastEventId;
onEvent(ev);
}
if (parser.retry !== null) retryMs = parser.retry;
}
} catch (err) {
if (signal?.aborted) return;
} finally {
clearTimeout(idle);
signal?.removeEventListener('abort', stop);
}
failures++;
onState('reconnecting');
const backoff = Math.min(retryMs * 2 ** (failures - 1), 30000);
await new Promise((r) => setTimeout(r, backoff * (0.5 + Math.random() / 2))); // jitter
}
};
return run();
}
// Command-line demo: node sse-fetch-client.mjs
if (import.meta.url === `file://${process.argv[1]}`) {
const ac = new AbortController();
const max = Number(process.env.MAX_EVENTS ?? Infinity);
let seen = 0;
await subscribe(process.env.FEED_URL ?? 'http://localhost:8080/events', {
token: process.env.TOKEN ?? 'change-me',
signal: ac.signal,
onState: (s) => console.log(`state: ${s}`),
onEvent: (ev) => {
console.log(`${ev.type} id=${ev.lastEventId} ${ev.data}`);
if (++seen >= max) ac.abort();
},
});
}
Run against the server above (Node.js 24.21.0, on a different port in this run):
$ MAX_EVENTS=3 node sse-fetch-client.mjs # three messages are posted after it starts
state: open
message id=1 {"room":"general","from":"ana","text":"msg 1"}
message id=2 {"room":"general","from":"ana","text":"msg 2"}
message id=3 {"room":"general","from":"ana","text":"msg 3"}
In a second run, with the idle watchdog set to 1.5 seconds against a silent server, the client printed state: reconnecting
and state: open between events: it aborted, waited, and reconnected carrying the last id it had seen, so no event published
during the gap was lost. With a wrong
token the client printed state: unauthorized and exited instead of looping. Feeding the parser one character at a time
with a BOM, CRLF, lone CR and a multi-line data produced the same events as feeding it whole.
Things to remember with this approach: the browser’s own EventSource is more battery-friendly on mobile, as the
browser can coalesce its reconnects; fetch streaming over HTTP/1.1 uses a connection from the same six-per-origin
pool; and an AbortController is the only way to close the stream, so always abort on page hide or component unmount.
Long polling done right
Long polling asks the question "anything new since cursor N?" and lets the server hold the request open until there is an answer or a deadline passes. The client then immediately asks again. Every response is a complete, ordinary HTTP response, so it works through any proxy, with any header, in any browser, and needs no sticky session as long as the cursor refers to shared state. RFC 6202 section 2 lists its known costs: HTTP header overhead per message, up to three network transits of latency for the first event after a response, one parked request per client on the server, and event batching under load. RFC 6202 concludes that a long poll should end before intermediary timeouts, noting that a 30 second timeout is safer than longer ones even though tests succeeded up to 120 seconds.
Design rules.
-
Hold below the shortest idle timeout. If the path contains a 60 second proxy limit, hold for 25 to 30 seconds. The client-side deadline is a little longer (35 s) so the server normally answers first and the client’s timeout is only a safety net.
-
Answer a timeout with
204 No Content, not an error and not an empty200. It is cheap, unambiguous and cannot be mistaken for a proxy error page. The client treats204as "nothing new, ask again with the same cursor". -
Use a cursor, not a timestamp. The client sends the last sequence number it processed (
since=42), the server returns events after it, plus the new cursor. Advance the cursor only after the batch was parsed and applied, so a failure repeats the request instead of losing events. An opaque cursor (a Redis Stream id, a database LSN) lets the server change its storage without changing clients. -
Answer immediately when events are already waiting. This is what makes long polling catch up after a gap, without a separate replay mechanism.
-
Return
410 Gonewhen the cursor is older than the retained window, the equivalent of the SSEresetevent. The client reloads state from the history API and restarts from the new cursor. Return400for a cursor from the future. -
Deliver at least once and de-duplicate by sequence number. If the response is lost after the server sent it, the client asks with the old cursor and receives the same events again.
-
Send
Cache-Control: no-storeso no cache returns a stale poll response, as RFC 6202 section 5.6 advises. -
Cap work per response: batch at most 100 events, so a client that was offline for an hour does not trigger a huge reply.
Avoiding the thundering herd. When many clients are parked and the server restarts, a deploy happens or a proxy cuts all connections at once, every client reconnects in the same instant and sends a burst of requests. Mitigations:
-
Add random jitter to every re-request after a
204(0 to 500 ms in the example) and exponential backoff with jitter after errors (1 s, 2 s, 4 s, up to 30 s). Never retry in a tight loop on5xx. -
Randomise the server hold time by a few seconds so the timeouts of different clients do not stay phase-aligned.
-
On a mass reconnect, answer with
503and aRetry-Afterheader and let clients spread out. -
Wake parked requests per room, not globally, so a message in one room does not make every poller run its filter.
-
Limit parked requests per user and in total, with the proxy or the application, to bound memory and file descriptors.
-
In a multi-process deployment, keep the cursor in a shared store so any instance can answer, and let each process subscribe to the backplane to wake its own parked requests.
The server below keeps a list of parked requests, wakes the matching ones when a message is posted, answers 204 after
the hold, 410 for a cursor that fell off the window and 400 for a future cursor. A req.on('close') handler removes
the parked request when the client leaves, which would otherwise leak one entry per abandoned poll.
// Cursor-based long polling for Chatterbox rooms, plain node:http.
import { createServer } from 'node:http';
const PORT = Number(process.env.PORT ?? 8081);
const MAX_HOLD_MS = Number(process.env.MAX_HOLD_MS ?? 25000); // under a 30-60 s proxy idle timeout
const KEEP = 1000; // events kept for cursors
const events = []; // { seq, room, from, text }
let seq = 0;
const waiters = new Set(); // { since, room, send, timer }
function after(since, room) {
return events.filter((e) => e.seq > since && (!room || e.room === room)).slice(0, 100);
}
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'POST' && url.pathname === '/messages') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
try {
const { room, from, text } = JSON.parse(body);
const ev = { seq: ++seq, room, from, text };
events.push(ev);
if (events.length > KEEP) events.shift();
for (const w of [...waiters]) { // wake every parked request whose room matches
if (!w.room || w.room === room) w.send();
}
res.writeHead(202, { 'Content-Type': 'application/json' }).end(JSON.stringify({ seq: ev.seq }));
} catch {
res.writeHead(400).end();
}
});
return;
}
if (req.method === 'GET' && url.pathname === '/poll') {
const since = Number(url.searchParams.get('since') ?? 0);
const room = url.searchParams.get('room');
const wait = Math.min(Number(url.searchParams.get('wait') ?? MAX_HOLD_MS), MAX_HOLD_MS);
const headers = { 'Cache-Control': 'no-store' };
if (!Number.isInteger(since) || since < 0 || since > seq) { res.writeHead(400, headers).end(); return; }
if (events.length && since < events[0].seq - 1) {
// the cursor fell off the retained window: the client must resync from the history API
res.writeHead(410, headers).end();
return;
}
const reply = () => {
const batch = after(since, room);
if (batch.length === 0) return false;
res.writeHead(200, { ...headers, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ events: batch, cursor: batch[batch.length - 1].seq }));
return true;
};
if (reply()) return; // something is already waiting: answer immediately
const waiter = {
room,
send: () => { if (reply()) cleanup(); },
timer: setTimeout(() => { cleanup(); res.writeHead(204, headers).end(); }, wait),
};
const cleanup = () => { clearTimeout(waiter.timer); waiters.delete(waiter); };
waiters.add(waiter);
req.on('close', cleanup); // the client went away while parked
return;
}
res.writeHead(404).end();
});
server.listen(PORT, () => console.log(`Chatterbox long-poll on http://localhost:${PORT}`));
// Long-poll client with cursor, jitter and exponential backoff. Node 18+ or any modern browser.
const BASE = process.env.POLL_URL ?? 'http://localhost:8081';
async function resync() {
// Real code would call the history API and return the newest seq; here we restart at 0.
return 0;
}
export async function pollLoop({ room, onEvents, signal }) {
let cursor = 0;
let failures = 0;
while (!signal?.aborted) {
try {
const res = await fetch(`${BASE}/poll?room=${encodeURIComponent(room)}&since=${cursor}&wait=25000`, {
cache: 'no-store',
// slightly longer than the server hold, so the server normally answers first
signal: AbortSignal.any([AbortSignal.timeout(35000), ...(signal ? [signal] : [])]),
});
if (res.status === 204) { // timeout, nothing new: ask again after a small random pause
failures = 0;
await sleep(Math.random() * 500);
continue;
}
if (res.status === 410) { cursor = await resync(); continue; }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { events, cursor: next } = await res.json();
cursor = next; // advance only after the batch was parsed
failures = 0;
onEvents(events);
} catch (err) {
if (signal?.aborted) return;
failures++;
const base = Math.min(1000 * 2 ** (failures - 1), 30000);
await sleep(base * (0.5 + Math.random() / 2)); // exponential backoff with jitter
}
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
if (import.meta.url === `file://${process.argv[1]}`) {
const ac = new AbortController();
let n = 0;
await pollLoop({
room: 'general',
signal: ac.signal,
onEvents: (events) => {
for (const e of events) console.log(`seq=${e.seq} ${e.from}: ${e.text}`);
n += events.length;
if (n >= Number(process.env.MAX_EVENTS ?? Infinity)) ac.abort();
},
});
}
Run with a 3 second hold to see every path (MAX_HOLD_MS=3000 node poll-server.mjs). Real results:
$ curl -s -o /dev/null -w '%{http_code} %{time_total}\n' 'localhost:8081/poll?since=0&room=general'
204 3.003614
$ curl -s -X POST -d '{"room":"general","from":"ana","text":"one"}' localhost:8081/messages
{"seq":1}
$ curl -s 'localhost:8081/poll?since=0&room=general'
{"events":[{"seq":1,"room":"general","from":"ana","text":"one"}],"cursor":1}
$ curl -s -w ' %{http_code} %{time_total}\n' 'localhost:8081/poll?since=1&room=general' # a post arrives 1 s later
{"events":[{"seq":2,"room":"general","from":"bo","text":"two"}],"cursor":2} 200 1.011529
$ curl -s -o /dev/null -w '%{http_code}\n' 'localhost:8081/poll?since=99'
400
$ node poll-client.mjs # a message is posted after the first 204
seq=1 ana: one
seq=2 bo: two
seq=3 cy: three
The first call waited the full hold and returned 204; the call with a cursor of 1 returned as soon as the post arrived,
after about one second; the client kept polling across the 204 and then received the third message.
When SSE plus HTTP POST beats WebSocket
WebSocket is the right default for dense two-way traffic. For many real applications, including most of Chatterbox’s
notification side, a split model is simpler and sometimes better: server-to-client over SSE, client-to-server over
ordinary fetch POST.
-
Notifications and feeds. Upstream traffic is rare or absent: badges, order status, deploy progress, live dashboards, AI token streaming. SSE gives resume and reconnect for free.
-
Chat over HTTP/2. The client posts a message with an idempotency key (the Chatterbox envelope
id) and gets a normal HTTP status and response body as the acknowledgement, and the stream delivers the broadcast. EachPOSTis an HTTP/2 stream on the connection that already carries the SSE stream, so there is no extra handshake. Rate limiting, auth, logging, retries and caching headers all use existing HTTP tooling. The server needs no per-connection read loop or ping/pong. -
Restrictive networks. Some corporate proxies, antivirus products and old mobile gateways break the HTTP upgrade that WebSocket needs, but allow plain HTTP responses. SSE needs the proxy to stream; long polling needs nothing special. Transport-fallback libraries (Socket.IO, SignalR, Atmosphere) use exactly this ladder, see Choosing a WebSocket Stack.
-
Operational simplicity. Standard load balancers, authentication middleware, observability and
curldebugging apply. Authentication is a normal cookie or, withfetchstreaming, a header.
Choose WebSocket (or WebTransport, see WebTransport and Media over QUIC)
when you need binary frames, per-message latency in the low tens of milliseconds in both directions (Pixel Arena input and
snapshots), very high upstream rates, server-side backpressure on the receive path, or a single ordered channel in which
the reply to a command must be interleaved exactly with broadcasts. SSE is text only, so binary payloads need base64;
its POST and stream are separate, so ordering between an acknowledgement and the broadcast it caused is not
guaranteed; and each upstream message pays HTTP header cost (small under HTTP/2 with HPACK header compression).
Chat Architecture discusses which transport to give each Chatterbox
feature, and the full duplex protocol is in The WebSocket Protocol.
Security
-
Do not trust the cookie alone. A cookie authenticates the user, not the page. For cross-origin
EventSourcewithwithCredentials, reply with a fixed allow-listAccess-Control-Allow-Originand checkOrigin, as for WebSockets. -
Keep
GET /eventsfree of side effects. Browsers reconnect it automatically and any page can trigger it. -
Authorise per stream and per event. Check room membership when the stream opens and when the membership changes; a user removed from a room must stop receiving its events on the already open stream.
-
Escape on render.
datais text. UsetextContent, as the page does, or sanitise before inserting HTML. -
Bound resources. Each stream is an open socket; set per-user and global limits, maximum event size and replay window, and close streams that stay silent past the keep-alive deadline. RFC 6202 section 6 points out that many held connections ease denial-of-service attacks.
-
Never put long-lived secrets in the URL of
EventSource,fetchor a long poll; URLs are logged. Use a ticket or a cookie. See Security.
Common errors and pitfalls
-
Nothing arrives until the connection closes: a proxy or the framework buffers the response. Send
X-Accel-Buffering: no, disable proxy buffering and compression, call flush after each write. -
onmessagenever fires forevent: something: named events needaddEventListener('something', handler). -
The stream stops forever after a deploy or a login redirect: the server answered with a status other than 200 or a non-event-stream content type, so the state is
CLOSED. Recreate theEventSourcein theerrorhandler whenreadyStateisCLOSED. -
Stream drops every 60 seconds: an idle timeout in a proxy or balancer; send comment keep-alives more often than it.
-
Events are replayed or lost on reconnect: the server ignores
Last-Event-ID, uses non-monotonic ids, or resets its counter on restart. -
Page becomes unresponsive on HTTP/1.1 with several tabs: the six-connection limit; use HTTP/2 or share one stream.
-
CORSerrors in the console,errorevents with no detail: checkAccess-Control-Allow-Originand, with credentials,Access-Control-Allow-Credentials. -
Server keeps working after the client left: it did not listen for the request’s
close, so intervals and subscriptions leak. -
Multi-line text breaks the format: a payload with a newline must be split into several
data:lines, or encoded as JSON (newlines become\nescapes), as the example does. -
Long poll loops hammer the server: a handler that returns immediately on error without backoff turns an outage into a request storm.
Versions and specification history
-
The WHATWG HTML Living Standard defines
EventSource, the event stream format and the parsing algorithm. It is a living standard, so there are no numbered versions. MDN listsEventSourceas Baseline widely available since January 2020. -
EventSourceis not available in all runtimes: Node.js ships anEventSourceimplementation behind the--experimental-eventsourceflag (it printed an experimental warning on 24.21.0), so server-side tests should use the fetch client above or a library. -
RFC 6202 (April 2011, Informational) records the long polling and streaming practices of the time, written when browsers allowed two connections per host. Its timing numbers are advice, not requirements; use current proxy defaults.
-
HTTP/2 is RFC 9113, and WebSocket over HTTP/2 is RFC 8441, see The WebSocket Protocol. Both share the multiplexing benefit that removes the SSE connection limit.
-
Where to go next: Real-Time Web Overview compares all transports on one timeline, and The Browser WebSocket API is the duplex counterpart of this page.