Most real-time dashboard demos stop at the happy path: new EventSource('/events'), a chart, done. Then a laptop switches Wi-Fi networks, the stream drops for a second, and the chart quietly skips a few dozen points. Nobody notices until the dashboard totals stop matching the database.

Server-sent events (SSE) already include most of what you need to avoid that: event ids, automatic reconnection, and a Last-Event-ID header the browser sends when it reconnects. The browser side comes for free. The server side is up to you, and that is where the gaps usually are.

In this post we build a small real-time analytics dashboard on plain Node.js (no framework, no npm runtime dependencies) and make it hold up under real failures:

  1. Write the text/event-stream format correctly (multi-line data, ids, retry).
  2. Keep the stream alive through proxies with heartbeat comments.
  3. Replay missed events after a reconnect, using Last-Event-ID and a bounded event log.
  4. Stop one slow client from eating the server’s memory (backpressure).
  5. Render at most once per animation frame, however fast events arrive.
  6. Prove all of it with node:test for the server and Playwright for a real Chromium.

Then we measure the six-connection limit on HTTP/1.1 against HTTP/2, and look at when WebSockets are the better tool.

Versions used: Node.js 22.23.4, Playwright 1.63.0 (Chromium 153), no other packages. Every output block below is copied from an actual run in a 2-vCPU Linux sandbox; timings will differ on your machine.

How server-sent events work

An SSE stream is a long-lived HTTP response with Content-Type: text/event-stream. The body is UTF-8 text, and the spec gives no way to choose another encoding. Events are separated by a blank line, and each line inside an event is a field: value pair:

When the connection drops, the browser waits for the reconnection delay and sends the same GET again with a Last-Event-ID header holding the last id it saw. The spec leaves the default delay to the browser (“probably in the region of a few seconds”). A server can tell the client to stop reconnecting by answering 204 No Content. And if the reply is not a 200 with text/event-stream, the browser fails the connection and does not try again. Keep that last rule in mind; it comes back in Step 6.

SSE connection lifecycle: events with ids, heartbeat, drop, reconnect with Last-Event-ID, replay

Server-sent events vs WebSockets

SSE is one-way: server to browser. The browser sends data back with ordinary fetch requests. For a dashboard that is usually what you want:

Choose WebSockets when the client sends a steady stream of its own (collaborative editing, games, chat with typing indicators) or when you need binary frames. For a feed of metrics, alerts or order updates, SSE does the job with less code, and the reconnect logic is already in the browser.

The architecture

Five small files do the work: src/sse.js (wire format, bounded writes), src/event-log.js (replay history), src/app.js (endpoint, heartbeat, fan-out, fake metrics producer), src/server.js (HTTP/1.1 or HTTP/2 listener) and public/dashboard.js (the client).

Step 1. Write the event stream format correctly

The classic bug is writing data: ${JSON.stringify(x)}\n\n and later sending a string that contains a newline. Everything after the newline becomes a new line in the stream: a broken field at best, an injected id: or event: line at worst. Each line of the payload needs its own data: prefix, and ids and event names must be single-line:
// Wire format helpers for text/event-stream (WHATWG HTML, "Server-sent events").
const LINE_BREAK = /\r\n|\r|\n/;
export function formatEvent({ id, event, data, retry }) {
let out = '';
if (event !== undefined) {
if (LINE_BREAK.test(event)) throw new Error('event name must be a single line');
out +=
event: ${event}\n;
}
if (id !== undefined) {
const s = String(id);
// A newline would end the field early; the spec ignores ids that contain NUL.
if (LINE_BREAK.test(s) || s.includes('\0')) throw new Error('invalid event id');
out +=
id: ${s}\n;
}
if (retry !== undefined) out +=
retry: ${Math.trunc(retry)}\n;
const payload = typeof data === 'string' ? data : JSON.stringify(data);
// Every line of the payload needs its own "data:" prefix.
for (const line of payload.split(LINE_BREAK)) out +=
data: ${line}\n;
return out + '\n'; // a blank line dispatches the event
}
export const comment = (text = '') =>
: ${text}\n\n;
// One connected browser. Writes are bounded: a client that cannot keep up
// is disconnected instead of growing an unbounded buffer in our process.
export class SseClient {
constructor(req, res, { maxBufferedBytes }) {
this.req = req;
this.res = res;
this.maxBufferedBytes = maxBufferedBytes;
this.closed = false;
this.droppedReason = null;
res.on('close', () => { this.closed = true; });
}
get buffered() {
// http1: ServerResponse; http2 compat: the underlying Http2Stream
return this.res.writableLength ?? this.res.stream?.writableLength ?? 0;
}
send(chunk) {
if (this.closed) return false;
if (this.buffered + Buffer.byteLength(chunk) > this.maxBufferedBytes) {
this.close('slow-consumer');
return false;
}
this.res.write(chunk);
return true;
}
close(reason = 'server') {
if (this.closed) return;
this.closed = true;
this.droppedReason = reason;
this.res.destroy(); // free the buffered bytes now; the browser will reconnect
}
}
SseClient is the backpressure piece; we come back to it in Step 4. The first unit test pins down the format:
test('formatEvent prefixes every line of a multi-line payload', () => {
assert.equal(
formatEvent({ id: 'a-1', event: 'metric', data: 'line one\nline two' }),
'event: metric\nid: a-1\ndata: line one\ndata: line two\n\n',
);
assert.throws(() => formatEvent({ id: 'a\n1', data: 'x' }), /invalid event id/);
});

Step 2. Open the stream: headers, retry and heartbeat

The endpoint sets four headers. text/event-stream is required by the spec. Cache-Control: no-cache, no-transform keeps caches and compressing proxies away from the response. X-Accel-Buffering: no tells nginx not to buffer this response. Without that, nginx (where proxy_buffering is on by default) can sit on your events until its buffer fills. Connection: keep-alive is only sent on HTTP/1.1, because HTTP/2 has no connection header (Node’s HTTP/2 layer drops it with an UnsupportedWarning).
function openStream(req, res, url) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no', // nginx: do not buffer this response
...(req.httpVersionMajor === 1 ? { Connection: 'keep-alive' } : {}),
});
const client = new SseClient(req, res, { maxBufferedBytes });
counters.connects++;
// EventSource sends Last-Event-ID itself on auto-reconnect; the query
// param covers manual reconnects (new EventSource(...) starts empty).
const lastId = req.headers['last-event-id'] ?? url.searchParams.get('lastEventId');
let head =
retry: ${retryMs}\n\n;
const missed = lastId ? log.since(lastId) : null;
if (missed) {
for (const item of missed) head += formatEvent(item);
counters.replayed += missed.length;
} else {
const points = log.tail(snapshotSize).map((i) => i.data);
const id = log.seq ?
${log.epoch}-${log.seq}: undefined;
head += formatEvent({ id, event: 'snapshot', data: { points } });
counters.snapshots++;
}
// Write history + register in the same tick: nothing published in between is lost.
clients.add(client);
client.send(head);
res.on('close', () => {
clients.delete(client);
if (client.droppedReason === 'slow-consumer') counters.slowDrops++;
});
}

Two details matter here.

First, the history and the registration happen in the same synchronous block. clients.add(client) runs before control returns to the event loop, so no publish() can slip in between “send the history” and “start receiving live events”. If you split them with an await (say, reading history from Redis), you need to buffer live events during the read, or you will lose some.

Second, heartbeats. The spec notes that legacy proxies may drop idle HTTP connections and suggests a comment line every 15 seconds or so. nginx’s proxy_read_timeout defaults to 60 seconds, so a dashboard with quiet periods would otherwise be cut every minute. The heartbeat is one timer for all clients:
const heartbeat = setInterval(() => {
const ping = comment('ping');
for (const c of clients) c.send(ping);
}, heartbeatMs);
heartbeat.unref();

Step 3. Reconnection with Last-Event-ID and a replay buffer

EventSource reconnects by itself and tells you where it stopped. Your job is to keep enough history to answer “what happened after k3f9-42?” A ring buffer of the last N events is enough for blips of a few seconds. For longer gaps, send a fresh snapshot instead of replaying thousands of events.

There is one more trap. If ids are a plain counter in memory, a server restart resets them to 1. A client that reconnects with Last-Event-ID: 5000 would then get either nothing (5000 is “in the future”) or the wrong events. Prefixing every id with a per-process epoch makes stale ids easy to detect:
// Bounded in-memory history used to replay events after a reconnect.
// Ids look like "<epoch>-<seq>": the epoch changes on every process start,
// so a client holding an id from a previous run gets a snapshot, not a replay.
export class EventLog {
constructor(capacity, epoch = Date.now().toString(36)) {
this.capacity = capacity;
this.epoch = epoch;
this.seq = 0;
this.items = [];
}
append(event, data) {
const item = { seq: ++this.seq, id:
${this.epoch}-${this.seq}, event, data };
this.items.push(item);
if (this.items.length > this.capacity) this.items.shift();
return item;
}
// Events after lastEventId, or null when the gap cannot be filled
// (unknown epoch, malformed id, or history already evicted).
since(lastEventId) {
const m = /^([a-z0-9]+)-(\d+)$/.exec(lastEventId ?? '');
if (!m || m[1] !== this.epoch) return null;
const seq = Number(m[2]);
if (seq > this.seq) return null;
if (seq === this.seq) return [];
const oldest = this.items[0].seq;
if (seq < oldest - 1) return null;
return this.items.slice(seq - oldest + 1);
}
tail(n) {
return this.items.slice(-n);
}
}

since() returns null for anything it cannot serve exactly, and the endpoint answers null with a snapshot event: the last 300 points, tagged with the current id so the next reconnect can replay again. The client treats snapshot as “replace your state”, which keeps the two paths simple.

The server test drops a connection, waits 300 ms while the producer keeps publishing every 5 ms, reconnects with the last id it saw, and requires the exact sequence of missed ids.

Step 4. Backpressure: one slow client must not take the server down

res.write() never refuses data. The Node.js docs say it returns false once the internal buffer reaches highWaterMark, but the chunk is still queued. If you keep writing, Node buffers everything “until maximum memory usage occurs, at which point it will abort unconditionally”. One phone on a bad connection, or a browser tab frozen by the OS, is enough to start that.

For a single producer the usual answer is to pause until 'drain'. A fan-out server cannot pause the producer for one client, so each client gets a byte budget instead. SseClient.send() checks writableLength before every write and destroys the response when it would go over 256 KiB. That is not data loss: the browser reconnects with Last-Event-ID, and it either catches up from the log or receives a snapshot.

To measure it, bench/slow-client.js opens a raw TCP socket, sends the GET, and never reads. It then publishes 40,000 events of about 1 KiB at roughly 2,000 events per second, first with no cap, then with the 256 KiB cap:

Without a cap, the server holds 37.55 MiB for one client that will never read it, and the number grows by about 10 MiB every 10,000 events. With the cap, Node’s queue stops at 0.25 MiB and the client is dropped.

Note when the drop happened: event 3,843, not around event 250. The first ~3.7 MiB went into the kernel’s socket buffers before Node had to queue anything (on this machine net.ipv4.tcp_wmem allows up to 4 MiB per socket, and loopback is generous). So writableLength measures only what is still in Node’s queue. In production, a slow client costs you its kernel buffers plus your cap, so set the cap with that in mind, and keep an eye on the open-connection count as well.

Bytes queued in Node for a client that never reads: unbounded vs 256 KiB cap

Step 5. Render once per frame, not once per event

The browser side has its own version of the same problem. If each message event triggers a redraw, a burst of 1,000 events per second means 1,000 redraws per second, while the screen updates about 60 times per second. Most of that work is never seen.

The client pushes events into a pending array and requests one animation frame. The frame callback moves everything pending onto the chart and draws once:
function seqOf(id) { return Number(id.split('-')[1]); }
function onMetric(e) {
const seq = seqOf(e.lastEventId);
if (seq <= state.lastSeq) { state.dupes++; return; }
if (state.lastSeq && seq !== state.lastSeq + 1) state.gaps++;
state.lastSeq = seq;
state.lastId = e.lastEventId;
state.received++;
pending.push(JSON.parse(e.data));
// rAF is paused in background tabs: keep only what the chart can show.
if (pending.length > WINDOW) pending.splice(0, pending.length - WINDOW);
NAIVE ? flush() : schedule();
}
function onSnapshot(e) {
state.snapshots++;
state.points = JSON.parse(e.data).points;
pending.length = 0;
if (e.lastEventId) { state.lastId = e.lastEventId; state.lastSeq = seqOf(e.lastEventId); }
schedule();
}
function schedule() {
if (frameRequested) return;
frameRequested = true;
requestAnimationFrame(flush);
}
function flush() {
frameRequested = false;
const t0 = performance.now();
state.points.push(...pending.splice(0));
if (state.points.length > WINDOW) state.points.splice(0, state.points.length - WINDOW);
render(state.points);
state.renders++;
state.renderMs += performance.now() - t0;
}

Three details in that code:

  • Gap detection is part of the client. Ids carry a sequence number, so the client counts gaps and dupes. The browser tests assert both are zero. In production you would report them to your metrics.

  • requestAnimationFrame is paused in background tabs in most browsers. Events keep arriving, so pending would grow without limit. The chart only shows 600 points, so the queue is trimmed to 600.

  • The snapshot replaces state, it does not append to it.

bench/render.js sends about 1,000 events per second to the same page in two modes (?render=naive redraws on every event) for 5 seconds, three rounds each, at normal speed and with Chromium’s CPU throttled 4x through the DevTools protocol:

At normal speed, frame batching cuts the main-thread time spent rendering from 340–419 ms to 54–74 ms per 5 seconds, with about 60 renders per second instead of one per event. With 4x throttling the naive mode used between 992 and 3,866 ms of every 5,000 ms. The spread comes from sharing the CPU with other jobs, but the batched mode stayed between 178 and 245 ms in every round. This chart is a single canvas line; with a charting library that rebuilds SVG on each update, the gap gets wider.

Renders and main-thread render time: per-event vs per-frame rendering at 1,000 events/s

Step 6. When EventSource gives up

Automatic reconnection only covers network errors. If the server (or a load balancer in front of it) answers a reconnect with a 503, a 401 or an HTML error page, the spec says the browser must fail the connection and not reconnect. readyState becomes CLOSED, and the dashboard stays frozen on its last value.

So onerror checks the state and reconnects manually when the browser has given up. A new EventSource starts with an empty last-event ID, so the client passes its last id in the query string, and the server reads Last-Event-ID first and the query parameter second:
let es;
function connect(lastEventId) {
const url = lastEventId ?
/events?lastEventId=${encodeURIComponent(lastEventId)}: '/events';
es = new EventSource(url);
es.onopen = () => { state.opens++; setStatus('live'); };
es.addEventListener('metric', onMetric);
es.addEventListener('snapshot', onSnapshot);
es.onerror = () => {
if (es.readyState === EventSource.CONNECTING) { setStatus('reconnecting'); return; }
// CLOSED: the browser gave up (non-200 status or wrong Content-Type).
// A fresh EventSource does not carry Last-Event-ID, so pass it ourselves.
setStatus('offline');
es.close();
setTimeout(() => connect(state.lastId), 3000);
};
}
connect();

Step 7. Test the browser with Playwright: drop the connection, lose nothing

Unit tests prove the server replays correctly. They do not prove that a real EventSource sends the header, waits the retry delay, and that the client code puts events back together without gaps. For that, the server has a debug-only /debug/drop route that destroys every open stream, which is what a network cut looks like from the server side.

The test records the Last-Event-ID header on each /events request, waits for 50 events, cuts the connection, and then waits until the page has caught up with everything the server published.

The second browser test arms /debug/fail?n=1 so that the automatic reconnect gets a 503, and checks that the manual reconnect sends ?lastEventId=. The third streams about 1,000 events per second and asserts there are at least 5 times fewer renders than events. The full run, server and browser tests together:

The numbers from the first test match the design: the browser sent no id on the first request and the last id it had seen on the second one. It reconnected after 1,077 ms (our retry: 1000 plus connection setup), the server replayed 49 events published while the tab was offline, and the client counted zero gaps and zero duplicates. In the 503 test you can see the whole sequence in the response log: 200, then the 503 that closed the EventSource, then the manual 200 ?lastEventId=....

Server-sent events max connections: HTTP/1.1 vs HTTP/2

Browsers limit how many HTTP/1.1 connections they open to one host, and every open EventSource holds one of them for as long as it lives. MDN puts the limit at 6 per browser and domain, counted across all tabs, and notes that Chrome and Firefox have marked the issue “Won’t fix”. Over HTTP/2, all requests share one connection as separate streams. The server announces the stream limit in SETTINGS_MAX_CONCURRENT_STREAMS, which RFC 9113 recommends be no smaller than 100.

bench/conn-limit.js opens 8 EventSource connections from one page, waits 2 seconds, then tries an ordinary fetch('/stats'), once over HTTP/1.1 and once over HTTP/2 with a self-signed certificate (no browser supports unencrypted HTTP/2):

On HTTP/1.1, two streams never opened, and the plain API call behind them timed out. That is the real cost of the limit: one user with six dashboard tabs open finds that every other request to your API from that browser hangs too. Over HTTP/2 all eight streams opened and the fetch returned in milliseconds.

Eight EventSource streams over HTTP/1.1 (6 open, fetch times out) vs HTTP/2 (8 open, fetch in 13 ms)

If you cannot serve HTTP/2 to the browser, the spec suggests a few workarounds: separate hostnames per stream, letting each page turn its stream off, or sharing one connection across tabs through a shared worker. Another option is to use a single stream per tab that carries every event type.

Running it in production

What changes once the dashboard leaves localhost:

  • Proxies and load balancers. Turn off response buffering (X-Accel-Buffering: no or proxy_buffering off in nginx) and set the read timeout higher than your heartbeat interval. Don’t let a proxy gzip the stream unless it flushes after each event.

  • More than one server. An in-memory EventLog only works when the client reconnects to the same process. With several instances, each needs the same ids and the same history, so publish through a shared log (Redis Streams, Kafka, a database table with a sequence) and use its offsets as event ids.

  • Authentication. EventSource cannot set an Authorization header. Use a cookie session (withCredentials: true for cross-origin requests) or a short-lived token in the query string, and keep tokens out of access logs. Re-check authorization on every reconnect: each reconnect is a new request.

  • Deploys. When a process restarts, every client reconnects at about the same moment. A modest retry with some random jitter, which you can send per connection, spreads that load.

  • Observability. Export open connections, slow-consumer drops, replays and snapshots. The sample app already counts them on /stats.

Checklist

☐ data: prefix on every payload line; ids and event names without newlines
☐ Content-Type: text/event-stream, Cache-Control: no-cache, proxy buffering off
☐ Heartbeat comment more often than the shortest idle timeout on the path (15 s is a safe default)
☐ retry sent explicitly
☐ Event ids that survive restarts (epoch or a durable offset) and a bounded replay log
☐ Snapshot fallback when the gap cannot be replayed
☐ Per-client byte budget; slow clients are disconnected, not buffered forever
☐ Client renders at most once per animation frame and caps its queue for hidden tabs
☐ Manual reconnect when readyState === CLOSED
☐ HTTP/2 in front of the stream, or one stream per tab at most
☐ Browser test that cuts the connection and checks for zero gaps

Wrapping up

Server-sent events make a real-time dashboard simple to start and easy to get almost right. The reconnect is free, but lossless resume needs ids that mean something, a replay log and a snapshot fallback. The server needs a byte budget per client, and the browser needs to render per frame, not per event. The two Playwright tests that cut the connection and check for zero gaps are the part I would keep even if everything else changed.

What do you run your live dashboards on: SSE, WebSockets, or polling that turned out to be good enough? And how do you handle resume across several server instances?