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

fetch or XMLHttpRequest

EventSource (or fetch streaming)

WebSocket

Wire

one complete HTTP response per batch of events

one HTTP response that never ends, text/event-stream

HTTP upgrade, then frames

Resume after a drop

cursor you design (since)

built in: id: field and Last-Event-ID header

you design it (see the Chatterbox resume in the protocol)

Reconnect

your loop

automatic in EventSource, yours with fetch

yours

Payload

any (JSON, binary)

UTF-8 text only

text or binary

Custom request headers

yes (fetch)

no with EventSource, yes with fetch

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

CONNECTING

0

The initial state, and also the state while the browser waits to reconnect after a drop.

OPEN

1

The response arrived with status 200 and Content-Type: text/event-stream, and events are being dispatched.

CLOSED

2

A fatal failure happened, or close() was called. The browser will not reconnect.

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:

  • open fires when the response is accepted.

  • message fires for every event that has no event: field. The MessageEvent carries data (a string, never parsed for you), origin and lastEventId.

  • Named events: a stream block with event: mention fires an event of type mention, which only addEventListener('mention', handler) receives. Setting source.onmessage does not catch it, a frequent surprise.

  • error fires on every failure, including the transient ones that the browser will recover from. Read readyState to tell them apart: CONNECTING means a retry is scheduled, CLOSED means 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

data

Appends the value, followed by a line feed, to the event’s data buffer. Several data: lines make a multi-line payload. On dispatch the final line feed is removed.

event

Sets the event type. Without it the type is message.

id

Sets the stream’s last event ID. It sticks: later events without an id inherit it. A value containing a NUL byte is ignored. id: with an empty value resets it to the empty string.

retry

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\n and forgets the second \n delivers 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:x and data: x are the same, and data: x keeps one space. A line without a colon is a field name with an empty value, so a bare data line 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-ID value 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
sequenceDiagram participant B as Browser EventSource participant S as Chatterbox feed B->>S: GET /events S-->>B: 200 text/event-stream, retry 3000 S-->>B: id 41 message S-->>B: id 42 mention Note over B,S: network drops, id 43 and 44 are produced B->>B: readyState CONNECTING, wait 3 s B->>S: GET /events with Last-Event-ID 42 S-->>B: replay id 43, id 44 S-->>B: live events continue

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:

  1. 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.

  2. Keep the last few hundred events in memory, or read them from the database table that already stores them.

  3. When the requested id is older than the buffer, send an application event such as reset that tells the client to reload its state from the history endpoint, then continue live. Replaying a partial tail would hide the gap.

  4. 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.

  5. Browsers cannot send Last-Event-ID on the first connection of a freshly loaded page, because the id lives in the EventSource instance. A page that stores the last id (for example in sessionStorage) can pass it as a query parameter, and the example server accepts both forms.

  6. 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.

sse-server.mjs
// 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:

index.html
<!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>

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

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://localhost server 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 rooms query, instead of one EventSource per room.

  • To share one stream among tabs, open the EventSource in a SharedWorker, or elect a leader tab with the Web Locks API and relay events with BroadcastChannel. 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.

nginx location for the feed
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: true and the CORS credentials headers). It is the simplest and works with HttpOnly. Because the cookie is sent automatically, the endpoint must not change state on a GET, and for cross-origin use it must answer CORS only for an allow-list of origins: any page can open an EventSource to 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 authenticated POST and 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.

  • fetch streaming, 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-fetch-client.mjs
// 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.

  1. 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.

  2. Answer a timeout with 204 No Content, not an error and not an empty 200. It is cheap, unambiguous and cannot be mistaken for a proxy error page. The client treats 204 as "nothing new, ask again with the same cursor".

  3. 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.

  4. Answer immediately when events are already waiting. This is what makes long polling catch up after a gap, without a separate replay mechanism.

  5. Return 410 Gone when the cursor is older than the retained window, the equivalent of the SSE reset event. The client reloads state from the history API and restarts from the new cursor. Return 400 for a cursor from the future.

  6. 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.

  7. Send Cache-Control: no-store so no cache returns a stale poll response, as RFC 6202 section 5.6 advises.

  8. 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 on 5xx.

  • 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 503 and a Retry-After header 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.

poll-server.mjs
// 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}`));
poll-client.mjs
// 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. Each POST is 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 curl debugging apply. Authentication is a normal cookie or, with fetch streaming, 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 EventSource with withCredentials, reply with a fixed allow-list Access-Control-Allow-Origin and check Origin, as for WebSockets.

  • Keep GET /events free 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. data is text. Use textContent, 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, fetch or 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.

  • onmessage never fires for event: something: named events need addEventListener('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 the EventSource in the error handler when readyState is CLOSED.

  • 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.

  • CORS errors in the console, error events with no detail: check Access-Control-Allow-Origin and, 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 \n escapes), 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 lists EventSource as Baseline widely available since January 2020.

  • EventSource is not available in all runtimes: Node.js ships an EventSource implementation behind the --experimental-eventsource flag (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.