Detecting detached DOM nodes from real-time lists #

A live activity feed prepends a row for every WebSocket event and trims the list to the newest 200 rows. The page shows 200 rows all day, but after a few hours the tab uses a gigabyte of memory and scrolling stutters. The rows you trimmed left the document, but they did not leave memory: something still references them. Detached DOM nodes — elements removed from the page but reachable from JavaScript — are the characteristic leak of real-time UIs, because those UIs create and remove elements continuously for as long as the tab is open. A static page that leaks one node per click leaks a few hundred a day; a feed that leaks one per message leaks millions.

Root cause #

An element removed from the DOM is garbage only when nothing reachable still references it. Real-time list code tends to create exactly such references as a side effect of performance or convenience features.

Lookup maps. To update a row in place when an event for the same id arrives, code keeps Map<id, HTMLElement>. Trimming the list removes rows from the DOM but not from the map. Closures in handlers. An event handler or a WebSocket callback that captured a row element — to flash it on update, say — keeps it alive as long as the handler is registered. Observers. An IntersectionObserver or ResizeObserver observing each row holds strong references to observed elements until unobserve is called. Framework escape hatches. Refs to rendered rows stored in a long-lived array or store outlive the component that rendered them.

Each removed row that stays referenced also keeps its entire subtree alive — child elements, text nodes, attached listeners and any data hung off the element.

Heap retained by a feed at 5 events per second When trimmed rows are released, retained heap stays flat at about eighteen megabytes; when a lookup map keeps them, it grows to over one and a half gigabytes in eight hours. Heap retained by a feed at 5 events per second about 2 KB retained per removed row rows trimmed and released rows kept by a Map 0 MB 500 MB 1.0k MB 1.5k MB 2.0k MB 1 hour 784 MB 4 hours 1.6k MB 8 hours
The page shows the same 200 rows the whole time; only the invisible ones grow.

Resolution #

Make every reference to a row share the row’s lifetime. The simplest structure is to make the id-to-element map the source of truth for which rows exist, and to trim through it: remove from the map, unobserve, and remove from the DOM in one function, so no path can remove a row from one place but not the others. For references that do not need to prevent collection — metadata keyed by element — use a WeakMap, which never keeps its keys alive.

const MAX_ROWS = 200;

interface FeedEvent { id: string; text: string; at: number }

export class LiveFeed {
private rows = new Map<string, HTMLLIElement>(); // strong: exactly the rows on screen
private meta = new WeakMap<HTMLLIElement, FeedEvent>(); // weak: never keeps a row alive
private seen: IntersectionObserver;

constructor(private list: HTMLUListElement, ws: WebSocket, signal: AbortSignal) {
this.seen = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) this.markRead(e.target as HTMLLIElement);
});
// Event delegation: ONE listener on the list, not one closure per row.
list.addEventListener('click', (e) => {
const li = (e.target as Element).closest('li');
if (li) this.open(this.meta.get(li as HTMLLIElement));
}, { signal });
ws.addEventListener('message', (e) => this.upsert(JSON.parse(e.data)), { signal });
signal.addEventListener('abort', () => this.destroy(), { once: true });
}

private upsert(ev: FeedEvent) {
let li = this.rows.get(ev.id);
if (!li) {
li = document.createElement('li');
this.rows.set(ev.id, li);
this.seen.observe(li);
this.list.prepend(li);
}
li.textContent = ev.text; // no innerHTML: see the sanitizing guide
this.meta.set(li, ev);
this.trim();
}

// The ONLY way rows leave: map, observer and DOM together.
private remove(id: string) {
const li = this.rows.get(id);
if (!li) return;
this.rows.delete(id);
this.seen.unobserve(li);
li.remove();
}

private trim() {
// Map iteration order is insertion order: the first keys are the oldest rows.
for (const id of this.rows.keys()) {
if (this.rows.size <= MAX_ROWS) break;
this.remove(id);
}
}

private destroy() {
for (const id of [...this.rows.keys()]) this.remove(id);
this.seen.disconnect();
}

private markRead(_li: HTMLLIElement) { /* … */ }
private open(_ev?: FeedEvent) { /* … */ }
}

Event delegation removes a whole class of leaks: one listener on the list replaces a closure per row, so there is nothing per row to forget. Setting textContent instead of innerHTML is both faster and safe against injected markup, as covered in sanitizing WebSocket messages before rendering. Frameworks with keyed list rendering handle the DOM side for you, but maps, observers and refs you create alongside them are still yours to clean up. For very long feeds, rendering only the visible window avoids creating most of these nodes at all — see virtualizing live-updating lists.

What can keep a removed row alive Structures that can retain a removed list row: lookup maps, per-row closures and observers hold strong references, while WeakMap metadata and delegated listeners do not. What can keep a removed row alive Map<id, element> lookup for in-place updates; must shrink with the list strong Per-row closures handlers or timers that captured the element strong Observers IntersectionObserver / ResizeObserver until unobserve strong WeakMap<element, data> metadata that never prevents collection weak Delegated listener one listener on the parent, no per-row reference none Every strong reference needs a removal path that runs when the row leaves
Replace strong per-row references with weak or delegated ones wherever you can.

Finding detached nodes #

Chrome DevTools can show you exactly what is leaking and why. Let the feed run for a few minutes, then open the Memory panel and take a heap snapshot. In the class filter, type Detached — Chrome groups detached elements as Detached HTMLLIElement and similar. A healthy feed shows zero or a handful; a leaking one shows thousands, growing between snapshots. Select one and read the Retainers pane from bottom to top: it names the chain from a GC root to the node, such as Maprows in LiveFeed, which points directly at the reference to fix.

Chrome also offers a dedicated “Detached elements” profile type in the Memory panel in recent versions, which lists detached subtrees and their retainers without manually filtering a snapshot. For comparison over time, take two snapshots a few minutes apart and use the Comparison view: a positive delta in detached elements that tracks the number of events received is the leak’s fingerprint. The general snapshot workflow is described in finding WebSocket listener leaks in DevTools.

Verification #

Automate the check so a regression cannot ship. With Playwright and the Chrome DevTools Protocol, drive the feed with a fake socket for a fixed number of events, force garbage collection, and count detached nodes through a heap snapshot or performance.memory trend:

test('feed does not retain trimmed rows', async ({ page }) => {
await page.goto('/feed?fakeSocket=1');
await page.evaluate(() => (window as any).__emit(5_000)); // 5,000 fake events
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
const count = await page.evaluate(() => document.querySelectorAll('#feed li').length);
expect(count).toBe(200);
const { usedSize } = await cdp.send('Runtime.getHeapUsage');
expect(usedSize).toBeLessThan(40 * 1024 * 1024); // budget for 200 live rows
});

The heap budget is coarse but effective: a leak of 5,000 rows at a couple of kilobytes each blows through it immediately. See Playwright tests for WebSocket apps for driving the socket.

Heap over a soak test In a sixty-minute soak test the leaky build grows to 120 megabytes at ten minutes and 360 at thirty, while the fixed build stays flat around twenty megabytes throughout. Heap over a soak test start (0 min) leaky build: 120 MB (10 min) leaky build: 360 MB (30 min) fixed build: flat ~20 MB (30.5 min) fixed build: flat (60 min) Flat is the only acceptable shape for a view that shows a fixed number of rows
A live list that shows N rows should use memory for N rows.

Operational checklist #

FAQ #

What is a detached DOM node? #

An element that has been removed from the document but is still reachable from JavaScript, so the garbage collector cannot free it. It is invisible on the page and still consumes memory, along with its whole subtree.

Why do real-time lists leak more than other UIs? #

They create and remove elements continuously for as long as the page is open, so a small per-row leak multiplies by the event rate and the session length. A leak that is harmless in a form becomes gigabytes in a feed.

Do React or Vue prevent detached nodes? #

Their renderers remove nodes correctly, but any reference you keep outside the framework — a map of refs, an observer, a closure in a WebSocket handler — can still retain removed elements. Framework cleanup does not know about your own structures.

Is WeakRef a better fix than WeakMap? #

WeakRef works but forces you to handle dereferencing to undefined everywhere. For metadata keyed by element, WeakMap is simpler; for lookup maps, removing entries explicitly on trim is clearer than weak references.

Back to Memory Leak Prevention.