SolidJS createRoot and Owner Cleanup
The console warns “computations created outside a createRoot or render will never be disposed”, effects in a data service keep firing after the page that needed them is gone, and each dashboard visit adds another set of memos to memory. This guide from Svelte and SolidJS Memory Management, part of Framework-Specific Memory Optimization, explains Solid’s ownership tree, why computations need an owner to be disposed, and how to use createRoot, runWithOwner and onCleanup so reactive code releases memory reliably.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Warning: computations created outside a root will never be disposed | createEffect/createMemo called with no current owner |
Create inside a component or createRoot and dispose |
Computations disposed with their owner |
| Effects run after the feature that created them closed | createRoot used but its dispose never called |
Keep dispose and call it on teardown |
Effects stop; closures released |
Computations created after await are never cleaned up |
Async boundary lost the component owner | Capture getOwner() and use runWithOwner |
Ownership restored |
| Sockets/timers stay open after unmount | Created in effects without onCleanup |
Register teardown with onCleanup |
Resources released on dispose |
| List item state retained after removal | Per-item computations outside the <For> item owner |
Create them inside the item component | Item scope disposed on removal |
Root Cause: Disposal Walks the Owner Tree
In Solid, every reactive computation — createEffect, createRenderEffect, createMemo, createComputed, and the effects the compiler generates for JSX bindings — is created under the current owner. Components run inside an owner created by render or their parent; control-flow components (<Show>, <For>, <Switch>) create child owners for their branches and items. When an owner is disposed — the app unmounts, a <Show> condition flips, an item leaves a <For> list — Solid walks the tree, disposes every child computation, unsubscribes them from their signals, and runs every onCleanup callback registered under that owner. That walk is what releases memory: a disposed computation is no longer in any signal’s observer list, so its closure becomes collectable.
A computation created with no current owner is never visited by any disposal walk. It stays in the observer lists of the signals it reads, and its closure — often capturing component state, DOM nodes or large data — remains reachable for as long as those signals are. Solid detects this in development and prints a warning. The common ways to end up there are creating effects at module level, inside setTimeout or promise callbacks, after an await in a component or resource fetcher, or in event handlers — all places where the component’s owner is no longer current, similar to the lost-scope problem in Vue described in using effectScope for Vue cleanup.
createRoot(fn) creates a new owner that is not attached to any parent and passes its dispose function to fn. It is the right tool for reactive logic with its own lifetime — a sync service, a per-connection state machine — but because nothing else disposes it, forgetting to call dispose turns the root into a permanent container of computations. getOwner() captures the current owner and runWithOwner(owner, fn) runs fn under it, which is how you re-attach work that happens after an async boundary to the component that should own it.
Step-by-Step Fix
- Turn warnings into failures. In development and tests, treat Solid’s “computations created outside a createRoot” warning as an error (fail tests on
console.warn). Verification: every ownerless computation surfaces with a stack trace. - Move computations into their owner. Create effects and memos synchronously in the component body, not in callbacks. Verification: the warning disappears for those sites.
- Re-attach async work with
runWithOwner. Captureconst owner = getOwner()in the component, then create computations afterawaitinsiderunWithOwner(owner, () => …). Verification: those computations are disposed when the component unmounts. - Give services explicit roots — and dispose them. Wrap long-lived reactive logic in
createRoot((dispose) => …), returndispose, and call it when the service’s lifetime ends (logout, connection close). Verification: after disposal, the service’s effects stop firing. - Register
onCleanupfor every external resource. Close sockets, clear timers, remove listeners and destroy widgets inonCleanupinside the effect or component that created them. Verification: unmounting leaves no active resources. - Verify with repeated mounts. Toggle the component twenty times and take a heap snapshot. Verification: closures from the component and its effects do not accumulate.
Command and Code Reference
Use case: keep ownership across an async boundary.
import { getOwner, runWithOwner, createEffect, onCleanup, onMount } from 'solid-js';
function Dashboard() {
const owner = getOwner(); // capture synchronously
onMount(async () => {
const config = await loadConfig(); // owner is no longer current here
runWithOwner(owner, () => {
createEffect(() => applyTheme(config.theme(), document.body));
const timer = setInterval(refresh, config.refreshMs);
onCleanup(() => clearInterval(timer)); // runs when Dashboard is disposed
});
});
return <DashboardView />;
}
Use case: a service with its own root and an explicit disposal.
import { createRoot, createSignal, createEffect, onCleanup } from 'solid-js';
export function createPresence(userId) {
return createRoot((dispose) => {
const [online, setOnline] = createSignal([]);
const ws = new WebSocket(`wss://presence.example.com/${userId}`);
ws.onmessage = (e) => setOnline(JSON.parse(e.data));
onCleanup(() => ws.close()); // part of the root: runs on dispose
createEffect(() => updateBadge(online().length));
return { online, dispose }; // owner of this object must call dispose
});
}
// const presence = createPresence(me.id); … on logout: presence.dispose();
Use case: fail tests on ownership warnings.
// test/setup.js (Vitest)
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation((msg, ...rest) => {
if (String(msg).includes('will never be disposed')) throw new Error(`Solid ownership: ${msg}`);
console.info(msg, ...rest);
});
});
Verification and Regression Prevention
Verify with repeated mount/unmount cycles and service start/stop cycles: effect closures, active sockets and timers return to baseline, the ownership warning never appears, and heap snapshots after teardown contain no closures from the component or service. For roots, add a unit test that creates and disposes the service many times and asserts that effects stop (for example by counting side-effect calls after dispose).
Keep ownership rules in the team’s conventions: computations are created synchronously in components or inside an explicit root; async continuations use runWithOwner; every external resource has an onCleanup. Solid’s resource primitives (createResource) manage async data with ownership built in, which removes many of the async-boundary pitfalls; prefer them over hand-written fetch-then-create-effect code. For the equivalent in Svelte, see Svelte 5 effect teardown and runes.
Edge Cases and Gotchas
Event handlers are not owners
Creating an effect inside a click handler has no owner, because handlers run outside the reactive tree. Create reactive state and effects in the component body and let handlers only update signals.
Nested roots
A createRoot inside a component is detached from the component’s owner, so disposing the component does not dispose it. If the root’s lifetime should match the component’s, call its dispose from the component’s onCleanup, or use runWithOwner instead of a new root.
Signals do not need disposal
Signals are plain values with observer lists; they are collected when unreachable. What needs disposal are computations that observe them. Module-level signals are fine; module-level effects are not, unless inside a managed root.
Stores and large data
createStore wraps objects in proxies for fine-grained tracking. For large, read-only datasets, consider plain objects in a signal, or reconcile updates, to limit proxy overhead and memory.
Frequently Asked Questions
What does “computations created outside a createRoot or render will never be disposed” mean?
It means an effect or memo was created when no owner was current, so Solid has nothing to dispose it with. It will stay subscribed to its signals and keep its closure alive for the rest of the session. Create it inside a component or an explicit root.
When should I use createRoot?
For reactive logic whose lifetime is independent of any component — services, connections, global synchronisation. Always keep the dispose function it provides and call it when that logic should end.
How do I keep ownership after await in Solid?
Capture the owner with getOwner() before the await, then wrap computations created afterwards in runWithOwner(owner, () => …). They will then be disposed with the component. Using createResource for async data avoids the problem in many cases.
Does onCleanup work outside components?
It registers a cleanup on the current owner, whatever that is — a component, an effect, or a root. Called with no owner, it has nothing to attach to. Inside an effect, it runs before the effect re-runs and when it is disposed.
How can I see leaked computations in a heap snapshot?
Filter by your component’s or service’s function names; surviving effect closures appear as closures retained through signal observer lists. A count that grows by the same number on each mount indicates computations that were never disposed.
Related
- Svelte and SolidJS Memory Management — the parent topic
- Svelte Store Subscription Leaks — the store-contract side of fine-grained reactivity
- Unsettled Promises That Leak Their Closures — async continuations that retain component state
- Framework-Specific Memory Optimization — the section overview