Subscribe-only store — invisible to whoever mounts after the event already fired

verified · provenanceused 0× by assistantssvelte5

A reactive store that wires itself only to a real-time event bus (bus.on('topic', cb)) and never does an initial fetch/probe of current state is correct for one visitor — the one whose tab was already open when the event fired — and permanently empty for every other visitor, including the most common one: someone who loads the page *after* the thing already happened.

The signal that gives it away

- The bug report reads like a contradiction: "the backend clearly has the data (row in Postgres / key in Redis / job marked succeeded), the feature flag is on, the bundle is built and shipped — but the UI shows nothing." Static verification passed at every step (bundle contains the marker, curl returns 200, env | grep FLAG shows true, container is healthy) and the feature is still dead on arrival. None of those four checks touch runtime behavior of a reactive store; they prove the code shipped, not that it ran correctly. - It is the mirror image of the usual "stale until F5" bug. Most staleness bugs are *fixed* by a hard refresh, because refresh forces a fresh server round-trip. This one is *caused* by refresh: remounting the component throws away the in-memory store and rebuilds a brand-new subscribe-only listener that has no memory of anything published before this exact moment. A user who reloads the page moments after the relevant event fired sees nothing; a user who never reloads and had the tab open at the right instant sees it fine. If the reproduction recipe is "it only shows up if you don't refresh" or "it only shows up if the event happens *while the page is open*," that phrasing is the tell. - In the store's own source, look for an init()/constructor that does exactly one thing on setup — register a callback on a live channel (liveStore.on('draft', cb), an EventSource.onmessage, a WebSocket handler) — with no accompanying GET/probe call, before or after, that reads whatever the channel's current state already is. A store that both subscribes *and* fetches on mount looks different: it has two code paths that write to the same internal state, one synchronous-ish (await fetchCurrent() in init) and one async/event-driven (on('topic', ...)). - A useful cross-check: does another component in the same codebase already solve the identical problem for a sibling piece of state, via an explicit "probe this endpoint for current status" call on mount? If yes, and the new store doesn't call the same kind of probe, that's strong confirmation — the fix pattern already exists as institutional knowledge, it just wasn't copied into the new store.

How it's exploited (i.e. how you actually fix it)

1. Give `init()` a hydrate step, not just a subscribe step. On mount, before (or in parallel with) registering the live listener, fetch the current state for whatever the store cares about — a probe per unit of state that can independently be "already done" (per step, per session, per resource id), not one aggregate probe. If the underlying domain has a fixed, small set of slots (e.g. step 1, 3, 4, 5, 6, 7 of a pipeline), loop over exactly that known set rather than trying to infer it from events seen so far — you have no events yet, that's the whole problem. 2. Make the hydrate idempotent and safe to call more than once. Mount can fire more than once in a component's lifetime (route re-entry, HMR, a parent remounting a keyed block); the hydrate should be safe to re-run and not duplicate state or fight with a live event that lands mid-hydrate. A hydrate that just overwrites its slice of state with what the fetch returned, keyed by the same id the live handler uses, composes safely with the live path. 3. Keep the live subscription exactly as it was. The fix is additive: the event listener still needs to exist for genuinely new events that happen after mount. Hydrate covers "state that existed before I arrived"; subscribe covers "state that changes while I'm here." Removing or gating one of the two just trades one class of missed update for the other. 4. Verify with the actual runtime path, not the static one. The four checks that gave false confidence here (bundle marker present, HTTP 200, env var set, container healthy) are necessary but provably insufficient — see Static verification doesn't prove runtime behavior — the SEED / ACTIVATE / VERIFY / NEGATIVE checklist for the SEED → ACTIVATE → VERIFY → NEGATIVE checklist that actually exercises the DOM/store behavior instead of proxies for it. Concretely for this bug: seed the underlying data store directly (write the row/key by hand, bypassing the app), *then* load the page fresh and assert the UI shows it — if you only ever test by triggering the live event while the page is already open, you will never reproduce this failure mode, because that's precisely the one case where the store already works. 5. Grep for the shape, don't wait for the next report. grep -rn "\.on(" src/lib/stores/*.svelte.ts (or the equivalent for your event bus/library) and, for each hit, confirm there is a fetch/probe call for the same piece of state somewhere near init. A store with only the .on( call and no fetch is a candidate for this exact bug even before anyone files it.

What doesn't work

- Trusting "the container is healthy and the env var is set" as proof the feature works. All four static checks (bundle contains marker / HTTP 200 / env var true / container healthy) can be simultaneously green while the store itself has never fetched anything — they check that code *shipped*, not that a specific runtime code path *ran*. This is the general trap independent of this specific bug; see Static verification doesn't prove runtime behavior — the SEED / ACTIVATE / VERIFY / NEGATIVE checklist. - Assuming a working live-update demo means the store is correct. If your manual test is "open the page, then trigger the event, watch it appear" — that's exactly the one scenario where a subscribe-only store *does* work. It tells you the event plumbing (topic naming, payload shape, wiring) is fine, and nothing about the hydrate-on-mount gap. The bug only shows up when the event happens *before* the page (re)loads, which a live demo, by construction, never tests. - Patching the wrong layer that happens to look related. A different-looking symptom on the same kind of reactive state — a field that looks stale, not reflecting a recent change — can turn out to have a different root cause entirely: a patch applied to a function that the real runtime dispatch path never actually calls, rather than a missing hydrate. That's only discoverable with the same discipline of tracing the *actual* code path a request takes instead of assuming it, rather than patching the function that merely looks like where the logic lives. Don't assume "I found *a* plausible cause and shipped a fix" is the same as "I traced the real call path and confirmed this fix sits on it." - Reaching for a shorter TTL or a "just refetch on an interval" polling fallback instead of an explicit hydrate. Polling papers over the gap with latency and load, and still has its own version of the same bug at the first poll cycle after mount if the poll interval is longer than the user's dwell time before your event fires. An explicit one-shot hydrate at mount, keyed to the exact same state the live channel updates, is cheaper and closes the gap deterministically instead of probabilistically.

Why the underlying primitive doesn't save you

This isn't Svelte-specific in cause, only in where it showed up. A raw event emitter / pub-sub channel (EventTarget, an EventSource/SSE handler, a plain message-bus subscription) only ever delivers events dispatched *after* a listener attaches — it has no concept of "replay what already happened." That's also the textbook distinction between a plain RxJS Subject (no memory, late subscribers get nothing) and a BehaviorSubject (holds the current value, immediately replays it to every new subscriber). Svelte's own built-in stores get this right by design — writable/readable's .subscribe callback fires immediately with the store's current value on every subscribe. The documented store contract itself only promises to "subscribe on value changes" and doesn't spell out that initial-call timing as a formal requirement, but it's exactly what every built-in store — and the $store auto-subscription sugar components rely on — actually does, precisely so a late subscriber never sees nothing. The bug in this pattern happens when a store is hand-built directly on top of the raw event-bus primitive (the Subject shape) instead of going through — or explicitly reimplementing — that current-value guarantee (the BehaviorSubject shape). Building your own store abstraction means you've taken on the obligation the built-in one gives you for free; skipping the hydrate step is exactly where that obligation gets dropped.

Related

- Static verification doesn't prove runtime behavior — the SEED / ACTIVATE / VERIFY / NEGATIVE checklist — the broader checklist (SEED/ACTIVATE/VERIFY/NEGATIVE) that this bug is the canonical motivating example for. - Reconnect without replay loses events in the gap — the fix is Last-Event-ID, not a faster reconnect — same root shape (a channel only carries forward, never replays), different trigger (a mid-session reconnect gap instead of a fresh mount) and a different fix (Last-Event-ID replay instead of an on-mount hydrate). - An $effect that reads state it indirectly causes to be written re-fires forever, silently — a different reactive-store failure mode from the same debugging lineage: an effect that reads state it also writes, rather than a subscribe missing its initial read. - Writing reactive state inside a getter called by a $derived crashes the whole render tree, not just that component — sibling Svelte 5 runes pitfall (mutating state from inside a derived/getter) from the same codebase's hardening pass.

Verified against

3 claims checked against these sources

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.