Deduplicating Events on the Client with Event IDs Permalink to this section
Part of Idempotent Event ID Generation, under Backend Stream Generation & Connection Management.
At-least-once delivery is the realistic guarantee for a resumable Server-Sent Events stream. A replay after reconnect may resend the event the client received just before the drop; a server that subscribes before replaying may deliver an event through both paths; a user with two tabs sharing state may see the same event arrive twice. The server should minimise duplicates, but the client must tolerate them. This guide makes client state idempotent, so a duplicate is a no-op rather than a double-counted order, a repeated toast or a list item shown twice.
Symptom & Developer Intent Permalink to this section
- After a flaky connection, the same notification appears twice in the list.
- Counters (unread items, cart totals, “3 new comments”) drift upward over a session.
- A celebratory animation or sound plays twice for one event.
- Two open tabs each append the same item when syncing through
BroadcastChannel. - Duplicates appear only on mobile, where reconnects are frequent.
The intent is a client where applying the same event twice produces exactly the same state as applying it once, with bounded memory and no dependence on the server being perfect.
Root Cause Analysis Permalink to this section
The browser’s Last-Event-ID records the id of the last event dispatched, not the last event the application finished processing. If the connection drops in the window between the server writing an event and the browser dispatching it — or if the server’s replay query is inclusive of the cursor — the event can arrive again after reconnect.
Applying events as increments or appends is what turns a harmless duplicate into corrupted state. Applying them as assignments keyed by id makes duplicates harmless by construction.
Step-by-Step Resolution Permalink to this section
Step 1 — Key state by entity id, not by arrival Permalink to this section
The strongest form of idempotence needs no seen-set at all: store entities in a map keyed by their own id, and make every event an upsert or delete.
// Notifications: a Map keyed by notification id — a duplicate overwrites itself.
const notifications = new Map();
es.addEventListener('notification', (e) => {
const n = JSON.parse(e.data);
notifications.set(n.id, n); // idempotent
render();
});
es.addEventListener('notification-deleted', (e) => {
notifications.delete(JSON.parse(e.data).id); // idempotent too
render();
});
Step 2 — Replace increments with authoritative values Permalink to this section
Counters are where duplicates do the most damage. Have the server send the value, not the change:
: fragile — a duplicate increments twice
event: comment-added
data: {"postId":7,"delta":1}
: robust — a duplicate sets the same value twice
event: comment-count
data: {"postId":7,"count":14,"version":88}
If the event must carry a delta (to avoid a read on the server), include the entity’s version and ignore deltas whose version is not exactly one greater than the one held — the same versioning used for snapshot plus delta streams.
Step 3 — Guard side effects with a bounded seen-set Permalink to this section
Some reactions cannot be expressed as state: a toast, a sound, an analytics beacon. Guard them with a set of recently seen event ids, bounded so it cannot grow without limit:
class SeenSet {
constructor(limit = 2000) { this.limit = limit; this.set = new Set(); this.order = []; }
firstTime(id) {
if (!id || this.set.has(id)) return !id ? true : false; // events without ids cannot be deduped
this.set.add(id); this.order.push(id);
if (this.order.length > this.limit) this.set.delete(this.order.shift());
return true;
}
}
const seen = new SeenSet();
es.addEventListener('notification', (e) => {
if (seen.firstTime(e.lastEventId)) toast(JSON.parse(e.data)); // side effect once
});
The limit only needs to cover the replay window — the number of events a reconnect could resend — not the whole session. Two thousand ids is generous for most feeds.
Step 4 — Deduplicate across tabs too Permalink to this section
When one tab holds the stream and broadcasts events to others through BroadcastChannel, or when each tab holds its own stream, the same event can reach shared state (IndexedDB, a shared store) more than once. Apply the same rules at the shared layer: upsert by entity id, and record handled event ids in the shared store if side effects run there. Sharing one SSE connection across tabs avoids most of this by having one tab own the stream.
Step 5 — Help from the server Permalink to this section
Clients should not depend on it, but the server can reduce duplicates: replay with an exclusive range (strictly after the cursor), subscribe-then-replay with a monotonic filter at the seam, and give every event an id. Events without ids cannot be deduplicated by id at all, which is a good reason never to send them for anything that matters. Generating monotonic event IDs covers the server side.
Validation & Monitoring Permalink to this section
// Test: feeding every event twice must produce the same state as feeding it once.
test('store is idempotent under duplicate delivery', () => {
const events = loadFixture('session-with-reconnects.json');
const once = events.reduce(apply, initialState());
const twice = events.flatMap((e) => [e, e]).reduce(apply, initialState());
expect(twice).toEqual(once);
});
In production, count duplicates the seen-set suppresses and report them as a client metric. A steady low rate is the normal cost of at-least-once delivery; a sudden rise indicates a server change that broke exclusive replay or the replay seam.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can the server guarantee exactly-once delivery instead?
Not over a connection that can fail at any point. The server can minimise duplicates with exclusive replay and careful seams, but the only robust guarantee is at-least-once delivery plus idempotent clients.
Should I deduplicate by event id or entity id?
Entity id for state, because it also collapses different events about the same thing into the latest version. Event id for side effects, because each event should trigger its reaction once.
How large should the seen-set be?
Large enough to cover every event a reconnect could replay — typically the server's maximum replay batch. A few thousand ids cost a few hundred kilobytes at most.
What about events without an id?
They cannot be deduplicated by id and do not advance Last-Event-ID. Use them only for data where duplicates are harmless, such as heartbeats and ephemeral presence signals.