A junior ships a “tiny framework”. Every component is a render function that builds its own DOM and binds its own addEventListener calls. Works on the first render. The reviewer opens it in Chrome, clicks a few things, merges the PR on a Friday afternoon.

A week later the memory graph is a staircase. Every minute the app runs, the JS heap grows another megabyte. Users on long sessions see tabs crash. The bug queue fills with “slow over time” reports that don't reproduce in QA — which only runs 30-second smoke tests. The leak is invisible in small cases and obvious once the room has been swapped a few hundred times.

Lounge · naive render functionswaps: 0
01
02
03
04
4live
0detached
4total

Each lamp above is a child “component”. Each one binds its OWN click listener in mount. There is no teardown step — when the parent rerenders, it overwrites root.innerHTML with a fresh template, which DETACHES the old DOM but leaves the listeners bound to those detached nodes. Tap the “swap room” button a few times, then look at the listener counter.

The junior's render function
1
// The junior's first render function. Works, leaks.
2
function render(root, state) {
3
  root.innerHTML = template(state);
4
  root.querySelectorAll('button').forEach((btn, i) => {
5
    btn.addEventListener('click', () => {
6
      state.value = i;
7
      render(root, state);   // blows away DOM, orphans listeners
8
    });
9
  });
10
}

Three probes ahead, each one a different user group hurting from the same absent pattern. Predict before each reveal — the probe answer is how the felt wall lands.

Step 1 / 3 · Probe: the memory graph

The room above has 4 lamps. The user has tapped “swap room” 3 times — each swap regenerates the lamp set from a fresh template. Count the listeners in the JS heap after 3 swaps.

After 3 swaps of a 4-lamp room using addEventListener per lamp and innerHTML rerender, how many listener closures are still reachable in the heap?

count
focus
zombie
residue