---
title: "Destroy, Unmount and Memory Leaks"
description: "Why long-lived apps die of memory, how v6 makes destruction structural and gated, the isDisposing() guard, and the heap-snapshot receipts behind it."
source: https://lemonadejs.com/docs/destroy/
---

# Destroy and memory leaks

Destroy matters as much as mount, because long-lived apps die of memory
leaks. A component opened
thousands of times must release every resource it holds on close.

```javascript
const handle = mount(Component, root);
handle.unmount();   // DOM removed, bindings disposed, listeners gone,
                    // states unsubscribed, refs nulled, provably
```

`mount()` returns a handle. `unmount()` removes the DOM and runs every cleanup: the interval stops.

<!--example-->

```js
import { html, mount } from 'lemonadejs';

const Ticker = (props, { state, onMount }) => {
    const ticks = state(0);
    onMount(() => {
        const id = setInterval(() => ticks.value++, 500);
        return () => clearInterval(id);          // the cleanup a destroy must run
    });
    return html`<p>ticking: ${ticks}</p>`;
};

const App = (props, { state }) => {
    let root = null;
    let handle = null;
    const status = state('not mounted');

    const start = () => { if (!handle) { handle = mount(Ticker, root); status.value = 'mounted'; } };
    const stop = () => { handle?.unmount(); handle = null; status.value = 'unmounted: DOM removed, interval cleared'; };

    return html`<div>
        <button onclick="${start}">mount</button> <button onclick="${stop}">unmount</button> <i>${status}</i>
        <div ref="${(el) => (root = el)}"></div>
    </div>`;
};
```

## The problem

LemonadeJS v5 had a wound here: instances were nearly impossible to fully
destroy. Listeners registered on `document` were never removed (the v5
router and wheel leak theirs to this day), subscriptions outlived their
owners, and memory only grew.

The root cause is general: when cleanup is the author's responsibility,
leaks are the default. A closure holding a removed element pins the entire
detached subtree. A subscription without explicit disposal survives its
owner. Neither fails immediately or loudly.

For agent-written code the stakes are higher: an agent will not notice
the memory tab creeping. Destruction must be structural, and *gated*.

## How LemonadeJS solves it

**Everything a component creates is owned by its instance.** States created
with the `state` tool, derivations from `computed`, bindings, child
instances, branch entries, all registered to the instance and disposed by
`unmount()`, recursively, children first. There is no path where a binding
survives its component.

**Cleanup is the return value, not a second registration:**

```javascript
onMount(() => {
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
});
```

External DOM listeners need even less: `listen(document, 'mousedown',
closer)` is removed by disposal automatically; raw `addEventListener`
never appears in component code (see [Lifecycle](/docs/lifecycle/)).

**Refs cannot pin corpses.** Object refs (`ref()`) are nulled when the DOM
they point to is disposed, branch swaps included, not only full unmounts.
A ref held in a long-lived closure never retains a dead subtree. The engine
also blurs any focused node it removes: the document's "last focused
element" reference is a classic accidental retainer.

**Web components destroy by default.** A custom element removed from the
DOM unmounts its instance (with a one-microtask grace so same-tick moves
survive). The v5 policy was keep-alive, which meant every React/Vue host
that removed an element leaked a subscribed tree. The policy flipped, and
the reasoning is documented in the source.

**Interactions hold one cleanup, not one per gesture.** Drag systems (the
modal's move/resize, the datagrid's column resize, the slider, the gantt)
register a single persistent release; an unmount mid-drag releases the
in-flight listeners. The test suites assert listener *balance*: counts
after stacked gestures and mid-drag unmounts return to baseline.

### isDisposing(), the focus corollary

Destruction has a subtle observable side effect: removing a focused element
makes the browser fire `focusout` exactly as if the user clicked away.
Three Studio blocks independently hit this (an editor that committed on
blur, a dropdown that closed mid-open, a dock that lost its click). The
engine now exposes the truth:

```javascript
onfocusout="${(e) => {
    if (isDisposing()) return;   // the renderer caused this, not the user
    close();
}}"
```

`isDisposing()` is true while the renderer is removing or blurring DOM,
branch swaps, slot detaches, unmounts. One line replaces every hand-rolled
mute flag.

## The receipts

The claims are gated, not promised. The library's own suite runs, on every
change:

- **Listener balance accounting.** `document.addEventListener` and
  `removeEventListener` are counted across stress cycles (including
  unmounts mid-drag); the delta must be zero.
- **WeakRef collectability.** Component instances and their DOM are held
  via `WeakRef` across forced GC; they must be collected. (The methodology
  matters and is documented in the tests: never dereference between GC
  passes, because V8's conservative stack scanning pins what you touch, and
  flush jsdom's per-selector query cache, which famously retains the last
  result set.)
- **Heap growth.** 600 create/interact/destroy cycles must stay under
  4MB of growth.
- **Real Chrome, real numbers.** 100,000 modal create/open/drag/destroy
  cycles in a live browser: **zero detached DOM nodes** in the heap
  snapshot afterwards; the only growth was V8's JIT warming up (~270KB of
  compiled code), which plateaus. The snapshot-diff tool that proved it
  ships in the repo (`scripts/diff-snapshots.mjs`) so the claim can be
  re-verified on any machine.

If you build a block: `verify()` plus the stress patterns above are the
bar. A component that cannot die cleanly does not enter the registry.