Tie every observer's teardown to the same lifecycle scope that created it — an action's destroy or an $effect's returned function in Svelte, onCleanup in the creating scope in Solid — and never create observers at a component's top level, where no teardown hook is guaranteed to match.

Problem / Scenario Context

A team maintains two apps: a SvelteKit marketing site and a SolidStart dashboard. Both slowly leak memory during long sessions. Heap snapshots (see finding observer leaks with heap snapshot diffing) show ResizeObserver instances accumulating on the dashboard and detached <section> elements accumulating on the marketing site. The code looks tidy — every observer has a cleanup somewhere — but some cleanups are registered in the wrong place and never run, or run for the wrong element.

The Svelte Actions & Solid Primitives topic introduces both frameworks' binding models; this page maps their teardown semantics precisely.

Mechanics Explanation

Svelte has three teardown points:

  • Action destroy() — runs when the element the action is on is removed from the DOM, for any reason: component unmount, {#if} becoming false, an {#each} item removed. After an outro transition, if there is one.
  • onDestroy() — runs when the component is destroyed. It does not run when an element inside the component is removed by a conditional block.
  • Svelte 5 $effect return value — runs before the effect re-runs (because a dependency changed) and when the component is destroyed.

Solid has one mechanism with scope-dependent meaning:

  • onCleanup(fn) registers fn with the current owner. In a component body, that is the component; in a createEffect or createMemo, it is that computation (so it runs before each re-run too); in a <For> row or <Show> branch, it is that row or branch; in a ref callback, it is the owner that created the element.

Leaks happen when teardown is registered with a longer-lived scope than the observed element (the element goes, the observation stays), or when the observer is created outside any scope, such as at module level or in an event handler, where onCleanup has no owner and does nothing — Solid warns about this in development.

When Each Teardown Hook RunsA grid of teardown hooks against three events. Svelte's action destroy runs when the element is removed, when a conditional hides it, and when the component unmounts. Svelte's onDestroy runs only when the component unmounts. Svelte 5 effect teardown runs when dependencies change and on unmount. Solid onCleanup in a For row runs when that row is removed and on unmount; in a component body it runs only on unmount.Element removed by if/eachDependencies changeComponent unmountsSvelte action destroyyesno, update()yesSvelte onDestroynonoyesSvelte 5 $effect teardownnoyesyesSolid onCleanup in rowyesnoyesSolid onCleanup in bodynonoyesSolid onCleanup in effectif effect owns ityesyes

Comparison Table: Correct Hook per Observer Placement

Observer created in… Tear down with… Common mistake
Svelte action action's destroy using onDestroy of the parent
Svelte onMount function returned from onMount forgetting that nested {#if} elements need their own
Svelte 5 $effect the effect's returned function reading extra state so it rebuilds constantly
Solid ref callback onCleanup inside the callback calling onCleanup in the component body
Solid createEffect / createMemo onCleanup inside it creating in an async callback after await
Module-level shared observer refcount and unobserve per element never disconnecting because "it is global"

Minimal Reproducible Example

HTML
<!-- Svelte: leaks sections removed by the {#if} -->
<script>
  import { onMount, onDestroy } from 'svelte';
  let showDetails = $state(false);
  let details;
  const io = new IntersectionObserver(() => {});
  $effect(() => { if (showDetails && details) io.observe(details); });
  onDestroy(() => io.disconnect());       // runs only when the whole component goes
</script>
{#if showDetails}<section bind:this={details}></section>{/if}
TSX
// Solid: onCleanup after an await has no owner, so it never runs.
function Chart() {
  let el!: HTMLDivElement;
  onMount(async () => {
    await loadChartLib();
    const ro = new ResizeObserver(() => redraw());
    ro.observe(el);
    onCleanup(() => ro.disconnect());     // too late: owner context lost after await
  });
  return <div ref={el} />;
}

declare function loadChartLib(): Promise<void>;
declare function redraw(): void;

Toggling showDetails repeatedly leaves each old <section> observed and retained. Mounting and unmounting Chart leaves every ResizeObserver alive.

Production-Safe Solution

Svelte: attach to the element, not the component.

HTML
<script lang="ts">
  import { inView } from '$lib/actions/in-view';
  let showDetails = $state(false);
</script>

{#if showDetails}
  <!-- destroy() runs when this section is removed, not just when the component unmounts -->
  <section use:inView={ { onchange: (v) => console.log('details visible', v) } }>…</section>
{/if}

Solid: register cleanup before any await, or capture the owner.

TSX
import { getOwner, runWithOwner, onCleanup, onMount } from 'solid-js';

function Chart() {
  let el!: HTMLDivElement;
  let ro: ResizeObserver | undefined;
  onCleanup(() => ro?.disconnect());            // registered synchronously, in the component scope

  onMount(async () => {
    await loadChartLib();
    if (!el.isConnected) return;                // unmounted while loading
    ro = new ResizeObserver(() => redraw());
    ro.observe(el);
  });
  return <div ref={el} />;
}

// Alternative when cleanup must be registered after async work:
function withOwner<T>(fn: () => T): () => T {
  const owner = getOwner();
  return () => runWithOwner(owner, fn)!;
}

The Svelte fix moves the observation onto the element's own lifecycle. The Solid fix registers the cleanup synchronously, before the await, so it always has an owner; the observer variable is filled in later, and the cleanup handles either state. The isConnected check covers the case where the component unmounted during the load.

Guaranteeing Teardown, Step by StepFour steps. Identify the lifecycle of the element being observed, not of the component. Create the observation in the hook bound to that element. Register the teardown synchronously in the same scope, before any await. Verify with a heap snapshot that toggling and navigation leave no observers or detached elements behind.1Find the element's lifecycleConditional block, list row or whole component?2Create in the matchinghookAction or row ref for elements; onMount or effect for components.3Register teardownsynchronouslydestroy, returned function, or onCleanup before any await.4VerifyToggle and navigate repeatedly, then compare heap snapshots.

Shared Observers and Reference Counting

Module-level shared observers — the pool pattern used throughout this section — outlive every component by design. They still need teardown at the element level: every registration must be paired with an unobserve, and the observer itself should disconnect when its last element leaves, so an app that navigates away from all observed content does not keep an idle observer and its callback alive.

TypeScript
let count = 0;
let shared: IntersectionObserver | null = null;

export function register(el: Element): () => void {
  shared ??= new IntersectionObserver(onEntries);
  shared.observe(el);
  count++;
  let released = false;
  return () => {
    if (released) return;                 // idempotent: safe if called twice
    released = true;
    shared?.unobserve(el);
    if (--count === 0) { shared?.disconnect(); shared = null; }
  };
}

declare function onEntries(entries: IntersectionObserverEntry[]): void;

Idempotent release functions matter in both frameworks: a Svelte action with a once option may release itself early and then have destroy call it again; a Solid row may be disposed after an explicit release. Neither double call should decrement the count twice.

Refcounted Shared Observer LifecycleFour boxes. The first registration creates the shared observer. Further registrations increment the count and observe their elements. Each release unobserves its element and decrements the count, ignoring repeated calls. When the count reaches zero the observer disconnects and is dropped.First registercreate observer, count1More registerobserve, count upRelease(idempotent)unobserve, count downCount reaches 0disconnect, dropinstance

Verification Steps

  • Toggle conditional content 20 times and compare heap snapshots: detached elements should not grow.
  • Mount and unmount components that create observers after async work and confirm instance counts stay flat.
  • Watch Solid's development warnings for "cleanups created outside a createRoot or render will never be run".
  • Call release functions twice in a test and confirm the shared count does not go negative.
  • Navigate across routes in SvelteKit or SolidStart and confirm shared observers disconnect when no page uses them.

Common Mistakes to Avoid

  • Using component-level teardown for element-level observations. Conditional and list content outlives or predeceases its component.
  • Registering onCleanup after await. The owner context is gone.
  • Non-idempotent release functions. Double calls corrupt reference counts.
  • Treating shared observers as permanent. Unobserve per element and disconnect at zero.

FAQ

Does Svelte call an action's destroy before or after an outro transition?

After. The element stays in the DOM while it transitions out, so the action remains active until the transition completes and the element is removed.

Why does Solid lose the owner after await?

Owners are tracked synchronously on a stack while a computation or component runs. After an await, the continuation runs later in a new microtask with no owner on the stack, so onCleanup has nothing to attach to.

Is disconnect enough, or must I also unobserve?

disconnect stops observing every target of that observer and is enough when the observer is exclusively yours. For a shared observer, unobserve only your element, since other components are still using it.

Do observers keep components alive after unmount?

They keep their callback closures alive, and closures often capture component state and elements. That is how a forgotten observer turns into a leak of a whole component tree.

How do I test teardown automatically?

Mock the observer constructor in unit tests to count observe, unobserve and disconnect calls, and assert they balance after mounting and unmounting. For real browsers, count live instances with a DevTools-protocol query after navigation cycles.


↑ Back to Svelte Actions & Solid Primitives for Observers