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-ID and 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.

What the reconnect gap is actually made of Timeline breaking a reconnect gap into the retry wait, connection setup, cold start and subscription, with the total that the buffer must cover. What the reconnect gap is actually made of time β†’ Disconnect cursor stored 0 ms retry elapsed the part people size for +3 000 TCP + TLS setup cost +5 000 Cold start serverless paths +8 000 Subscribed replay begins here +9 000
Sizing the buffer for the retry interval alone covers a quarter of the real gap, which is why resyncs appear on exactly the reconnects that matter.

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

From cursor to replayed events Path of a reconnect: the cursor arrives in the Last-Event-ID header, is looked up in the buffer index, and either replays the tail or triggers a resync. From cursor to replayed events replay is inclusive at the boundary, so clients still deduplicate by id Last-Event-ID sent by the browser Index lookup O(1) by id Replay tail or event: resync Attach live no gap automatic found / expired then subscribe
The lookup has three outcomes, and the third β€” cursor aged out β€” is the one that must never be silently treated as "start from now".

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

Buffer memory by retention window Bar chart of replay-buffer memory for retention windows from ten seconds to five minutes at fifty events per second. Buffer memory by retention window 50 events/s Γ— 300-byte events, per topic 10 s 0.15 MB 30 s 0.45 MB 60 s 0.9 MB 5 min 4.5 MB resident buffer size per topic
Retention is bounded by what a reconnecting client can plausibly have missed. Past a few minutes the buffer stops serving resume and starts being an event store.
# 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.