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.
Step-by-Step Fix
- List effects that create resources. Search for
$effect(bodies that callnew WebSocket,setInterval,addEventListener,new ResizeObserver, or third-party constructors. Verification: you know which effects need a teardown. - 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).
- 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. - Audit
$effect.rootusage. 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). - Use
$state.rawfor large data. Hold big, replace-only datasets with$state.rawand replace the whole value on update. Verification: heap snapshots show plain arrays of objects rather than proxies, and retained size falls. - Replace state-syncing effects with
$derived. Where an effect only computes a value from state and assigns it, use$derivedinstead. Verification: fewer effects and no duplicated data in 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.
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.
Related
- Svelte and SolidJS Memory Management — the parent topic
- SolidJS createRoot and Owner Cleanup — the equivalent ownership model in Solid
- Angular Signals: effect() Cleanup and Memory — onCleanup semantics in Angular
- Framework-Specific Memory Optimization — the section overview