Implementing a Replay Buffer for Last-Event-ID Permalink to this section
Part of Event ID & Retry Mechanism Design, under SSE Protocol Fundamentals & Architecture.
The browser does its half of resume for free: it remembers the last id and sends it back as Last-Event-ID. The server half β turning that cursor into the events the client missed β is the part you have to build, and its absence is invisible until users start seeing holes in their data.
Symptom & Developer Intent Permalink to this section
- After a brief disconnect the UI is missing updates that were published during the gap.
- Totals and counters drift away from the serverβs truth over a long session.
- The server receives
Last-Event-IDand ignores it, starting every reconnect from βnowβ. - Replay works in development and misses events in production, where reconnects take longer.
- Memory grows steadily on a server that buffers events but never expires them.
The intent is a bounded buffer that covers the real reconnect gap and tells the client honestly when it cannot.
Root Cause Analysis Permalink to this section
EventSource sends Last-Event-ID on every automatic reconnect. A handler that ignores it resumes the live feed from the moment of reconnection, so everything published during the gap is lost silently β no error, no gap marker, just a client whose state diverges from the serverβs.
The size of that gap is routinely underestimated. It is not the retry interval alone: it is the retry delay plus DNS, TCP and TLS setup, plus request handling, plus any cold start. Two to five seconds is typical, and on mobile networks after a tunnel or a lift, much longer. A buffer sized for the retry interval alone will miss events on exactly the reconnects that matter.
The third failure is an unbounded buffer. Retaining every event for every topic turns a resume mechanism into an in-memory event store that grows until the process dies.
Finally, a cursor can be older than anything retained β the client was away too long. Starting from now in that case reintroduces the silent gap; the honest response is to tell the client to resynchronise from a snapshot.
Step-by-Step Resolution Permalink to this section
Step 1 β Emit monotonic ids Permalink to this section
Replay requires an ordering the server can seek to. See Generating Monotonic Event IDs for SSE for multi-node id design.
let seq = 0;
const nextId = () => `${Date.now()}-${++seq}`; // sortable, unique within the process
Step 2 β Size the buffer from the real gap Permalink to this section
// Retain events for the worst realistic reconnect, with headroom.
const RETRY_S = 3; // the retry: value you send
const SETUP_S = 2; // DNS + TCP + TLS + handler
const COLD_START_S = 3; // serverless or scaled-to-zero paths
const HEADROOM = 4; // mobile networks are worse than your model
const WINDOW_S = (RETRY_S + SETUP_S + COLD_START_S) * HEADROOM; // β32 s
const EVENTS_PER_S = 50;
const CAPACITY = WINDOW_S * EVENTS_PER_S; // β1600 events
Step 3 β Implement a bounded ring buffer with cursor lookup Permalink to this section
// replay-buffer.js β bounded by BOTH count and age.
class ReplayBuffer {
constructor({ capacity = 1600, maxAgeMs = 60_000 } = {}) {
this.capacity = capacity;
this.maxAgeMs = maxAgeMs;
this.events = []; // ordered oldest β newest
this.index = new Map(); // id β position, for O(1) cursor lookup
}
push(event) {
this.events.push(event);
this.index.set(event.id, this.events.length - 1);
this.evict();
}
evict() {
const cutoff = Date.now() - this.maxAgeMs;
let drop = 0;
while (drop < this.events.length &&
(this.events.length - drop > this.capacity || this.events[drop].at < cutoff)) {
drop++;
}
if (!drop) return;
for (let i = 0; i < drop; i++) this.index.delete(this.events[i].id);
this.events = this.events.slice(drop);
// Positions shifted: rebuild the index for what remains.
this.index.clear();
this.events.forEach((e, i) => this.index.set(e.id, i));
}
// null = cursor unknown (too old or never existed) β the client must resync.
since(cursor) {
if (!cursor) return []; // fresh connection: live only
const pos = this.index.get(cursor);
if (pos === undefined) return null;
return this.events.slice(pos + 1);
}
}
Step 4 β Replay on connect, then attach to live Permalink to this section
app.get('/events', (req, res) => {
const topic = req.query.topic ?? 'default';
const cursor = req.headers['last-event-id'] ?? req.query.lastEventId ?? null;
writeHeaders(res);
res.write('retry: 3000\n\n');
const missed = buffers.get(topic).since(cursor);
if (missed === null) {
// Honest failure: the cursor aged out. Tell the client to refetch a snapshot.
res.write('event: resync\ndata: {"reason":"cursor_expired"}\n\n');
} else {
for (const e of missed) {
res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${e.json}\n\n`);
}
}
const send = (e) => res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${e.json}\n\n`);
subscribe(topic, send);
req.on('close', () => unsubscribe(topic, send));
});
Step 5 β Share the buffer across nodes with Redis Streams Permalink to this section
An in-process buffer only replays events that this node saw. Behind a load balancer the reconnect lands anywhere, so the buffer must be shared.
// Publish: XADD gives you the id and the retention policy in one call.
await redis.xadd('events:prices', 'MAXLEN', '~', 5000, '*',
'type', 'price', 'data', JSON.stringify(payload));
// Replay: read everything after the client's cursor.
async function replayFrom(cursor) {
const start = cursor ?? '0-0';
const entries = await redis.xrange('events:prices', `(${start}`, '+', 'COUNT', 5000);
return entries.map(([id, fields]) => ({ id, ...Object.fromEntries(chunk(fields, 2)) }));
}
Redis Streams give ids, ordering, range queries and trimming as one primitive, which is why they suit this better than the pub/sub channel that carries the live feed.
Step 6 β Handle resync on the client Permalink to this section
es.addEventListener('resync', async () => {
const snapshot = await (await fetch('/api/snapshot')).json();
store.replaceAll(snapshot); // authoritative state, then resume live
});
Validation & Monitoring Permalink to this section
# 1. Replay covers a short gap.
curl -N -s -H 'Last-Event-ID: 1717000000000-41' https://api.example.com/events | head -5
# Expect 42, 43, β¦ with no gap and no resync.
# 2. An ancient cursor produces a resync rather than silence.
curl -N -s -H 'Last-Event-ID: 1' https://api.example.com/events | head -2
# event: resync
# 3. The buffer stays bounded under sustained load.
curl -s localhost:9090/metrics | grep sse_replay_buffer_events
Two metrics make the sizing self-correcting. Replay length tells you how much clients actually miss; resync rate tells you when the buffer is too small.
metrics.observe('sse_replay_events', missed.length);
if (missed === null) metrics.inc('sse_resync_total');
A resync rate above roughly one per thousand reconnects means the window does not cover real gaps β raise capacity or maxAgeMs rather than asking clients to tolerate it. Conversely, a p99 replay length far below capacity means memory is being held for nothing.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
How large should the replay window be?
Long enough to cover the worst realistic reconnect, not the average one. Add the retry delay, connection setup, and any cold start, then multiply by three or four for mobile networks. Then let the data correct you: a non-zero resync rate means the window is too small, and a p99 replay length far below capacity means it is larger than it needs to be.
Should the buffer be per topic or global?
Per topic. A global buffer forces every reconnecting client to scan events for topics it does not subscribe to, and one high-volume topic evicts everything else's history. Per-topic buffers are also what let you size retention differently for a chatty telemetry feed and a quiet notification channel.
What should happen when the cursor is older than the buffer?
Send an explicit resync event. Resuming from "now" leaves a hole the client cannot detect, and refusing the connection loses the user entirely. Telling the client to refetch a snapshot and then resume is the only option that ends with correct state.
Do I need a replay buffer if I already use Redis pub/sub?
Yes β pub/sub is fire-and-forget and retains nothing, so a subscriber that reconnects fifty milliseconds later has already missed whatever was published in that window. Redis Streams solve both problems at once with ids, ordering and trimming, which is why they are the usual pairing for the replay side.