The Dashboard That Got Slower the Longer You Left It Open

A single page app that was fine for an hour and unusable by the afternoon. Three wrong hypotheses, then a heap snapshot showed forty thousand detached DOM nodes.

Share
The Dashboard That Got Slower the Longer You Left It Open. Abstract bug hunt illustration in orange and dark grey on debugly.dev

An operations dashboard, left open on a wall display and on the desks of about thirty people. It polls, it charts, it updates a table every few seconds. Fine in the morning. By mid afternoon the tab was using 3.2 GB and scrolling had gone visibly choppy. By the next morning Chrome had killed it.

The reports were vague in the way these always are. Nobody says "there is a memory leak". They say the dashboard is slow, and it is slow only for the people who never close it.

What the symptom actually was

Memory that only grows. That is the signal that separates a leak from ordinary heavy usage.

setInterval(() => {
  const m = performance.memory;
  console.log(new Date().toISOString(),
    Math.round(m.usedJSHeapSize / 1048576) + ' MB');
}, 30000);

Crude, and enough. Over two hours the heap went from 180 MB to 1.4 GB in a straight line, with no plateau. Garbage collection was running and reclaiming nothing, which meant something was still holding references.

Tested on Chrome 133, Linux 6.8.

The hypotheses that were wrong

Hypothesis one: the chart library

The obvious suspect. We render four charts, each redrawn on every poll, and charting libraries have a reputation for this.

I disproved it by removing the charts entirely and letting the page run for an hour. The heap still climbed at nearly the same rate. That killed it, and it was worth the twenty minutes because it removed the component everyone was certain about.

Hypothesis two: the polling responses

Second guess: we keep every response for a history feature. Maybe the array is unbounded.

It was bounded. history.slice(-200) on every update, verified in the debugger, and 200 objects of that size is a few megabytes at worst. Not 1.4 GB.

This is worth stating plainly because it is where I lost the most time: I believed the array was the problem for long enough that I re-read the same slice call three times looking for an off by one. The measurement had already told me it was not big enough to matter and I kept looking anyway.

Hypothesis three: WebSocket buffering

We use a socket for live events. If the client cannot keep up with inbound messages, they queue.

Ruled out by checking bufferedAmount, which sat at zero, and by observing that the leak persisted with the socket disconnected. Three suspects, three eliminations, and the memory still climbing.

The breakthrough

Two heap snapshots, taken twenty minutes apart, compared with the Comparison view in Chrome DevTools. This is the tool that actually answers the question, and I should have reached for it first.

The comparison sorts by objects allocated since the previous snapshot and still alive. At the top:

Detached HTMLTableRowElement    41,208    +41,208
Detached HTMLDivElement          8,442     +8,442

A detached DOM node is an element removed from the document that JavaScript still holds a reference to. It cannot be collected, and because DOM nodes hold their children, one retained row can hold an entire subtree.

Forty thousand rows we had removed from the page and never released.

Clicking one shows the retaining path, which is the part that names the culprit. The chain ran back to a closure held by an event listener held by a module scoped Map.

The code was this, reduced:

const rowState = new Map();

function renderTable(items) {
  tbody.innerHTML = '';                    // rows removed from the document
  for (const item of items) {
    const tr = document.createElement('tr');
    tr.addEventListener('click', () => select(item.id));
    rowState.set(tr, { item, expanded: false });   // reference kept forever
    tbody.appendChild(tr);
  }
}

Every poll built new rows, registered them in rowState, and wiped the table body. The rows left the document. The Map kept holding them, and each held its listener, which held the closure, which held item.

innerHTML = '' removes nodes from the tree. It does not remove references your own code is holding. Nothing about it is a delete.

At roughly 4,000 rows per poll cycle and a poll every fifteen seconds, the arithmetic is unforgiving.

What I changed

Three things, in order of how much they mattered.

A WeakMap instead of a Map. This alone fixed the leak. A WeakMap holds keys weakly, so once the row is out of the document and out of scope it becomes collectable along with its entry.

const rowState = new WeakMap();

Stopped rebuilding rows that had not changed. Reconciling by key instead of clearing the table cut allocation dramatically and made the table visibly smoother, because we were no longer discarding and recreating the same forty rows every fifteen seconds.

Used AbortController for listeners. One signal, one abort, every listener attached with it is removed. Much harder to forget than pairing each addEventListener with a matching removeEventListener.

const controller = new AbortController();
tr.addEventListener('click', handler, { signal: controller.signal });
// later
controller.abort();

After the fix the heap rose to about 210 MB in the first few minutes and stayed there for eight hours. Flat is what healthy looks like.

What I would do differently

Take the heap comparison first. I spent nearly a day on hypotheses that a two snapshot diff answered in about ninety seconds. My excuse was that snapshots on a 1 GB heap are slow and awkward to read, which is true and was not a good enough reason.

Distrust the component everyone suspects. The chart library was the consensus answer and it was innocent. Consensus is a social signal, not evidence, and I have written before about how the obvious suspect distorts a search.

Watch the shape of the curve, not the value. Absolute memory tells you very little. Linear growth that never plateaus is a leak, sawtooth is healthy collection, and a step change is a feature that allocated something big. The shape is the diagnosis.

Treat "only affects people who never close the tab" as the clue it is. That sentence appeared in the very first bug report and it is a precise description of a leak. I read past it.

If you are chasing the server side equivalent, heap snapshots in Node work the same way and the retaining path is again the thing that names your bug.