Svelte 5 Effect Teardown and Runes

After migrating to Svelte 5 runes, a component that tracks a selected item starts a new websocket every time the selection changes, an analytics effect created in a helper module never stops, and a 50,000-row dataset held in $state uses far more memory than the same array did before. This guide from Svelte and SolidJS Memory Management, in Framework-Specific Memory Optimization, explains the teardown contract of $effect, when effects are owned and when they are not, and how rune choices affect memory.

Symptom Root Cause Immediate Action Measurable Impact
Resources multiply as tracked state changes $effect creates a socket/timer each run with no teardown Return a teardown function from the effect One live resource per effect
Effect keeps running after its feature is gone Created with $effect.root and never destroyed Keep the returned destroy and call it Effect stops; closure released
Effect created in a callback throws or is unowned $effect used outside component initialisation Create during initialisation, or use $effect.root explicitly Predictable ownership
Large dataset uses much more memory in $state Deep reactive proxies created for every nested object Use $state.raw for large, replace-only data Plain objects, no proxies
Derived values copied via effects Using $effect to set state from other state Use $derived No extra state, fewer allocations

Root Cause: Effects Re-run, and Only Owned Effects Are Destroyed

$effect(fn) runs fn after the component mounts and again whenever state read inside fn changes. If fn returns a function, Svelte treats it as the teardown: it runs immediately before the next execution of the effect and once more when the effect is destroyed. Resources created in the effect body — websocket connections, intervals, listeners on window, third-party instances — therefore need to be released in that teardown, or each re-run adds another. Because the effect re-runs whenever any tracked value changes, a component whose effect reads a frequently changing value can create resources at a high rate without anyone noticing, the same failure mode as React effects without cleanup discussed in React Strict Mode double effects and leak detection.

Effects created during component initialisation are owned by the component and destroyed with it. Effects cannot normally be created elsewhere; for code that needs reactive effects outside a component’s lifecycle — in a module, a store-like class, a test — Svelte provides $effect.root(fn), which creates a separate scope and returns a function that destroys it. That scope is not tied to any component: if you do not call the returned function, its effects live forever, like Solid’s roots described in SolidJS createRoot and owner cleanup.

Runes also affect memory directly through reactivity depth. $state(value) makes objects and arrays deeply reactive by wrapping them in proxies, lazily, as nested objects are accessed. For ordinary UI state that is negligible; for large datasets — thousands of rows with nested objects — the proxies and their bookkeeping add memory and slow access. $state.raw(value) keeps the value as a plain object that is only reactive when reassigned as a whole, which suits large, immutable data that is replaced rather than mutated. Finally, $derived(expr) computes values from state without storing an extra copy you manage; using $effect to write derived values into other $state duplicates data and adds effect overhead.

The $effect teardown contract As the selected ID changes three times, the effect runs four times. Without a teardown, four websocket connections are left open. With a teardown returned from the effect, each run first closes the previous socket, so only one is open at any time, and destroying the component runs the teardown once more, leaving none. Without teardown run 1: socket A run 2: + socket B run 3: + socket C destroy: A, B, C still open With teardown returned from $effect run 1: socket A close A → socket B close B → socket C destroy: close C → none teardown runs before every re-run and once on destroy

Step-by-Step Fix

  1. List effects that create resources. Search for $effect( bodies that call new WebSocket, setInterval, addEventListener, new ResizeObserver, or third-party constructors. Verification: you know which effects need a teardown.
  2. Return a teardown from each. Make the effect return a function that closes, clears, removes or destroys everything the body created. Verification: changing tracked state repeatedly leaves exactly one live resource (check the Network panel’s WS tab or a counter).
  3. Narrow what the effect tracks. Read only the state that should trigger a re-run; wrap incidental reads in untrack(). Verification: the effect re-runs only when its intended inputs change.
  4. Audit $effect.root usage. For each call, store the returned destroy function and call it when the owning feature ends. Verification: after the feature closes, its effects stop (logs inside them go quiet).
  5. Use $state.raw for large data. Hold big, replace-only datasets with $state.raw and replace the whole value on update. Verification: heap snapshots show plain arrays of objects rather than proxies, and retained size falls.
  6. Replace state-syncing effects with $derived. Where an effect only computes a value from state and assigns it, use $derived instead. Verification: fewer effects and no duplicated data in snapshots.
50,000 rows: deep $state versus $state.raw Holding fifty thousand rows with nested objects in deep $state retains about 38 megabytes after the rows have been accessed, because proxies are created for accessed objects. Holding the same rows with $state.raw retains about 22 megabytes of plain objects. Retained after rendering 50,000 rows (approximate) $state (deep proxies) ~38 MB $state.raw (plain) ~22 MB illustrative; measure your own data shape with heap snapshots

Command and Code Reference

Use case: an effect that follows a selected ID and owns its socket.

<script>
  let { selectedId } = $props();
  let updates = $state.raw([]);                       // replaced, never mutated in place

  $effect(() => {
    const id = selectedId;                            // the only tracked dependency
    const ws = new WebSocket(`wss://feed.example.com/items/${id}`);
    ws.onmessage = (e) => { updates = [...updates.slice(-99), JSON.parse(e.data)]; };
    return () => {                                    // before the next run and on destroy
      ws.onmessage = null;
      ws.close();
    };
  });
</script>

Use case: effects outside components with an explicit lifetime.

// analytics.svelte.js — module using runes
import { session } from './session.svelte.js';

export function startAnalytics() {
  const destroy = $effect.root(() => {
    $effect(() => {
      track('route', session.route);                  // re-runs on route change
    });
  });
  return destroy;                                     // caller must call it
}

// const stopAnalytics = startAnalytics();  …  on logout: stopAnalytics();

Use case: derive instead of syncing.

<script>
  let items = $state.raw([]);
  let filter = $state('');

  // Instead of $effect(() => { visible = items.filter(...) }):
  const visible = $derived(items.filter((i) => i.name.includes(filter)));
</script>

Verification and Regression Prevention

Verify by driving tracked state changes repeatedly and by creating and destroying components: resources created in effects never exceed one per effect, effects created with $effect.root stop when their destroy function is called, and heap snapshots after destroy contain none of the component’s closures. For data-heavy views, compare retained size with $state versus $state.raw on realistic data to confirm the choice.

Adopt conventions that make teardown the default: effects that create resources must return a teardown (enforce in review, and consider a small helper like useSocket(url) that encapsulates creation and teardown), $effect.root calls must store their destroy function, and large replace-only data uses $state.raw. For store-based code in the same app, apply Svelte store subscription leaks as well.

Verifying rune effect teardown Drive tracked state changes repeatedly and create and destroy components. Resources created in effects never exceed one per effect, effects created with $effect.root stop when their destroy function is called, and heap snapshots after destroy contain none of the component’s closures. Repeated state changes and destroys One per effect Timers, listeners and sockets never exceed one per running effect. $effect.root stops Calling its destroy function ends every nested effect. Nothing after destroy Snapshots hold none of the component’s closures.

Edge Cases and Gotchas

$effect.pre and teardown

$effect.pre runs before DOM updates rather than after; the teardown contract is the same. Use it when you need to read DOM state before it changes, and still return a teardown for any resources.

Effects do not run during server rendering

$effect bodies do not run on the server, so server-side code paths never create their resources. Do not rely on effects for work that must also happen during SSR, and do not create resources at component initialisation outside effects, where they would run on the server too.

Mutating $state.raw values does nothing

Changes inside a raw object are not tracked. Always assign a new value (items = [...items, next]). If you need fine-grained updates of individual rows, keep row-level state separate or accept deep $state for that part of the data.

Async work inside effects

An effect’s teardown runs synchronously; it cannot await. Use an AbortController created in the effect and abort it in the teardown so pending requests are cancelled and their results ignored.

Frequently Asked Questions

When does the $effect teardown run?

Immediately before the effect re-runs because a tracked value changed, and once more when the effect is destroyed — usually when its component is destroyed. That makes it the right place to close anything the effect created.

Is $effect.root a memory leak risk?

It creates effects that are not owned by any component, so they live until you call the destroy function it returns. That is intended for long-lived logic, and it becomes a leak only when the destroy function is lost or never called.

Should I use $state or $state.raw for API data?

Use $state.raw for large data you replace wholesale, such as API responses and big lists, because it avoids creating deep proxies. Use $state for smaller objects you want to mutate property by property with fine-grained updates.

Can $derived replace most effects?

Any effect whose only job is to compute a value from other state and store it can be a $derived. Effects remain the right tool for side effects — network, DOM APIs, timers — which is exactly where teardown matters.

How do I debug which effects are still alive?

Temporarily log inside the effect body and teardown, then trigger its dependencies after the component is destroyed; any log means it is still alive. Heap snapshots filtered by the component’s function names show surviving closures and the reactive sources retaining them.