Skip to content

Memory leak: a $derived only read in untracked contexts (e.g. a $state initializer) is never disconnected from its dependencies on destroy #18501

Description

@northfacejmb

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);
+}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions