Describe the bug
Human note-- the following was created by Fable after many hours of looping Opus 4.8 over the same issue we have had for over a year and tried multiple times to resolve. Finally, I assigned Fable to the issue and it drafted the following:
If a component-level $derived is only ever read in untracked contexts — the common case being a $state(...) initializer — it subscribes to its dependencies when first evaluated but never gains a reaction of its own. When the component is destroyed, nothing removes it from its dependencies' reactions arrays:
destroy_effect calls remove_reactions(effect, 0) for the effect's own deps, but has no knowledge of deriveds created during the component's init.
- The lazy-disconnect branch in
remove_reaction ("If the derived has no reactions, then we can disconnect it from the graph…") only fires when removing a reaction empties reactions. A derived that never had a reaction never enters that path.
The stale derived stays in the dependency's reactions[] forever. Because its fn closure shares the component instance's activation scope, this retains the entire destroyed component — locals, props, and its DOM.
The whole chain leaks, not just the root: in the repro, a stays in urlSource.reactions, b stays in a.reactions, and b.reactions === null — the cascade had nothing to trigger it.
We found this in production (a SvelteKit app): navigating between two routes accumulated 177+ stale reactions on page.url in a day-old tab. Heap snapshots showed the chain page.url → reactions[] → derived.fn → component scope → detached DOM, and since Chrome retains a detached DOM tree atomically, each stale subscription pinned an entire dead page. The offending app pattern was simply:
const searchParam = $derived(page.url.searchParams.get('x'));
const normalized = $derived(searchParam?.trim() ?? null);
const inferred = $derived(options.find(o => o.value === normalized)?.region);
let selected = $state(inferred || null); // ← only read of the chain
i.e. "state initialized from derived data, then mutated locally" — a fairly natural thing to write.
Reproduced on 5.53.3, 5.55.9, and 5.56.4 (latest at time of writing).
Reproduction
Playground: https://svelte.dev/playground/untitled#H4sIAAAAAAAAE41UwY7bNhD9lam6gCXAK-0CPXltL4K2pyYN0KCHou6BokYWa2qokqP1qo6AfES_sF9SkJSUdZoUPVEaznskZ968S0KixWST9MRWyBNWt4b0ADcVWvWEFRA-oYVKOWmIULJL1kmtNLpk8-sl4aHzYB9I1jPVq67L3RNq9rFSOPxcXBpiJHbJJtk6aVXH-wMdWLWdsQyvUZwGqK1pYZUX4W-Crh5epF3AopCsDH1remIYF0hvdf67i8kaGVxjzrCDG8eCMWXbY7bsyYBdNu_iTt1TYAY2x6PGNIOLDx94ovrKrw8x9EePPb5R0hoW7pSmGez2kM68V3dMsyzyjwfaFh9fTtuyZzYEhqRW8rS7xHPHfVwhvYSDH2HVeh6sVrCBVU_z35hti0ixP9C22_dWvzO9lZjP57tcIx252cC23F_C7cZtUe63RRcg4HjQuDskrXi-PavKZ35zZ7E9JKE33z93KBmrDXCDU9Uscm_JARu4hzTEe2uRWA8w3QwUORYkMQNRM1rwiho84XT5wmJYQQ5SYw6vJPdCb0AxHK05OygHuIcObUxYQ21sUOXfH_4CFLKBCh1bM2DlWaVpO0NIvHIwq1g2QhE4FoMD15e-7CVWIKgCxQ6kNq636F8jlH9Ng3BujMYrPkgVSd1Xio4B9d3bN1nuu9iFBl6-VnXQ2biN8i32l0LV44GSdcL4zMnG625cf2FsXqr8enA-2fnv0bnAEflnq78wDEUBYiqHqef6uDzP41PJMQg_C9NOGsnSLI9vR5eunleThGN6-TJdeIEO6II4yayy-dA8z8-NcQhvf3z9C3hk36IF5UAQLObjZ6WCVEyzCIoUK6HVn2izfGL6CUVoQQmChnODFmFGp4xtpwXjGm6wrlFyBq04YeyoRnHyTia6DoXNF2tAHWT90QFKeP8eqNfa3_56SLv9nL6By_w5Rgn8vzbHZlw3eIm9aG1RwNvSoX0SwYVco1oI1uxVvzynEQ7IcOMLwgYqA2fFDShitCS0e_BFOlBRgDZ0vNVhGlwjrF_UkYSGFPNjDu-Cun5Qfmo6ccS8tzqDEhvxNJXPiRbzAy0yC7Vae7UtUosSLebTC6mVn0OvAcLngIuaWcwJdpEnXYndfVDLlLfY7yzAaL_Rb3wwXTg8avw38BPbvcJ_xhwfJ3eEx0e4i4zXDf1tnbBQ-qyoSja10A7HfwDS8uiEPgcAAA
Click "toggle" repeatedly: the shared signal's subscriber count grows by 1 per unmount/remount cycle, unbounded. Expected: it returns to 1 (the mounted instance) after every cycle.
after mount: 1
after 1 unmount/remount cycle: 2
after 2 unmount/remount cycles: 3
after 3 unmount/remount cycles: 4
...
Controls that make it collect properly (either one):
- change
let selected = $state(b || null) to let selected = $state(null), or
- read
b anywhere tracked (template or $effect).
The url.js file in the repro imports svelte/internal/client only to observe urlSource.reactions.length — the leak itself has nothing to do with internals; any long-lived shared signal (module $state, SvelteKit's page.url) behaves the same.
Logs
No errors — silent retention. Heap-snapshot retainer path from the production app (Chrome DevTools):
page.url source (v: URL, reactions: 177)
→ reactions[n] = derived { fn, ctx, deps, parent: <destroyed effect, all fields null> }
→ fn closure → component activation scope
→ detached <div> … (entire destroyed page subtree)
System Info
svelte: 5.53.3 / 5.55.9 / 5.56.4 (all reproduce)
kit: 2.61.1 (app), not required for repro
browser: Chrome 143 (any V8 runtime; also reproduces in jsdom)
os: macOS 15
Severity
annoyance for the repro, but a serious memory leak in real apps: in ours, one such component rendered ~90× per page turned every client-side navigation into a permanently retained copy of the previous page's DOM (hundreds of MB over a workday).
Possible fix
We're currently shipping a small patch: derived() registers the signal on active_effect.deriveds, and destroy_effect disconnects any of them that still have reactions === null — reusing the exact logic of remove_reaction's disconnect branch (including the new_deps guard). Our full app test-suite passes with it and the leak is gone. Happy to turn it into a PR if the approach (or a variant) is acceptable — I imagine there are reasons deriveds aren't tracked on their parent effect anymore, so treat it as a starting point:
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ derived()
if (DEV && tracing_mode_flag) {
signal.created = get_error('created at');
}
+
+ // track owned deriveds so destroy_effect can disconnect the
+ // never-reacted ones (untracked-only reads) from their dependencies
+ if (active_effect !== null) {
+ (active_effect.deriveds ??= []).push(signal);
+ }
return signal;
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ destroy_effect()
destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0);
+
+ if (effect.deriveds !== null) {
+ for (var i = 0; i < effect.deriveds.length; i++) {
+ disconnect_unused_derived(effect.deriveds[i]);
+ }
+ effect.deriveds = null;
+ }
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ (new export)
+export function disconnect_unused_derived(derived) {
+ if (derived.reactions !== null || (derived.f & DESTROYED) !== 0 || derived.deps === null) return;
+ if (new_deps !== null && includes.call(new_deps, derived)) return;
+ if ((derived.f & CONNECTED) !== 0) {
+ derived.f ^= CONNECTED;
+ derived.f &= ~WAS_MARKED;
+ }
+ if (derived.v !== UNINITIALIZED) {
+ update_derived_status(derived);
+ }
+ freeze_derived_effects(derived);
+ remove_reactions(derived, 0);
+}
Describe the bug
Human note-- the following was created by Fable after many hours of looping Opus 4.8 over the same issue we have had for over a year and tried multiple times to resolve. Finally, I assigned Fable to the issue and it drafted the following:
If a component-level
$derivedis only ever read in untracked contexts — the common case being a$state(...)initializer — it subscribes to its dependencies when first evaluated but never gains a reaction of its own. When the component is destroyed, nothing removes it from its dependencies'reactionsarrays:destroy_effectcallsremove_reactions(effect, 0)for the effect's own deps, but has no knowledge of deriveds created during the component's init.remove_reaction("If the derived has no reactions, then we can disconnect it from the graph…") only fires when removing a reaction emptiesreactions. A derived that never had a reaction never enters that path.The stale derived stays in the dependency's
reactions[]forever. Because itsfnclosure shares the component instance's activation scope, this retains the entire destroyed component — locals, props, and its DOM.The whole chain leaks, not just the root: in the repro,
astays inurlSource.reactions,bstays ina.reactions, andb.reactions === null— the cascade had nothing to trigger it.We found this in production (a SvelteKit app): navigating between two routes accumulated 177+ stale reactions on
page.urlin a day-old tab. Heap snapshots showed the chainpage.url → reactions[] → derived.fn → component scope → detached DOM, and since Chrome retains a detached DOM tree atomically, each stale subscription pinned an entire dead page. The offending app pattern was simply:const searchParam = $derived(page.url.searchParams.get('x')); const normalized = $derived(searchParam?.trim() ?? null); const inferred = $derived(options.find(o => o.value === normalized)?.region); let selected = $state(inferred || null); // ← only read of the chaini.e. "state initialized from derived data, then mutated locally" — a fairly natural thing to write.
Reproduced on 5.53.3, 5.55.9, and 5.56.4 (latest at time of writing).
Reproduction
Playground: https://svelte.dev/playground/untitled#H4sIAAAAAAAAE41UwY7bNhD9lam6gCXAK-0CPXltL4K2pyYN0KCHou6BokYWa2qokqP1qo6AfES_sF9SkJSUdZoUPVEaznskZ968S0KixWST9MRWyBNWt4b0ADcVWvWEFRA-oYVKOWmIULJL1kmtNLpk8-sl4aHzYB9I1jPVq67L3RNq9rFSOPxcXBpiJHbJJtk6aVXH-wMdWLWdsQyvUZwGqK1pYZUX4W-Crh5epF3AopCsDH1remIYF0hvdf67i8kaGVxjzrCDG8eCMWXbY7bsyYBdNu_iTt1TYAY2x6PGNIOLDx94ovrKrw8x9EePPb5R0hoW7pSmGez2kM68V3dMsyzyjwfaFh9fTtuyZzYEhqRW8rS7xHPHfVwhvYSDH2HVeh6sVrCBVU_z35hti0ixP9C22_dWvzO9lZjP57tcIx252cC23F_C7cZtUe63RRcg4HjQuDskrXi-PavKZ35zZ7E9JKE33z93KBmrDXCDU9Uscm_JARu4hzTEe2uRWA8w3QwUORYkMQNRM1rwiho84XT5wmJYQQ5SYw6vJPdCb0AxHK05OygHuIcObUxYQ21sUOXfH_4CFLKBCh1bM2DlWaVpO0NIvHIwq1g2QhE4FoMD15e-7CVWIKgCxQ6kNq636F8jlH9Ng3BujMYrPkgVSd1Xio4B9d3bN1nuu9iFBl6-VnXQ2biN8i32l0LV44GSdcL4zMnG625cf2FsXqr8enA-2fnv0bnAEflnq78wDEUBYiqHqef6uDzP41PJMQg_C9NOGsnSLI9vR5eunleThGN6-TJdeIEO6II4yayy-dA8z8-NcQhvf3z9C3hk36IF5UAQLObjZ6WCVEyzCIoUK6HVn2izfGL6CUVoQQmChnODFmFGp4xtpwXjGm6wrlFyBq04YeyoRnHyTia6DoXNF2tAHWT90QFKeP8eqNfa3_56SLv9nL6By_w5Rgn8vzbHZlw3eIm9aG1RwNvSoX0SwYVco1oI1uxVvzynEQ7IcOMLwgYqA2fFDShitCS0e_BFOlBRgDZ0vNVhGlwjrF_UkYSGFPNjDu-Cun5Qfmo6ccS8tzqDEhvxNJXPiRbzAy0yC7Vae7UtUosSLebTC6mVn0OvAcLngIuaWcwJdpEnXYndfVDLlLfY7yzAaL_Rb3wwXTg8avw38BPbvcJ_xhwfJ3eEx0e4i4zXDf1tnbBQ-qyoSja10A7HfwDS8uiEPgcAAA
Click "toggle" repeatedly: the shared signal's subscriber count grows by 1 per unmount/remount cycle, unbounded. Expected: it returns to 1 (the mounted instance) after every cycle.
Controls that make it collect properly (either one):
let selected = $state(b || null)tolet selected = $state(null), orbanywhere tracked (template or$effect).The
url.jsfile in the repro importssvelte/internal/clientonly to observeurlSource.reactions.length— the leak itself has nothing to do with internals; any long-lived shared signal (module$state, SvelteKit'spage.url) behaves the same.Logs
No errors — silent retention. Heap-snapshot retainer path from the production app (Chrome DevTools):
System Info
Severity
annoyance for the repro, but a serious memory leak in real apps: in ours, one such component rendered ~90× per page turned every client-side navigation into a permanently retained copy of the previous page's DOM (hundreds of MB over a workday).
Possible fix
We're currently shipping a small patch:
derived()registers the signal onactive_effect.deriveds, anddestroy_effectdisconnects any of them that still havereactions === null— reusing the exact logic ofremove_reaction's disconnect branch (including thenew_depsguard). Our full app test-suite passes with it and the leak is gone. Happy to turn it into a PR if the approach (or a variant) is acceptable — I imagine there are reasons deriveds aren't tracked on their parent effect anymore, so treat it as a starting point: