Svelte and SolidJS Memory Management
Svelte and SolidJS skip the virtual DOM and update the page through fine-grained reactive graphs — signals, derived values and effects wired directly to DOM nodes. That design is fast and allocation-light, but it moves memory responsibility into the reactive graph itself: every effect, subscription and derived value belongs to an owner, and anything created without one, or not cleaned up by one, lives for the rest of the session. This topic, part of Framework-Specific Memory Optimization, is for engineers building with Svelte (including Svelte 5 runes) or SolidJS who need to know where cleanup is automatic, where it is not, and how to find the leaks: store subscriptions in Svelte, effect teardown with runes, and Solid’s createRoot and owner cleanup.
Conceptual Grounding
Both frameworks build a reactive graph at runtime. Sources (Svelte’s $state, Solid’s createSignal, stores) hold values; derived nodes ($derived, createMemo) compute from sources; effects ($effect, createEffect, createRenderEffect) run side effects — including DOM updates — when their dependencies change. Each node keeps links to its dependencies and dependents, so a live effect keeps its closure, and everything the closure captured, reachable from the sources it reads. If a long-lived source (a global store, a module-level signal) is read by an effect that should have died with a component, the source’s subscriber list keeps the effect — and through it the component’s state and DOM — alive.
To make cleanup automatic, both frameworks track ownership. In Solid, every computation is created under the current owner: a component’s render, a createRoot, or another computation. When an owner is disposed — a component unmounts, a <Show> branch closes, a root’s dispose is called — all computations it owns are disposed and their onCleanup callbacks run. Svelte 5 does the same with components and effect scopes: effects created during component initialisation are destroyed with the component, and the function returned from an $effect runs before re-execution and on destruction. Svelte 4’s compiler inserted equivalent teardown for component code and for $store auto-subscriptions.
Leaks appear at the edges of that ownership model. Code outside components: Solid computations created outside any owner are never disposed (Solid warns about this in development); Svelte 5’s $effect.root creates an effect scope that you must destroy yourself; module-level code that subscribes to stores has no component to clean it up. Manual subscriptions: calling a store’s subscribe() in script code, rather than using the $store syntax, returns an unsubscribe function that nobody calls. Non-reactive side effects: timers, DOM listeners, sockets and third-party widgets created in effects need explicit teardown in the effect’s cleanup. And long-lived sources holding large values: a global store that keeps the last large payload retains it regardless of which components still use it.
None of these are exotic. They are the ordinary ways real applications grow: a helper module that starts syncing state at import time, a component that subscribes in a callback after data arrives, a widget initialised in an effect without a matching destroy. Each one is cheap to fix once identified, which is why the workflow below focuses on identifying the owner that should have cleaned up and did not.
Diagnostic Workflow
- Reproduce with repeated mounts. Toggle the suspect component (open/close a panel, navigate to a route and back) twenty times in a production build. Expected output: a repeatable flow. Metric: JS heap after the cycles versus before, after forced GC.
- Snapshot and look for component DOM and closures. In DevTools → Memory, take a heap snapshot; filter by
Detachedand by your component’s function names. Expected output: detached subtrees or closures multiplying with the cycle count indicate a leak. - Follow retainers to the reactive source. From a detached node or closure, read the retainer path until you reach a store, signal or module variable. Expected output: a path through a subscriber list (
subscribers,observers,reactionsor similar internal fields). Metric: one identified owner-less subscription. - Check ownership at the creation site. Determine whether the subscription was created inside a component/effect (should be disposed) or outside (needs manual disposal). Expected output: the missing
onCleanup, returned teardown, unsubscribe or root disposal. - Add the missing teardown. Use
$storeauto-subscription, return cleanups from$effect, registeronCleanupin Solid, or disposecreateRoot/$effect.rootscopes. Expected output: repeated cycles no longer grow. - Verify with the same flow. Re-run steps 1 and 2. Metric: heap difference after twenty cycles within noise; no detached component DOM.
Code Patterns & Signatures
Use case: a Svelte 5 component whose effect owns a timer and a subscription.
<script>
import { prices } from './stores.js'; // writable store at module level
let { symbol } = $props();
let latest = $state(null);
$effect(() => {
// Reads `symbol`, so it re-runs when the prop changes
const unsubscribe = prices.subscribe((all) => { latest = all[symbol]; });
const timer = setInterval(() => prices.refresh(symbol), 10_000);
return () => { // before re-run and on destroy
clearInterval(timer);
unsubscribe();
};
});
</script>
<p>{symbol}: {latest ?? '…'}</p>
Use case: SolidJS computations owned by a component, with cleanup.
import { createSignal, createEffect, onCleanup } from 'solid-js';
function LivePrice(props) {
const [price, setPrice] = createSignal(null);
createEffect(() => {
const ws = new WebSocket(`wss://prices.example.com/${props.symbol}`); // tracked prop
ws.onmessage = (e) => setPrice(JSON.parse(e.data).price);
onCleanup(() => ws.close()); // runs before re-run and on dispose
});
return <output>{price() ?? '…'}</output>;
}
Use case: reactive code outside components with an explicit owner.
// Solid: a feature service with its own root, disposed when the feature closes
import { createRoot, createEffect } from 'solid-js';
export function startSync(store) {
return createRoot((dispose) => {
createEffect(() => saveDraft(store.draft)); // owned by this root
return dispose; // caller must call it
});
}
// const stop = startSync(store); … later: stop();
Symptom-to-Fix Reference
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Store subscribe callbacks multiply with mounts |
Manual subscription in component script without unsubscribe | Use $store, or unsubscribe in teardown |
One subscriber per mounted component |
| Solid warns “computations created outside a createRoot” | Effect or memo created at module level or in an async callback | Wrap in createRoot or runWithOwner and dispose |
Computation disposed with its owner |
| Timers keep running after component destroy | Timer created in effect without teardown | Return teardown from $effect / onCleanup |
No background work after destroy |
| Detached DOM from destroyed components | Third-party widget or listener holding nodes | Destroy the widget in cleanup | Component DOM collectable |
| Global store retains a large last value | Long-lived source holds its latest payload | Reset or scope large data to components | Payload released |
| Memory grows with list items | Per-item effects or stores created without keyed teardown | Use keyed {#each}/<For> so items are disposed |
Item state released on removal |
Edge Cases & Gotchas
Async boundaries lose the owner
In Solid, code that runs after an await or in a setTimeout callback no longer has the component as its current owner; computations created there are ownerless. Capture the owner with getOwner() and create them with runWithOwner(owner, …), or create them before the async boundary. Svelte 5 effects have the same concern: create them during component initialisation, not in callbacks.
Keyed versus unkeyed lists
Unkeyed lists reuse item components for different data, which avoids churn but can leave per-item subscriptions pointing at the wrong item. Keyed lists ({#each items as item (item.id)} or Solid’s <For>) create and dispose item scopes as items come and go, which keeps ownership aligned with data.
Context values outliving components
Values placed in Svelte context or Solid context are fine, but copying them into module-level structures escapes ownership. Keep them in component scope.
SSR and hydration
Server rendering creates reactive graphs per request. Effects typically do not run on the server, but stores and roots created during rendering must be disposed at the end of each request, or they accumulate in the server process.
Transitions and outro animations
Svelte keeps an element in the DOM until its outro transition finishes, and Solid transition libraries do the same. If a parent is destroyed while an outro is running, or an outro never completes because the element is hidden, the element and its closures stay alive longer than expected. Keep outro durations short, avoid transitions on elements inside long lists that are frequently replaced, and verify with a snapshot taken after animations have settled.
Global stores holding component references
A common shortcut is to put callbacks, component instances or DOM elements into a global store “so other components can reach them”. Anything stored there lives as long as the store, regardless of the component’s lifecycle. Store data in global state and keep functions and elements local, exposing them through context instead.
Development warnings are your friend
Solid’s ownership warnings and Svelte’s runtime checks catch many leaks early. Treat them as errors in tests rather than noise in the console.
Third-Party Libraries in Fine-Grained Components
Most real leaks in Svelte and Solid apps are not in the reactive graph at all but at its boundary with imperative code: chart libraries, maps, rich-text editors, date pickers, video players. These libraries create their own DOM, listeners, timers and sometimes WebGL contexts, and they know nothing about owners or effect scopes. The component that creates them must destroy them.
In Svelte, the idiomatic places are an $effect that creates the instance and returns a teardown calling the library’s destroy(), or onMount returning a cleanup function (onMount supports returning a function that runs on destroy). Svelte actions — use:chart={options} — are a particularly good fit: an action receives the node, may return an update function for option changes, and a destroy function that Svelte calls when the element is removed. Encapsulating each library in an action keeps teardown next to setup and makes it reusable.
In Solid, create the instance in onMount (or a createEffect that tracks the options it needs) and register onCleanup(() => instance.destroy()) in the same owner. Solid’s ref callback gives you the element synchronously during rendering, so you can also set up the instance there — but make sure cleanup is registered under the component’s owner rather than inside an event handler.
Watch in particular for libraries that attach global listeners (resize, scroll, keydown) or that register themselves in module-level registries: even a correct destroy() call on your side can be undone by a library bug, which is why heap snapshots after repeated mounts remain the final check. The patterns in third-party library memory leaks apply unchanged to both frameworks.
Measuring Memory in Svelte and Solid Apps
Because fine-grained frameworks allocate little during updates, the heap line in a Performance recording is usually calm, and growth stands out clearly. Use repeated mount/unmount cycles as the basic test: navigate to a route and back twenty times, or toggle a {#if}/<Show> branch, force garbage collection, and compare heap sizes. Growth that scales with the number of cycles is retention; growth that stops after the first few cycles is usually caching or code compilation.
When you open snapshots, remember that compiled Svelte components and Solid JSX produce many small closures with generated names. Searching the snapshot’s class filter for your component’s file-level function names, or for distinctive variable names from its script, finds them faster than looking for a component class. Then follow retainers upward; a path that passes through an observer or subscriber collection of a signal or store points to reactive retention, while a path through (Global handles) or an event listener points to an imperative resource that was not released.
For development builds, Solid’s ownership warnings and Svelte’s runtime diagnostics catch many issues immediately, and both frameworks’ devtools extensions can show live component trees. Treat those tools as a first pass and heap snapshots of production builds as the authority, as in the three-snapshot technique.
Comparing with React and Vue
The mental model maps closely onto patterns elsewhere on this site. Svelte’s returned $effect teardown and Solid’s onCleanup play the role of React’s effect cleanup, covered in fixing useEffect cleanup memory leaks. Solid’s createRoot and Svelte’s $effect.root correspond to Vue’s effectScope, described in using effectScope for Vue cleanup. The main practical difference is granularity: because fine-grained frameworks create more, smaller reactive nodes — often one per binding — a missing owner can leak many small computations rather than one component, and heap snapshots show long lists of closures rather than a single large instance. Filtering snapshots by the function names in your component, rather than by a class name, is therefore the fastest way in.
The allocation side is usually better than in virtual-DOM frameworks: updates touch only the affected nodes, so garbage churn during interactions is lower. That makes retention — rather than churn — the memory problem worth watching in Svelte and Solid apps, and it is why this topic focuses on ownership and teardown.
Frequently Asked Questions
Do Svelte and Solid need manual cleanup at all?
Inside components, most reactive cleanup is automatic: effects and derived values are disposed with their component. You still need teardown for non-reactive side effects created in effects — timers, listeners, sockets, widgets — and for any reactive code created outside components.
Why does Solid warn about computations created outside a root?
Because nothing owns them, so nothing will ever dispose them; they stay subscribed to their sources and keep their closures alive. Wrap such code in createRoot and call its dispose, or run it under an existing owner with runWithOwner.
Is $store auto-subscription safe from leaks?
Yes. In components, the $store syntax subscribes when the component is created and unsubscribes when it is destroyed. Leaks come from calling store.subscribe() manually in script code without unsubscribing.
Are Svelte 5 runes different from Svelte 4 for memory?
The ownership principles are the same, but runes make effects explicit: $effect returns a teardown, and $effect.root creates a manually managed scope. Code that relied on implicit component teardown generally carries over; code that created subscriptions in plain JavaScript modules still needs explicit cleanup.
Are Svelte actions a good place for cleanup?
Yes. An action receives the element, can return an update function for changed parameters and a destroy function that Svelte calls when the element leaves the DOM. That makes actions an excellent wrapper for third-party widgets, observers and listeners tied to one element, because setup and teardown live side by side and are reused everywhere the action is applied.
Do fine-grained frameworks use less memory than virtual-DOM frameworks?
Usually they allocate less during updates, because they skip building and diffing virtual trees, which reduces garbage churn and GC pauses. Retained memory depends on your data and on whether effects and subscriptions are cleaned up; a leaking Solid or Svelte app grows just like a leaking React app. The framework choice changes the churn profile, not the need for ownership discipline.
What should a memory test for a Svelte or Solid component look like?
Mount the component, exercise it, unmount it, and repeat twenty times in a production build; force garbage collection and compare heap size and detached DOM counts with the starting point. Automate the same cycle with Playwright or a Memlab scenario so that a new effect without teardown fails the build instead of reaching users.
How do I find leaked effects in a heap snapshot?
Filter by the names of functions in your component or by closure names, then follow retainers from a closure to the source whose subscriber list holds it. A growing number of identical closures after repeated mounts points to effects that were never disposed.
Related
- Svelte Store Subscription Leaks — manual subscriptions and
$store - Svelte 5 Effect Teardown and Runes —
$effect, teardown and$effect.root - SolidJS createRoot and Owner Cleanup — ownership and disposal in Solid
- Framework-Specific Memory Optimization — the parent section