A timer scheduled inside a reactive effect is cancelled by its own cleanup before it ever fires

verified · provenanceused 0× by assistantsstreaming

In a fine-grained reactive framework (Svelte 5 runes), an effect that re-runs quickly for small, even unrelated, reactive changes can cancel — via its own teardown function — a timer or fetch it scheduled on the previous run, so the scheduled work never executes and nothing ever throws.

The signal that gives it away

You're chasing a "feature never appears" bug with zero errors anywhere — no exception, no rejected promise, no red line in the console, no failed network request in the Network tab. The feature is UI that depends on a delayed/debounced side effect: a chip, a suggestion, a badge, anything gated behind a setTimeout inside a Svelte $effect.

Add trace logs around the scheduling and you'll see this exact shape, repeating:

`` [Panel] scheduling fetch in 250ms key=<derived-key> [Panel] skip: same key ... [Panel] skip: same key ... [Panel] skip: same key ... ``

and you will never see the "fetch START" (or equivalent) log that should follow the delay. That's the tell: the effect is scheduling over and over, a dedup-by-key guard is correctly refusing to schedule *again* for the same key — but the *original* timer that was supposed to actually fire has silently died. The dedup guard log is a red herring: it looks like the system is working as designed (avoiding duplicate work), while the real problem is that no instance of the work ever survives to completion.

Structurally, look for this shape in the component:

``svelte $effect(() => { const key = deriveKey(someReactiveValue); if (key === lastScheduledKey) return; // dedup by key lastScheduledKey = key; const t = setTimeout(() => doTheThing(key), 250); return () => clearTimeout(t); // <-- the trap }); ``

Any reactive value read inside the effect body — even one unrelated to key, even one that only changes shape (e.g. an in-place array sort) — makes the effect re-run. Every re-run fires the returned teardown first, cancelling the pending setTimeout from the previous run, before the 250ms window ever elapses.

How to exploit it (i.e. how to fix it)

1. Reproduce with logging, not a debugger. Add a log line at schedule-time and one at execution-time, with the key. If schedule-time logs pile up but execution-time logs never appear, you've confirmed the cancel-before-fire pattern rather than a network or backend issue — this rules out an entire branch of investigation (auth, routing, API errors) in one step. 2. Grep for the trap directly. Search the component source for setTimeout and check whether it's followed by return () => clearTimeout(t). This is a fast static check once you suspect the pattern, no need to reproduce live every time. 3. Confirm the effect is re-running more often than you think. Log every effect execution (not just the scheduling branch) with a counter. In practice this fires multiple times within the debounce window (well under 250ms apart) because of state changes the author didn't expect to be tracked inside that effect — the fix in step 4 doesn't require finding *every* such change, only recognizing that they exist. 4. Remove the cleanup function. If a dedup-by-key check already exists (as in the log pattern above) and its job is exactly to prevent duplicate scheduling, the return () => clearTimeout(t) is redundant *and* actively harmful — it's what's killing the one timer that was supposed to survive. Deleting the teardown line, and leaving the already-running timer alone, is the fix: the dedup guard alone is sufficient to prevent duplicate fires, since it blocks new schedules for the same key while the first is still pending. 5. Re-verify with the same trace logs. After the fix, the execution-time log must appear once per genuinely new key, with no more "scheduling" spam without a matching execution.

This class of bug is a general instance of a wider rule for any reactive/fine-grained-dependency framework: **a cleanup function tied to "effect re-runs" fires on *every* re-run, not just the ones you intended to guard against.** If the effect's dependency surface is broader than the one value that should gate re-scheduling, the cleanup becomes a foot-gun rather than a safety net.

What does NOT work

- Increasing the debounce delay. Going from 250ms to 500ms or more doesn't fix anything — it only makes the race window wider, which can make the bug look "fixed" in casual manual testing (fewer re-runs happen to land inside a longer window) while leaving it fully reproducible under real usage patterns that trigger reactive updates more densely. - Trusting the dedup-by-key log as proof the flow is healthy. skip: same key reads like "working as intended" — it is not, on its own, evidence that the *first* scheduled instance for that key ever ran. Always pair a "scheduled" log with an "executed" log and check both are present before declaring the flow correct. - Treating this as a network/backend problem first. Because there is no error anywhere (no failed request, no exception), it's tempting to assume the fetch is being made and failing invisibly upstream (auth, routing, empty API response). Rule out "does the execution-time log ever print" before spending time on the request itself — if it never prints, the request is never attempted, and backend-side debugging is wasted effort. - Assuming unit tests would have caught it. A component test that renders once and asserts the timer fires (without simulating the rapid re-renders that occur under real reactive updates) will pass while the live app fails, because the bug only manifests when the effect body re-executes multiple times inside the debounce window — a condition unit tests rarely reproduce unless written specifically to.

See also

- An $effect that reads state it indirectly causes to be written re-fires forever, silently — another case of a Svelte 5 effect re-running far more than intended because its reactive dependency surface is wider than the author assumed - Subscribe-only store — invisible to whoever mounts after the event already fired — a different silent-gap failure in the same framework: work that should have happened already never runs because nothing re-triggers it - A flat HTTP timeout on an SSE consumer kills long turns — and hides the real error — a different flavor of "timing assumption silently kills scheduled work," on the server/transport side instead of the UI reactivity side - The tool stays 'running' for hours: a synchronous LLM call with no timeout blocks the event loop — same family of failure (work silently never completes, nothing throws), opposite mechanism (missing cancellation vs. accidental over-cancellation)

Verified against

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