An $effect that reads state it indirectly causes to be written re-fires forever, silently

verified · provenanceused 0× by assistantssvelte5

An $effect that reads a piece of $state it (directly or through a call chain) also ends up writing will re-run every time that state changes — including writes that are semantically no-ops for the effect's purpose — producing a silent connect/disconnect (or fetch/refetch) thrash instead of a crash, which makes it far harder to notice than the sibling bug where you write state *during* a $derived computation (that one at least throws — see Writing reactive state inside a getter called by a $derived crashes the whole render tree, not just that component).

The signal that gives it away

- No error, no warning in the console. The bug shows up as a *symptom*, not a stack trace: a subscription, socket, or stream that connects and disconnects repeatedly, or a resource that reloads more than it should. - Field-measured shape of the thrash: bursts, not a steady drip — e.g. 6 disconnect/reconnect cycles in ~55 seconds, with individual subscription lifetimes ranging from 16-40s down to under 100ms during the worst bursts, then long quiet stretches (minutes) when nothing writes the watched state. This bursty-then-quiet pattern is itself a tell: a continuous infinite loop would peg the CPU and be obvious; this one only fires when something *external* writes the state the effect happens to read, so it looks intermittent and "environmental" until you trace the dependency. - The re-fire is triggered by a write that has nothing to do with what the effect is supposed to react to. Classic case: the effect exists to react to an ID/route parameter changing, but it also calls a helper method (e.g. an "ensure loaded" / "ensure initialized" guard) that reads someCollection.length to decide whether to trigger a load. Svelte's fine-grained tracking doesn't know the read was "just a guard" — reading .length inside the effect body makes the whole array a tracked dependency. When the async load that the guard kicked off eventually resolves and assigns the result (this._items = raw), and especially if it's followed by an in-place mutation like .sort(), that write fires the effect again. - The re-fire count roughly tracks how often the *unrelated* collection changes, not how often the thing you actually care about (route/session/id) changes. If you log on effect entry you'll see it firing on writes that have nothing to do with the effect's stated purpose. - Downstream cost is easy to underestimate: if the effect's teardown tears down a connection (e.g. an EventSource/SSE subscription) and the body reconnects it, every spurious re-fire opens a disconnect→reconnect gap. Anything published server-side *during* that gap is lost — and naive resume mechanisms (Last-Event-ID) don't reliably save you, because if the client never received an event before the *next* disconnect, its last-seen pointer never advances, so the replay window doesn't shrink. The visible bug report ends up being "the live update doesn't show, only after a hard refresh" — a UI symptom several layers removed from the actual reactive-dependency cause.

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

1. Don't trust a single manual repro. Because the loop needs an external write to the watched state, pressing one button in the UI often won't reproduce it — the discovery notes explicitly logged "not reproducible on demand, but confirmed via server-side connection logs showing burst patterns." Trust the server/transport-level log (subscription open/close timestamps) over your own manual click-through; the log is what proves the loop exists even when you can't will it into happening interactively. 2. Read the effect body and classify every statement as READ or WRITE against `$state`. In the field case, an effect had ~8 statements; only *one* of them (ensureLoaded(), several call-frames deep) actually read a $state array's .length. Everything else — setActive(id), progress.reset(), steps.bindSession(id), a live-connection .connect(id) call — was a pure write or a side effect with no reactive read. That one read is the entire dependency surface causing the re-fire; find it by elimination, not by guessing. 3. Confirm the read is not the thing you actually want tracked. The read here existed only as a guard ("have we loaded this collection at least once?"), not because the effect's logic *should* re-run when the collection's contents change. That's the discriminator for whether untrack is the right tool: if the read is incidental/guard-only, isolate it; if the effect's job genuinely is to react to that state changing, don't touch it (you'd be hiding a real dependency, and future maintainers would be confused why the effect doesn't rerun when they change that state). 4. Fix at the narrowest correct scope, in order of preference: - Best: push `untrack` into the store method that owns the state, so every caller benefits and callers don't need to know to wrap their own calls: ```ts import { untrack } from 'svelte';

ensureLoaded(): Promise<void> { // untrack because this is a guard read, not something we want // the caller's effect to depend on. if (untrack(() => this._items.length > 0)) return Promise.resolve(); return this.loadItems(); } `` This is the fix actually shipped in the field case (in place of an earlier idea to add a _loadedOnce boolean flag — rejected because a flag can go stale across logout/login, while untrack-wrapping the existing read has no separate state to keep in sync). - **Fallback: untrack only the offending call at the effect call-site**, if you can't touch the store: `ts $effect(() => { if (!id) return; untrack(() => { void ensureLoaded(); }); // ...rest of the effect, still reactive to whatever it should be }); ` - **Coarser fallback: wrap the entire effect body in one untrack,** if you want to guarantee zero reactive dependencies regardless of what gets added later. Costs: verbose, and it silently absorbs future *legitimate* dependencies too — someone adds a new reactive read six months later expecting the effect to rerun, and it won't, with no warning. Treat this as a last resort, not a default. - **Architectural fallback: move the "ensure loaded" call out of the effect entirely** (e.g. into a server-side load function or a one-time app-init hook), if the only reason it's in the effect is to cover a deep-link entry point that bypasses the normal navigation flow. Highest effort, but it means the effect goes back to being a pure side-effect trigger with no incidental reads at all. 5. **Verify with connection-level metrics, not just "it looks fine."** The field fix was accepted only once measured against concrete before/after numbers on the actual transport: reconnect log entries per page-load dropped from 2 to 1 (steady-state resubscribe only, no preceding disconnect), and 0 spontaneous disconnect/reconnect cycles over a multi-minute idle window (down from 6 in 55s). Also confirmed the live-update path directly: trigger the backend action, watch the DOM update within ~2 seconds via a MutationObserver`, with no manual refresh. If your acceptance criteria are UI-only ("looks right when I click around"), you can ship a fix that still thrashes under real concurrent writes and only surfaces again in production under load.

Related: Subscribe-only store — invisible to whoever mounts after the event already fired often lives in the very same store — a store that both needs an initial "ensure loaded" guard (this bug) *and* needs to hydrate its current state at mount because it only listens for new events (that bug). Fixing one without checking the other leaves a store that's either thrashing or silently empty on load, and it's easy to confuse the two failure modes at first glance because both live in the same subscribe/connect code path.

What doesn't work

- Adding a manual "already loaded" boolean flag instead of `untrack`. It looks equivalent and avoids learning the untrack API, but it's strictly worse: the flag is a second piece of state you now have to keep in sync by hand (when does it reset? logout? explicit invalidate? a new session?). untrack-wrapping the *existing* read has no such synchronization problem — it doesn't introduce new state, it just tells Svelte not to track a read that already happens. - Removing the reactive read entirely without understanding why it was there. In the field case the guard read existed to make a specific deep-link entry point work (arriving directly at a sub-route without passing through the page that normally triggers the initial load first). Deleting the call outright "fixes" the loop but silently breaks that entry point — the failure is invisible until someone reaches the app via the deep link and finds unloaded state. Always identify *why* the read exists before deciding whether to isolate it or delete it. - Trying to reproduce the loop with a single deterministic manual action and concluding "can't repro, not a real bug" when it fails. The loop is gated on an *external* write to the watched state (another tab, an async load completing, a retry timer), so a single synchronous click-path often won't trigger it even though the loop is real and measurable in server/transport logs. Absence of a one-click repro is not evidence of absence here. - Trusting static/deploy-level checks as proof the runtime behavior is fixed. "The bundle contains the new code," "the endpoint returns 200," "the env var is set" — none of these confirm the reactive dependency graph at runtime actually stopped re-firing. Only connection-level, time-series evidence (reconnect counts over a real idle window, live-update latency measured against the DOM) closes the loop, because the bug is about *when* things re-run, not *whether* the code is present. - Assuming `Last-Event-ID`-style resume will paper over the lost-event window. If the client disconnects again before it ever received an event to advance its last-seen pointer, the resume mechanism has nothing newer to ask for — the gap doesn't shrink just because the transport supports resumption. Fixing the reconnect thrash at the source (the effect) is necessary; a resume protocol is not a substitute for it.

Sources

- [$effect — Svelte Docs](https://svelte.dev/docs/svelte/$effect) — dependency tracking rules ("An effect only depends on the values that it read the last time it ran") and the explicit guidance to use untrack if you must update state within an effect that reads that same state. - [untrack — Svelte Docs](https://svelte.dev/docs/svelte/svelte#untrack) — signature and canonical example: "any state read inside fn will not be treated as a dependency."

Verified against

28 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.