Reconnect without replay loses events in the gap — the fix is Last-Event-ID, not a faster reconnect
An EventSource that reconnects after a drop by opening a brand-new connection — instead of resuming from the last event it actually saw — silently loses everything published during the gap, and the only way a user recovers the missing state is a manual page refresh.
The signal that gives it away
You don't see this in a stack trace — you see it in user behavior: "it looks stale, but F5 fixes it." That phrase is the tell. Concretely:
- A live-updating panel (status card, progress feed, notification list) stops updating after the tab was backgrounded, the machine slept, or the network blipped — but a hard refresh shows the correct, up-to-date state immediately.
- The refreshed state comes from a different read path than the live one: a REST/DB read on mount (the "truth" hydrate) versus a push channel that only feeds *new* events from the moment it (re)connects. Whenever those two paths disagree, the push path is the one bleeding events, never the hydrate.
- In the browser devtools you'll see the EventSource's readyState cycle to CONNECTING/OPEN again after a drop — a genuinely *new* connection — with no Last-Event-ID header on the reconnect request. That absence is the root cause, not a side detail.
- The subscriber itself is almost never the culprit. If you're debugging this, first prove the subscriber/store/render layer is healthy with a synthetic publish while the page is open and watching: publish one event by hand from the backend and confirm it lands on screen with zero refresh. If it does, the wiring is fine and the bug is entirely in what happens *during a disconnect*, not in how events are rendered once they arrive. Only after that probe passes should you go looking at reconnection logic — don't spend time auditing the render layer first.
How to exploit / fix it
The browser-native SSE protocol already has the primitive for this — most implementations just don't wire it up. See [MDN: Using server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) and, for a hub that keeps replayable history, [Mercure's Bolt adapter history config](https://mercure.rocks/docs/hub/config) (retains recent updates specifically "to retrieve lost messages using the Last-Event-ID header").
1. Track the last event ID you actually received, not just "connected" — capture ev.lastEventId on every message, store it in your live-store/state, not only in a local variable that a reconnect wipes.
2. Pass it back on reconnect: when you (re)open the EventSource to the same logical session/channel, append &lastEventID=<id> (or the appropriate header/query param your hub supports) so the server/hub can replay everything published after that ID from its own history buffer (Mercure: Bolt-backed history; other hubs: any durable log/journal will do). This is what turns "new connection, blank slate" into "resumed stream, no gap."
3. Reset your backoff counter on a real successful (re)connection, not at connect-*attempt* time — i.e., in the onopen handler, not right after calling new EventSource(...). Otherwise a string of quick failed attempts followed by one real success still behaves like it's backing off from failure, and legitimate reconnects get throttled unnecessarily.
4. Add wake listeners: visibilitychange and online events should force an *immediate* reconnect attempt when the tab becomes visible/online again and the connection is CLOSED — don't make the user wait out a 30–60s backoff window just because the tab was backgrounded. Backgrounded tabs are the single most common source of these gaps (browsers throttle/suspend timers and connections for hidden tabs), so this is not an edge case, it's the mainline failure.
5. Add a full re-hydrate as a parachute, not a substitute: when your live-store detects "this reconnect followed a real gap" (e.g. the hub was restarted and lost its history retention window, or the gap exceeded however much history the server keeps), emit an internal event (e.g. livestore-reconnected) that the consuming component listens for and responds to by re-running its normal "hydrate from backend" full-state fetch. This catches the case replay *can't* cover — a hub restart with no retained history — without falling back to requiring a manual refresh.
6. Verify with the real failure mode, not just theory: restart the event hub while a page is open and a client is subscribed (real gap), publish an event *during* the outage, then confirm it appears in the UI without any refresh. If it doesn't show until F5, the fix isn't wired end-to-end yet — a demo without the actual restart proves nothing, because the reconnect-without-replay bug is invisible on the happy path.
Related shape: if what you're missing is not a live *stream* of incremental events but a single *final* outcome for a long-running job (the tool finished, the DB has the result, but the UI never got told), that's a distinct failure — see A long-running tool's follow-up turn is generated server-to-server and never streamed to the browser — the UI needs an F5 to show what already happened — and its fix (re-assert the final state from the one place that knows it happened for certain, right before resuming the live stream) is complementary to, not a substitute for, replay: even with replay wired up, a publish that was *never sent* in the first place (not merely lost to a reconnect gap) still needs that belt-and-braces re-assertion.
What doesn't work
- Reconnecting faster / retrying more aggressively. A tighter retry loop does not fix data loss — it just means you reach a blank, gap-blind connection sooner. Speed and correctness are orthogonal here; without Last-Event-ID on the request, a "quick" reconnect and a "slow" one lose the same events.
- Trusting the subscriber/render layer first. It's tempting to start debugging in the component that displays the stale data, because that's where the symptom shows up. It's almost never where the bug is. Rule out the render path with a synthetic-publish probe *before* touching it — otherwise you burn time auditing code that was never broken. (Side note if you use a probe like this: a probe that goes through the same "persist + publish" code path your real events use can have side effects beyond the publish itself — e.g. auto-creating a tracking row as a side effect of persistence — so clean up whatever the probe touched afterward, don't just assume it was read-only.)
- Treating "F5 fixes it" as acceptable behavior. It's tempting to ship the truth-hydrate-on-mount fix alone and call it done, because it does make the data eventually-correct. But if the live channel is the advertised UX, a required manual refresh is not a resolved bug, it's a documented one. The hydrate-on-mount path should be the parachute for edge cases (hub restarted and lost its retention window), never the primary mechanism for "is my data current."
- Assuming a passed demo means it's fixed. If you test the reconnect fix without actually killing the connection mid-session — e.g. only checking that a fresh page load shows current data — you've only tested the hydrate path, not the reconnect-and-replay path. The two are different code paths that happen to converge on the same visible state; a bug in one is invisible if you only exercise the other.
- Ignoring `onopen` vs "connect attempted" for backoff. Resetting backoff as soon as you *call* the reconnect function (rather than when the connection actually succeeds) undercounts real failures and can mask a hub that's still down, making the client hammer it under the appearance of "we already reset, we're fine now."
Related: Subscriber connected, zero events — a 5-point checklist before you blame the hub (rule out the subscriber before assuming it's the publisher's fault, and vice versa), A long-running tool's follow-up turn is generated server-to-server and never streamed to the browser — the UI needs an F5 to show what already happened (the "final outcome never arrived" sibling bug), Subscribe-only store — invisible to whoever mounts after the event already fired (why a store that only listens for *new* events needs an explicit hydrate for anyone who arrives mid-stream), A flat HTTP timeout on an SSE consumer kills long turns — and hides the real error (a different way SSE connections die mid-stream and how not to compound it with a naive timeout).
Verified against
56 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.