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.

Solid's owner tree and what disposal reaches The render root owns App, which owns a Dashboard component. Dashboard owns a Show branch and a For list whose items each have their own owner. Disposing Dashboard walks down the tree, disposing every computation and running onCleanup callbacks. An effect created inside a setTimeout callback has no owner and is never reached. A createRoot started by a service is detached and is disposed only when its own dispose function is called. render() root Dashboard owner Show branch For items dispose walks down, runs every onCleanup effect in setTimeout no owner → never disposed createRoot (service) detached: call dispose() neither is reached by Dashboard's disposal

Step-by-Step Fix

  1. 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.
  2. Move computations into their owner. Create effects and memos synchronously in the component body, not in callbacks. Verification: the warning disappears for those sites.
  3. Re-attach async work with runWithOwner. Capture const owner = getOwner() in the component, then create computations after await inside runWithOwner(owner, () => …). Verification: those computations are disposed when the component unmounts.
  4. Give services explicit roots — and dispose them. Wrap long-lived reactive logic in createRoot((dispose) => …), return dispose, and call it when the service’s lifetime ends (logout, connection close). Verification: after disposal, the service’s effects stop firing.
  5. Register onCleanup for every external resource. Close sockets, clear timers, remove listeners and destroy widgets in onCleanup inside the effect or component that created them. Verification: unmounting leaves no active resources.
  6. 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.
Live effect closures across dashboard visits A dashboard that creates three effects after awaiting its configuration, outside the component owner, accumulates three live effect closures per visit, 60 after twenty visits. Using runWithOwner to attach them to the component keeps the count at 3 while the dashboard is open and 0 after leaving. 60 0 effects created after await (ownerless) runWithOwner(owner, …) dashboard visits (0 → 20)

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.

Is this computation owned? Computations created synchronously in a component are owned and disposed with it. Computations created later in a callback or after an await have no owner and trigger the ownership warning; capture the owner with getOwner and run them with runWithOwner. Long-lived services should create their own root with createRoot and call dispose when they stop. Where is the effect created? Owned: disposed with the component in component body No owner: use getOwner + runWithOwner in a callback or after await createRoot, and call dispose on stop in a long-lived service

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.