Skip to content

Detached DOM subtrees retained after destroy when templates capture multiple nodes (parentNode chains) #18096

@dimensi

Description

@dimensi

Summary

Hi!

While working with Svelte 5 in an SPA setup, we noticed detached DOM nodes accumulating over time after components are destroyed.

In our case, repeatedly mounting/unmounting a component like:

{#if chatId}
  <Chat />
{/if}

eventually leads to a noticeable number of detached elements (e.g. HTMLVideoElement, wrappers, buttons) remaining in memory. After several open/close cycles, this can grow quite large.

In heap snapshots, these nodes appear with detachedness != 0, often retained via system / Context (closure scope). This seems consistent with the behavior described in Chrome DevTools documentation:
https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots#uncover_dom_leaks
— where a reference to a single node can keep the whole detached subtree alive.


What might be happening

From what we can see, Svelte correctly removes nodes from the document (node.remove()) and clears effect internals.

However, the detached subtree itself remains internally connected (via parent/child relationships). If any closure still references one of those nodes (for example via bind:, event handlers, or template locals), the entire subtree may remain reachable.

Since compiled templates keep multiple DOM references within a shared closure, it’s not straightforward to fully clean this up at the application level.


Reproduction

Repo: https://github.com/dimensi/svelte-dom-leak-repro

  • SvelteKit SPA (ssr = false)
  • Run: npm install && npm run dev

Steps:

  1. Take heap snapshot (A)
  2. Interact with rows (play/pause, counters)
  3. Click “Stress 600 remounts”
  4. Click garbage collect in devtoos
  5. Take snapshot (B)

Detached DOM nodes tend to increase, especially after interactions.

Image

Possible direction (just for discussion)

One idea could be to extend teardown so that detached subtrees are not only removed from the document, but also fully disconnected internally, for example:

  • Clearing delegated event handler storage (event_symbol)
  • Resetting media elements (video/audio)
  • Recursively removing children to break parent/child chains

This might help avoid situations where a single lingering reference retains the entire subtree.


Experimental patch (PoC)

This is just a proof of concept that helped in our case (significantly reduced detached nodes), not necessarily a suggested final solution:

export function remove_effect_dom(node, end) {
	while (node !== null) {
		var next = node === end ? null : get_next_sibling(node);

		node.remove();
		deep_cleanup_node(node);
		node = next;
	}
}

function deep_cleanup_node(node) {
	while (node.firstChild) {
		deep_cleanup_node(node.firstChild);
		node.firstChild.remove();
	}
}

Environment

  • Svelte 5.x (observed on 5.55.x)
  • Chrome DevTools heap snapshots

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions