Row 8 of the baseline from 09-01 says +37.7 MB retained after 200 filterings. It is the quietest of the ten and, in the long run, the most serious: a speed problem is noticeable and measurable; a memory problem shows up as "the tab goes weird after a while" and as inexplicable crashes on phones. Marta leaves Nómada Tasks open all day; three hours into filtering, sorting and marking tasks, the application is using more than a gigabyte and the browser kills it. This lesson explains how memory works in JavaScript —stack, heap, references and the garbage collector based on reachability—, what the five classic causes of leaks in the browser are with their concrete example in Nómada Tasks, how a leak is diagnosed with the DevTools Memory panel using the three-snapshot pattern, what WeakMap, WeakSet, WeakRef and FinalizationRegistry are and when they genuinely help, and how to build systematic cleanup with destroy() methods and AbortController that stops leaks from appearing at all. By the end you will have found and fixed the view's real leak.

Contents

  1. Why memory matters in a long-lived application
  2. Stack, heap and references
  3. The garbage collector: reachability
  4. Why reference counting is not enough
  5. Generational collection and mark-and-sweep
  6. What a memory leak is, exactly
  7. The five classic causes
  8. Detached nodes and why delegation avoids them
  9. Diagnosis with DevTools: the Memory panel
  10. The three-snapshot pattern
  11. Finding the real leak in Nómada Tasks
  12. Fixing it
  13. Weak references: WeakMap, WeakSet, WeakRef and FinalizationRegistry
  14. Systematic cleanup: destroy() and AbortController
  15. Detecting leaks in continuous integration
  16. Common Mistakes and Tips
  17. Exercises
  18. Conclusion

  1. Why memory matters in a long-lived application

On a traditional web page every navigation throws everything away: if something leaks, it is cleaned up when you change page. Nómada Tasks is a single-page application —it has its own router (view/router.js), a service worker and persistent state— and it can be open for eight hours without ever reloading. There, a small leak turns into a big problem through accumulation.

The symptoms, in order of appearance:

Symptom Usual cause
The application is fine when opened and bad after a while Cumulative leak
Periodic stutters every few seconds Collections that get more and more expensive
Scrolling becomes irregular Large heap: every collection steals frames
"Aw, Snap" / tab killed by the system Memory limit reached
It only happens on phones The limit there is far lower

And one important clarification from the outset: using a lot of memory is not a leak. A board of 600 tasks takes up what it takes up. A leak is memory the application no longer needs but cannot release. The difference is measurable, and this chapter teaches you how to measure it.

  1. Stack, heap and references

JavaScript stores data in two places.

The stack is a small, blazingly fast structure where each call's execution context (03-05) lives, along with primitive values: numbers, short strings, booleans, null, undefined, symbols. It frees itself when the function returns: there is nothing to manage.

The heap is a large region where objects live: object literals, arrays, functions, class instances, DOM nodes. What a variable holds is not the object, but a reference to its position in the heap.

let hours = 12;                       // primitive: the value is on the stack
let task = { id: 1, hours: 12 };      // object on the heap; `task` holds a reference

let other = task;                     // the REFERENCE is copied, not the object
other.hours = 20;
console.log(task.hours);              // 20 ← it is the same object (04-08)

task = null;                          // one reference is released…
console.log(other.hours);             // 20 ← …but `other` keeps it alive

That last block is all the theory you need: an object lives for as long as somebody can reach it. The relevant question is never "did I set this variable to null?", but "is there any path left that leads to this object?".

  1. The garbage collector: reachability

In C you allocate and free by hand. In JavaScript there is a garbage collector (GC) that automatically frees whatever can no longer be reached. Its criterion is reachability.

The engine keeps a set of roots: starting points that are always alive.

Root Example
The global object window, globalThis
The active call stack Local variables of the functions currently running
The DOM tree reachable from document document.body and its descendants
Loaded ES modules and their state The const board in app.js (05-04)
Registered handlers and active timers Whatever a pending setInterval holds on to

An object is reachable if a chain of references exists from some root to it. Whatever is not reachable is garbage and gets freed. Full stop.

flowchart LR
    R(("Roots")) --> A["app.js: board"]
    A --> B["Task 1"]
    A --> C["Task 2"]
    R --> D["document"]
    D --> E["div.board"]
    E --> F["li[data-id='1']"]
    G["Old task"] -.-> H["Orphaned object"]
    style G fill:#fdd,stroke:#c00
    style H fill:#fdd,stroke:#c00

In the diagram, Old task and Orphaned object reference each other but no root reaches them: they are garbage, even though they have references. That island is exactly the case that sinks the strategy in the next section.

  1. Why reference counting is not enough

The simplest strategy imaginable would be to keep a counter on each object of how many references point at it and free it when the counter reaches zero. That is what some old systems did, and it does not work, for one concrete reason: cycles.

function createCycle() {
  const task = { id: 1, title: 'Redesign the multipurpose room' };
  const review = { reviewer: 'Marta' };

  task.review = review;           // task → review
  review.task = task;             // review → task  ← cycle

  return null;                    // nobody on the outside keeps either of them
}

createCycle();
// With reference counting: each has 1 reference → they are never freed. LEAK.
// With reachability: no root reaches them → garbage. They are freed.

Cycles are not rare: they appear as soon as a DOM node holds a reference to an object that in turn holds the node, in any parent-child structure with an upward link, and in practically any graph. That is why all modern engines use reachability, not counting.

The practical consequence is liberating: you do not have to worry about cycles. What you do have to watch out for is the opposite: references from a live root towards something you no longer need. A handler on window, an entry in a module-level Map, a running setInterval.

  1. Generational collection and mark-and-sweep

The base algorithm is mark and sweep: start from the roots, mark everything reachable, and sweep away whatever is unmarked. Doing that over a heap of hundreds of megabytes on every collection would be enormously expensive, so engines add a statistical observation known as the generational hypothesis: most objects die young.

Hence the split of the heap:

flowchart TD
    subgraph N["Young generation (new space) · a few MB"]
        direction LR
        A["Nursery"] -->|"survives one<br/>minor collection"| B["Intermediate"]
    end
    B -->|"survives two:<br/>promotion"| C
    subgraph O["Old generation (old space) · hundreds of MB"]
        C["Long-lived objects:<br/>board, view, modules"]
    end
    N -.->|"MINOR collection<br/>frequent and very fast (~1 ms)"| N
    O -.->|"MAJOR collection<br/>rare and expensive (tens of ms)"| O
Minor collection (scavenge) Major collection (mark-compact)
Area Young generation The whole heap
Frequency Very high (several per second) Low
Typical cost < 1–2 ms Tens of ms, sometimes more
Visible impact None It can steal frames

Three practical consequences for performance:

  1. Creating lots of short-lived objects is cheap. The object you create in a map and discard straight away dies in the young generation and collecting it is nearly free. This demolishes another myth: "avoid creating objects in loops" is, in general, obsolete advice.
  2. Retaining objects is expensive. Whatever survives is promoted to the old generation, and that one does require expensive major collections. A leak does not just consume memory: it makes every future collection more expensive.
  3. Modern engines collect in parallel and incrementally, so pauses are far shorter than they were a few years ago. But they are not zero, and in the Main lane of the Performance panel they show up as gray blocks labeled Minor GC / Major GC.

One final note: you cannot force collection from your code. There is no API for it (Chrome offers a bin icon in the Memory panel and the --expose-gc flag in Node, and both are diagnostic tools, not production ones). The only thing you control is which references you keep.

  1. What a memory leak is, exactly

A memory leak is memory the application no longer needs but that is still reachable, and therefore the collector cannot free it.

Both halves matter. If it is not reachable, it gets freed and there is no leak even if consumption is high. If it is reachable but you do need it, that is legitimate usage. The leak is the intersection: reachable and unnecessary.

Diagnosis always reduces to the same question: what chain of references, from what root, is keeping this alive? DevTools answers it literally, with a path called retainers.

  1. The five classic causes

Almost every leak in the browser falls into five categories. Here are all five, each with its case in Nómada Tasks.

7.1 Accidental globals

In non-strict mode, assigning to an undeclared identifier creates a property on window (01-04). And window is a root: whatever hangs off it is never freed.

// ✗ Without 'use strict' and without modules, this creates window.taskCache
function paint(tasks) {
  taskCache = tasks;         // ← const/let is missing. Goodbye to 600 tasks, forever
}

Nómada Tasks is safe almost by accident: ES modules are strict by default (05-04), so that line would throw a ReferenceError. And ESLint's no-undef (08-02) catches it before anything even runs. But the deliberate variant is still possible and is just as damaging:

// ✗ Just as bad, and perfectly legal
window.__boardDebug = board;   // "just for debugging"…

That window.__boardDebug, added one day to inspect things from the console and never removed, keeps the entire board alive even if the view is destroyed. If you need something like that, put it behind an environment check and remove it during cleanup.

7.2 Timers that are never cancelled

A pending setInterval is a root: it keeps its function alive along with everything its closure captures. The BoardChannel from 07-04 has a heartbeat with HEARTBEAT_MS = 25000, and here is the buggy version:

// ✗ js/data/realtime.js — version with a leak
#onOpen() {
  this.#setState(STATES.OPEN);

  // Every reconnection starts ANOTHER interval. The previous ones stay alive.
  this.#heartbeat.interval = setInterval(() => {
    this.#send({ type: 'ping' });
  }, BoardChannel.HEARTBEAT_MS);
}

Over a working day with an unstable network there can be forty reconnections: forty active intervals, forty pings every 25 seconds, forty closures holding on to the channel (and with it, everything the channel references). Plus a functional bug: the server receives forty pings where it expects one.

// ✓ ALWAYS cancel before creating, and cancel on close
#stopHeartbeat() {
  clearInterval(this.#heartbeat.interval);
  clearTimeout(this.#heartbeat.watchdog);
  this.#heartbeat.interval = null;
  this.#heartbeat.watchdog = null;
}

#onOpen() {
  this.#setState(STATES.OPEN);
  this.#stopHeartbeat();                                // ← idempotent: safe to call always
  this.#heartbeat.interval = setInterval(() => {
    this.#send({ type: 'ping' });
  }, BoardChannel.HEARTBEAT_MS);
}

#onClose() {
  this.#stopHeartbeat();
  // …reconnection with exponential backoff, as in 07-04…
}

The general rule: for every setInterval there must be a clearInterval on the cleanup path, and the stopping function must be idempotent so you can call it without fear.

setTimeout is less dangerous because it limits itself, but a 30-second timer holds on to its closure for 30 seconds even if the view has already been destroyed, and its callback will run against a state that no longer exists. Cancel those too.

7.3 Handlers on removed nodes

This is the one that closes the warning from 06-05 about innerHTML = ''.

// ✗ One handler per card, and clearing with innerHTML
function paintAll(tasks) {
  container.innerHTML = '';                       // the nodes leave the tree…
  for (const task of tasks) {
    const li = createCard(task);
    li.addEventListener('click', () => openDetail(task));   // ← closure holding the task
    container.append(li);
  }
}

The widespread belief is that innerHTML = '' cleans up the handlers. It is neither true nor false: it depends. The browser frees a removed node and its handlers as soon as the node stops being reachable. The problem is that the handler function is a closure capturing task, and if anything else still references that node —an array of nodes, a cache Map, a console variable, an observer— then the node, the handler, the closure and the task all stay alive, outside the tree but in memory. That is called a detached node.

With 600 cards and 200 filterings that is up to 120,000 nodes and 120,000 closures potentially retained. A good chunk of the 37.7 MB is right there.

The real solution is not to clean up better, but not to register 600 handlers: that is the delegation from 06-04.

7.4 Closures that retain more than they seem to

03-04 established that a closure keeps alive the environment where it was born, and it deferred the details to this lesson. Here is the uncomfortable part: a closure does not capture only what it uses; it captures the environment, and the engine can only discard what it can prove is unused.

// ✗ The handler only needs the id, but the environment holds far more
function setUp(tasks) {
  const allTasks = tasks;                           // 600 objects
  const history = loadHistory();                    // 40,000 records, 9 MB
  const id = tasks[0].id;

  document.addEventListener('task:changed', () => {
    console.log(`Something changed while we were looking at ${id}`);   // only uses `id`
  });
}

That handler lives on document, which is a root. And even though it only uses id, the environment where it was born contains allTasks and history. Engines do analysis to discard unused variables, but that analysis has known limits: it takes only an eval, a debugger, or another function in the same scope that does use history (all functions in a given scope share the environment object) for everything to be retained.

// ✓ Extract only what is needed and create the handler in a small scope
function setUp(tasks) {
  const history = loadHistory();
  process(history);                                 // used here and done with

  registerNotice(tasks[0].id);                      // ← a new, minimal scope
}

function registerNotice(id) {
  document.addEventListener('task:changed', () => {
    console.log(`Something changed while we were looking at ${id}`);
  });
}

The rule: create long-lived closures in the smallest possible scope, passing them only the data they need.

7.5 Caches that grow without limit

In 09-02 you memoized readableDate and it was said that the third condition was a size limit. This is why:

// ✗ Memoization over an unbounded key
const memoizedSearch = memoize((text) => [...board].filter(
  (t) => t.title.toLowerCase().includes(text.toLowerCase())
));

// The user types: 's', 'sc', 'scr', 'scre', ... one new key per keystroke,
// and each value is an array of up to 600 tasks. It grows forever.

With 200 distinct searches and arrays of results, the cache alone can weigh tens of megabytes. And memoize uses a module-level Map: a live root.

Two correct solutions. First, a cache with a cap and an eviction policy (LRU, least recently used):

// js/util/lru-cache.js

/**
 * Cache with a maximum size. When it fills up it evicts the least recently used entry.
 * It takes advantage of Map preserving insertion order.
 */
export class LruCache {
  #max;
  #map = new Map();

  constructor(max = 100) { this.#max = max; }

  get(key) {
    if (!this.#map.has(key)) return undefined;
    const value = this.#map.get(key);
    this.#map.delete(key);        // reinserting moves it to the end: it becomes the most recent
    this.#map.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.#map.has(key)) this.#map.delete(key);
    this.#map.set(key, value);

    if (this.#map.size > this.#max) {
      this.#map.delete(this.#map.keys().next().value);   // the oldest one
    }
  }

  get size() { return this.#map.size; }
  clear() { this.#map.clear(); }
}

Second, when the key is an object, a WeakMap (section 13), which releases the entry automatically when the key stops being reachable.

And here the warning from 09-02 about Board's #byId index closes: if removeTask(id) takes the task out of the array but not out of the Map, the task is still reachable from the index. It is not just a data bug: it is a leak.

  1. Detached nodes and why delegation avoids them

A detached DOM node is an element that is no longer in the document tree but that somebody in JavaScript is still pointing at. It is not painted, it cannot be selected with querySelector, it is good for nothing… and it takes up memory, dragging all its descendants along with it.

// ✗ Storing nodes in a long-lived structure
const nodesById = new Map();

function paint(tasks) {
  container.replaceChildren();
  for (const task of tasks) {
    const li = createCard(task);
    nodesById.set(task.id, li);       // ← the Map retains the node even after it leaves the tree
    container.append(li);
  }
}

A detail that surprises everybody: retaining a single child is enough to retain the whole tree it belonged to, because every node points at its parentNode. Keeping a <span> from a card can retain the entire card, the list and the column.

The delegation from 06-04 eliminates the most frequent cause at the root:

One handler per card Delegation on the container
Handlers with 600 tasks 600 (or 1,800, with three actions) 1
Retained closures 600 1
When a card is removed Its handler has to be removed There is nothing to remove
Cards created later They have to be registered They work by themselves
Risk of a detached node High Practically nil

That is why the app.js from 06-06 registers a single click on .board and works out the card with closest('[data-id]'). That was justified on clarity grounds; now it also has a measurable memory justification.

  1. Diagnosis with DevTools: the Memory panel

Enough theory: let us look at the real heap. The DevTools Memory panel offers three tools, and each answers a different question.

Tool Question it answers Cost
Heap snapshot What is alive right now and who is retaining it? Pauses the page for a few seconds
Allocation instrumentation on timeline When is memory allocated and what survives? High: it slows things down a lot
Allocation sampling Which function allocates the most memory? Low: usable in long sessions

In addition, the Performance tab has two complementary instruments: the Memory checkbox, which draws curves for the JS heap, DOM nodes, event listeners and documents throughout the recording, and the Detached Elements tool (under More tools), which lists detached nodes directly.

How to read a heap snapshot. When you open one you see a table of constructors with four columns:

Column Meaning
Constructor The object's type: Task, Array, HTMLLIElement, (closure), system / Context
Distance Distance in hops from the root. A small and unexpected number is suspicious
Shallow Size The memory of the object itself, without what it references
Retained Size The important one: memory that would be freed if this object disappeared

Retained Size is what points at the culprit. A Map of 200 entries has a ridiculous shallow size, but if each entry retains an array of 600 tasks its retained size will be tens of megabytes.

And below, the Retainers section: the chain of who references whom all the way to a root. That panel is literally the answer to "why is this still alive?", and it is where every leak investigation ends.

Three views selectable at the top left:

  • Summary: grouped by constructor. The default view.
  • Comparison: compares two snapshots and shows the objects created and deleted between them, with # New, # Deleted and # Delta. It is the view that solves the case.
  • Containment: the full graph from the roots. Useful for exploring by hand.

An essential trick: in the Summary view's filter, type Detached. Every detached node appears, grouped, with its retained size.

  1. The three-snapshot pattern

This is the standard procedure for confirming a leak. It is called the three-snapshot pattern and it works because it separates noise from real growth.

flowchart LR
    A["Open in incognito<br/>and reach the base state"] --> B["Bin icon:<br/>force collection"]
    B --> C["Snapshot 1"]
    C --> D["Repeat the suspect<br/>action N times"]
    D --> E["Return to the base state"]
    E --> F["Bin icon"]
    F --> G["Snapshot 2"]
    G --> H["Repeat the action<br/>another N times"]
    H --> I["Return to the base state<br/>+ bin icon"]
    I --> J["Snapshot 3"]
    J --> K["Compare 3 with 1"]

The keys to the method:

  1. Always return to the same state before each snapshot. If you end up with a filter applied and did not have one before, the difference means nothing.
  2. Force collection with the bin icon before each snapshot. Without it you will see garbage still awaiting collection and believe there is a leak where there is none. (Taking a snapshot already forces a collection, but pressing the bin makes it explicit and removes doubt.)
  3. Compare 3 with 1, not 2 with 1. The first round of an action legitimately creates structures that will be reused afterwards (initialized caches, compiled templates). What appears between 2 and 3 is pure growth, and comparing 3 against 1 makes it obvious.
  4. If the number of objects grows linearly with the repetitions, it is a leak. If it grows and then levels off, it is a capped cache: legitimate usage.

And a complementary, far cheaper procedure for the first look, which answers "is there a leak, yes or no?" in a minute:

  1. Performance tab, Memory checkbox ticked.
  2. Record while you repeat the action 20 times.
  3. Look at the JS Heap curve: normal is a sawtooth pattern that returns to the same base. Worrying is a sawtooth whose base climbs step by step.
  4. Look at the Nodes and Listeners curves: if they go up and never come down, you already know what kind of leak it is.

  1. Finding the real leak in Nómada Tasks

To the case. We reproduce the baseline scenario with a script in the console, so it is repeatable:

// Console: 200 filterings over the board of 600 tasks
const ASSIGNEES = ['Iván', 'Lucía', 'Marta', null];

async function stressFilters(times = 200) {
  for (let i = 0; i < times; i += 1) {
    view.update({ filters: { assignee: ASSIGNEES[i % 4], text: `t${i % 7}` } });
    await new Promise((r) => setTimeout(r, 0));      // let the browser breathe (09-02)
  }
  view.update({ filters: { assignee: null, text: '' } });   // back to the base state
}

Applying the three-snapshot pattern:

Heap size DOM nodes Listeners Detached HTMLLIElement
Snapshot 1 (base) 12.4 MB 7,812 41 0
Snapshot 2 (after 200) 31.8 MB 7,812 241 20,914
Snapshot 3 (after 400) 50.1 MB 7,812 441 41,828

The verdict is immediate: linear growth. Each batch of 200 filterings adds about 19 MB, 200 listeners and ~20,900 detached nodes. This is not a cache levelling off: it is a leak.

Now the Comparison view between 3 and 1, sorted by Retained Size, with the suspects:

Constructor # New # Deleted # Delta Retained (delta)
Detached HTMLLIElement 41,828 0 +41,828 22.1 MB
(closure) 41,628 0 +41,628 9.4 MB
Array 400 0 +400 5.8 MB
system / Context 400 0 +400 0.4 MB

And now the part that solves the case: select a Detached HTMLLIElement and read its retainers. The chain is this:

Detached HTMLLIElement
 └─ value in Map                          ← a Map retains it
     └─ #nodesById in BoardView
         └─ view in app.js (module)       ← root

And for one of the (closure) entries:

(closure)
 └─ handleEvent in EventListener
     └─ resize listener in Window         ← root

Two distinct leaks, found in two minutes. Let us look at the guilty code.

// ✗ js/view/board-view.js — the code with both leaks
export class BoardView {
  #container; #summary; #state;
  #nodesById = new Map();           // "an index to speed up reconciliation" (09-02)

  render() {
    const visible = this.#visible();

    // LEAK 2: a NEW handler on every render, on window, which on top of that
    // captures `visible` (up to 600 tasks) in its closure
    window.addEventListener('resize', () => this.#adjustHeights(visible));

    for (const { status } of COLUMNS) {
      const column = $(`.column[data-status="${status}"]`, this.#container);
      const inStatus = byStatus[status] ?? [];

      reconcile(
        $('.task-list', column),
        inStatus,
        (t) => t.id,
        (t, node) => {
          this.#nodesById.set(t.id, node);   // LEAK 1: added… and never removed
          return paintCard(t, node, this.#state.today);
        }
      );
    }
  }
}

Both faults are entirely reasonable taken separately, and that is the message of this section. The Map was added following 09-02's advice to index by key. The addEventListener was put where it seemed natural, next to the code that needs it. Neither of them shows up in a code review, neither breaks any of the 124 tests, and with six tasks neither is noticeable. Leaks are not found by reading: they are found by measuring.

  1. Fixing it

The two corrections, with their reasoning:

// ✓ js/view/board-view.js
export class BoardView {
  #container; #summary; #state;
  #nodesById = new Map();
  #controller = new AbortController();      // governs ALL the view's handlers

  constructor({ container, summary, board, today = TODAY }) {
    this.#container = container;
    this.#summary = summary;
    this.#state = { board, today, filters: { assignee: null, text: '' }, sort: 'priority' };
    this.#prepareColumns();

    // FIX 2: registered ONCE, in the constructor, with a signal so it can be removed.
    // And it does not capture `visible`: it reads the current state when it runs.
    window.addEventListener('resize', this.#onResize, {
      signal: this.#controller.signal,
      passive: true
    });
  }

  // Class field with an arrow function: correct `this` and always the same reference (04-02)
  #onResize = throttle(() => this.#adjustHeights(this.#visible()), 100);

  render() {
    const visible = this.#visible();
    const byStatus = Object.groupBy(visible, (t) => t.status);
    const alive = new Set(visible.map((t) => t.id));       // FIX 1, part A

    for (const { status } of COLUMNS) {
      const column = $(`.column[data-status="${status}"]`, this.#container);
      const inStatus = byStatus[status] ?? [];

      reconcile($('.task-list', column), inStatus, (t) => t.id, (t, node) => {
        this.#nodesById.set(t.id, node);
        return paintCard(t, node, this.#state.today);
      });
    }

    // FIX 1, part B: purge from the index whatever is no longer in the tree
    for (const id of this.#nodesById.keys()) {
      if (!alive.has(id)) this.#nodesById.delete(id);
    }
    // …summary and event, exactly as in 06-06…
  }

  /**
   * Releases everything this view retains outside itself.
   * Without this, destroying the view is not enough: window still points at its handlers.
   */
  destroy() {
    this.#controller.abort();         // removes ALL handlers registered with the signal
    this.#onResize.cancel?.();
    this.#nodesById.clear();
    this.#container.replaceChildren();
    this.#state = null;
  }
}

Four points that deserve attention:

  • { signal } in addEventListener is the key tool of the lesson: a single abort() removes every handler registered with that signal, however many there are and wherever they live. It is the same AbortController you used in 07-03 to cancel requests, applied to events (06-04).
  • The handler is a class field with an arrow function, not an arrow created on the spot. That guarantees the reference is always the same, an essential requirement if one day you wanted to remove it with removeEventListener.
  • The handler no longer captures visible. It reads the current state when it runs. A long-lived closure must not freeze large amounts of data.
  • The index purge is the literal application of the warning from 09-02: keeping an index obliges you to maintain it in both directions.

And the measurement, redoing the three-snapshot pattern:

Measurement Before After
Heap after 400 filterings 50.1 MB 12.8 MB
Retained growth +37.7 MB +0.4 MB
Detached HTMLLIElement 41,828 0
Event listeners 441 41
Major GC pauses in 60 s 14 (max. 84 ms) 3 (max. 11 ms)

That last row is the benefit nobody expects: fixing the leak has also made the application faster, because a small heap is collected quickly. Row 8 of the baseline is solved.

  1. Weak references: WeakMap, WeakSet, WeakRef and FinalizationRegistry

JavaScript offers four mechanisms for referencing without retaining. First of all, the honest warning: 95% of correct code needs none of the four. The solution to a leak is almost always to clean up properly, not to use weak references. But there is one case where WeakMap is exactly the right tool.

Tool What it holds It is released when… Iterable Realistic use
WeakMap Object key → any value The key stops being reachable No Metadata attached to objects or DOM nodes
WeakSet Objects The object stops being reachable No Marking objects as already processed
WeakRef A loose reference The target stops being reachable Very large caches. Rare
FinalizationRegistry A cleanup callback After the target is collected Releasing external resources. Very rare

The fact that WeakMap and WeakSet are not iterable is not an oversight: if you could walk them, the result would depend on when the collector had run, and your program would be non-deterministic. The impossibility of iterating is what makes the semantics safe.

The legitimate case in Nómada Tasks: attaching data to DOM nodes without retaining them.

// js/view/card.js

// Key: the node. Value: metadata we do not want to put in the dataset (04-01)
const metadata = new WeakMap();

export function createCard(task, today) {
  const li = buildElement('li', { class: 'task', 'data-id': String(task.id) });

  metadata.set(li, {
    paintedAt: performance.now(),
    dataVersion: task.version,
    computedHeight: null
  });

  return li;
}

export function metadataFor(node) {
  return metadata.get(node) ?? null;
}

The difference is exactly the leak from section 11: with a normal Map, every painted node would be retained forever by the map. With a WeakMap, when the node leaves the tree and nobody else references it, the entry disappears on its own. It is the only structure that gives you a cache with no obligation to purge.

// Conceptual demonstration of the contrast
const strong = new Map();
const weak = new WeakMap();

let node = document.createElement('li');
strong.set(node, 'metadata');
weak.set(node, 'metadata');

node = null;
// `strong` still retains the node: LEAK.
// `weak` does not retain it: on the next collection, the entry disappears.

And its limitation, which you have to know: WeakMap only helps when the key is the object whose lifetime governs. To memoize readableDate, whose key is the string '2026-09-05', WeakMap is no good (strings cannot be weak keys): there the answer is the LruCache from section 7.5.

WeakRef and FinalizationRegistry exist, and it is worth recognizing them, but the specification itself advises against depending on them: there is no guarantee of when —or whether— finalization will run. Never put logic there that your program's correctness needs.

// Illustrative example, NOT recommended as an everyday pattern
const registry = new FinalizationRegistry((label) => {
  console.log(`Collected: ${label}`);          // may NEVER run
});
registry.register(new Worker(url), 'planning worker');

  1. Systematic cleanup: destroy() and AbortController

The structural lesson from all of the above: any object that registers something outside itself needs a way of undoing it. Handlers on window or document, timers, observers, sockets, workers, subscriptions. That contract is called destroy(), and it is worth making it systematic across the whole project.

What it registers How it is undone
addEventListener removeEventListener or, better, { signal } + abort()
setInterval / setTimeout clearInterval / clearTimeout
IntersectionObserver, ResizeObserver, MutationObserver .disconnect()
WebSocket .close()
Worker .terminate()
An in-flight fetch AbortController.abort() (07-03)
requestAnimationFrame cancelAnimationFrame
Entries in your own caches and maps .clear()

Applied to the BoardChannel from 07-04, which is the class with the most external resources in the project:

// js/data/realtime.js
export class BoardChannel extends EventTarget {
  #controller = new AbortController();

  connect() {
    if (this.#controller.signal.aborted) throw new Error('Channel destroyed');
    this.#socket = new WebSocket(this.#url);

    const { signal } = this.#controller;
    this.#socket.addEventListener('open',    () => this.#onOpen(),     { signal });
    this.#socket.addEventListener('message', (e) => this.#onMessage(e), { signal });
    this.#socket.addEventListener('close',   () => this.#onClose(),    { signal });
    this.#socket.addEventListener('error',   () => this.#onError(),    { signal });

    // Even the window handlers are governed by the same signal
    window.addEventListener('online', () => this.connect(), { signal });
  }

  /** Releases the thread, the socket, the timers and the handlers. Idempotent. */
  destroy() {
    this.#closedOnPurpose = true;
    this.#stopHeartbeat();               // clearInterval + clearTimeout
    clearTimeout(this.#retryId);
    this.#socket?.close(1000, 'destroyed');
    this.#socket = null;
    this.#queue.length = 0;
    this.#controller.abort();            // ← removes EVERYTHING in one go
  }
}

And the wiring in app.js, with the natural cleanup point of a single-page application:

// js/app.js
const view = new BoardView({ /* … */ });
const channel = new BoardChannel(WS_URL);
const planner = new Planner();

function destroyAll() {
  view.destroy();
  channel.destroy();
  planner.destroy();
}

// On route changes (view/router.js) and when the page is unloaded
window.addEventListener('pagehide', destroyAll);

pagehide is used rather than unload deliberately: unload stops the page from entering the back/forward cache (bfcache), which worsens back-navigation performance. It is a nice case of two goals in tension, and pagehide resolves them.

And like every design decision, it is tested (08-03):

// test/view/board-view-destroy.test.js
test('destroy() removes every window handler', () => {
  const register = jest.spyOn(window, 'addEventListener');
  const view = new BoardView({ container, summary, board, today: TODAY });
  expect(register).toHaveBeenCalledTimes(1);

  view.destroy();
  window.dispatchEvent(new Event('resize'));    // must do nothing and must not throw
  expect(() => view.render()).toThrow();        // the view is destroyed
});

test('render() purges from the index the tasks that are no longer shown', () => {
  const view = new BoardView({ container, summary, board, today: TODAY });
  view.render();
  expect(view.indexedNodes).toBe(6);

  view.update({ filters: { assignee: 'Lucía' } });   // only 1 task belongs to Lucía
  expect(view.indexedNodes).toBe(1);                 // ← the rest, purged
});

  1. Detecting leaks in continuous integration

A fixed leak comes back in three months if nobody polices it. There are two levels of automatic defense, and it is worth having both.

Level 1: invariant tests in Jest. Cheap, fast and they do not measure memory but the structures that retain it. These are the ones that genuinely run on every commit.

// test/view/no-leaks.test.js
test('200 filterings do not accumulate handlers or index entries', () => {
  const spy = jest.spyOn(window, 'addEventListener');
  const view = new BoardView({ container, summary, board: largeBoard, today: TODAY });
  const afterConstruction = spy.mock.calls.length;

  for (let i = 0; i < 200; i += 1) {
    view.update({ filters: { assignee: ['Iván', 'Lucía', 'Marta', null][i % 4] } });
  }

  expect(spy.mock.calls.length).toBe(afterConstruction);     // not a single extra one
  expect(view.indexedNodes).toBeLessThanOrEqual(600);        // the index does not grow forever
  expect(document.querySelectorAll('li.task').length).toBeLessThanOrEqual(600);
});

test('the channel does not accumulate intervals when reconnecting', () => {
  jest.useFakeTimers();
  const channel = new BoardChannel('ws://example');
  const create = jest.spyOn(global, 'setInterval');
  const clear = jest.spyOn(global, 'clearInterval');

  for (let i = 0; i < 10; i += 1) { channel.simulateOpen(); channel.simulateClose(); }

  expect(create).toHaveBeenCalledTimes(10);
  expect(clear).toHaveBeenCalledTimes(20);   // it clears both on open and on close
  channel.destroy();
});

Level 2: real heap measurement with Puppeteer, in a nightly job (it is slow and noisy for every commit):

// scripts/measure-leak.mjs
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ args: ['--js-flags=--expose-gc'] });
const page = await browser.newPage();
await page.goto('http://localhost:4173');
await page.waitForSelector('li.task');

const measure = async () => {
  await page.evaluate(() => globalThis.gc?.());          // force collection
  return (await page.metrics()).JSHeapUsedSize;
};

const baseline = await measure();
await page.evaluate(() => stressFilters(200));
const after = await measure();

const growthMB = (after - baseline) / 1024 / 1024;
console.log(`Growth after 200 filterings: ${growthMB.toFixed(1)} MB`);

await browser.close();
if (growthMB > 2) {                                       // threshold with headroom for noise
  console.error('Possible memory leak: the heap grows more than expected');
  process.exit(1);
}

Two warnings about this script. The threshold must have generous headroom (2 MB, not 0.1 MB): memory on a shared runner is noisy, and a budget that fails falsely gets disabled within a week. And --expose-gc is essential: without forcing collection you would be measuring garbage still awaiting pickup, and the result would mean nothing.

Common Mistakes and Tips

  • Believing that assigning null frees memory. It only releases one reference. If another path from a root remains, the object stays alive.
  • Believing that innerHTML = '' cleans up handlers. It frees them only if nobody else references the nodes. If a Map, an array or an observer retains them, you have detached nodes.
  • Storing DOM nodes in long-lived structures. It is the number one cause of detached nodes. And retaining a child retains its whole tree through parentNode.
  • Registering handlers inside render(). Every render adds another one. Register them in the constructor, once.
  • setInterval with no clearInterval. A permanent root that retains its entire closure.
  • Reconnecting without stopping the previous timer. Forty reconnections, forty heartbeats.
  • Uncapped caches. A Map with a free-text key grows indefinitely. Set a limit (LRU) or use a WeakMap if the key is an object.
  • Indexes that are not kept in sync. If removeTask() takes it out of the array and not out of the Map, you have a leak and a data bug.
  • Long-lived closures created in large scopes. They capture the whole environment, not just what they use.
  • Measuring without forcing collection. You will see garbage awaiting pickup and diagnose a leak that does not exist.
  • Comparing snapshot 2 with 1. The first round creates legitimate structures. Compare 3 with 1.
  • Measuring with extensions installed. They inject nodes and handlers into your page. Use incognito.
  • Relying on FinalizationRegistry to release resources. There is no guarantee it will run. Never put necessary logic there.
  • Using unload to clean up. It blocks the back/forward cache and worsens navigation. Use pagehide.
  • Tip: every class that registers something outside itself gets its destroy(). And make it idempotent.
  • Tip: one AbortController per long-lived object. A single abort() removes dozens of handlers at once: it is the best cleanup tool available today.
  • Tip: event delegation is not just elegance. One handler instead of 600 eliminates an entire family of leaks at the root.
  • Tip: look at the nodes and listeners curves in Performance before taking snapshots. In one minute you know whether there is a leak and what kind.

Exercises

Exercise 1 — Retention audit. For each fragment, say whether there is a leak, what root retains what, and write the correction.

// (a)
const history = [];
document.addEventListener('task:changed', (e) => {
  history.push({ when: Date.now(), card: e.target.closest('li'), board: [...board] });
});

// (b)
function startSync(channel) {
  setInterval(() => channel.send({ type: 'sync' }), 30000);
}

// (c)
const heights = new WeakMap();
function measure(node) {
  heights.set(node, node.offsetHeight);
}

// (d)
class Editor {
  constructor(node) {
    this.node = node;
    this.observer = new ResizeObserver(() => this.adjust());
    this.observer.observe(node);
  }
  close() { this.node.remove(); }
}

Exercise 2 — Confirming a leak with the method. Iván suspects that opening and closing a task's detail panel leaks memory. Write the complete procedure you would follow: the stress script, the steps with the Memory panel, what you would look at in each snapshot, what result would confirm the leak and what would rule it out. Then state which two of the five classic causes would be the most likely in a modal panel, and why.

Exercise 3 — Map or WeakMap. For each cache, decide which to use and justify it. If neither works, propose the alternative.

  1. The computed height of each card, indexed by its <li> node.
  2. The result of readableDate(iso), indexed by the ISO string.
  3. Board's id → Task index.
  4. The last 50 search results, indexed by the search text.
  5. Marking which tasks have already been sent to the server, indexed by Task instance.

Solutions

Solution 1

(a) Serious leak. Three at once. document is a root → it retains the handler → the closure retains history → each entry retains a DOM node (which will be detached after the next render) and a full copy of the board (600 tasks). With 200 changes, 200 copies of 600 tasks.

// ✓ Store data, not nodes or whole structures; and cap the size
const history = [];
const MAX_HISTORY = 100;

document.addEventListener('task:changed', (e) => {
  history.push({
    when: Date.now(),
    id: Number(e.target.closest('li')?.dataset.id),   // ← the id, not the node
    total: board.summary(TODAY).total                  // ← a number, not a copy
  });
  if (history.length > MAX_HISTORY) history.shift();
}, { signal: controller.signal });

(b) Leak. The setInterval is not stored: there is no way to cancel it. It retains channel forever, even if the channel is destroyed.

// ✓ Return the way to cancel it
function startSync(channel) {
  const id = setInterval(() => channel.send({ type: 'sync' }), 30000);
  return () => clearInterval(id);        // called from channel.destroy()
}

(c) No leak. WeakMap does not retain its keys: when the node leaves the tree and nobody else references it, the entry disappears on its own. This is the canonical use. (Side note: offsetHeight forces a synchronous style calculation, and that is a different performance problem you will meet in 09-04.)

(d) Leak. close() takes the node out of the tree but does not disconnect the ResizeObserver. The observer retains the node and the whole Editor, and the node ends up detached.

// ✓
class Editor {
  #controller = new AbortController();

  constructor(node) {
    this.node = node;
    this.observer = new ResizeObserver(() => this.adjust());
    this.observer.observe(node);
    node.addEventListener('keydown', (e) => this.#shortcut(e), { signal: this.#controller.signal });
  }

  close() {
    this.observer.disconnect();      // ← essential
    this.#controller.abort();
    this.node.remove();
    this.node = null;
  }
}

Solution 2

Stress script (repeatable, returning to the base state and yielding to the browser):

async function stressDetail(times = 100) {
  for (let i = 0; i < times; i += 1) {
    document.querySelector(`li[data-id="${(i % 600) + 1}"] [data-action="detail"]`).click();
    await new Promise((r) => setTimeout(r, 20));      // let the modal mount
    document.querySelector('.modal__close').click();
    await new Promise((r) => setTimeout(r, 20));      // let it unmount
  }
}

Procedure:

  1. An incognito window, no extensions. Load the application and wait for the initial render.
  2. Performance panel with the Memory checkbox: record 20 iterations and look at the JS Heap, Nodes and Listeners curves. If the base of the sawtooth climbs step by step, there is a leak and we already know whether it is nodes or listeners. This step takes a minute and is usually enough to decide whether it is worth going on.
  3. Memory panel → bin icon → Snapshot 1.
  4. await stressDetail(100) → return to the base state → bin icon → Snapshot 2.
  5. await stressDetail(100) again → base state → bin icon → Snapshot 3.
  6. Select snapshot 3, Comparison view against 1, sorted by Retained Size.
  7. Filter by Detached in Summary and look at the retainers of a detached node.

What confirms the leak: the counters growing linearly —if 2 has +100 modals and 3 has +200, it is a leak—, Detached HTMLDivElement appearing in a quantity proportional to the repetitions, or the listener count going up by 100 per batch. What rules it out: snapshot 3 being practically the same as 2 (growth that levels off: a capped cache, legitimate usage) or the delta oscillating with no trend (noise).

The two most likely causes in a modal are, in this order: handlers that are not removed —a modal almost always registers a keydown on document to close with Escape, and a click on the backdrop; if close() removes the node but does not remove those handlers from document, every opening leaves another one behind and each of them retains the whole modal, which ends up detached— and timers that are not cancelled, because modals usually use a setTimeout for the exit animation or for returning focus, and if it is closed before that fires, the timer keeps retaining the node. Both are solved with the same pattern: one AbortController per modal and a close() that calls abort() and clearTimeout().

Solution 3

# Cache Choice Justification
1 Height per <li> node WeakMap The key is an object whose lifetime should govern. When the card leaves the tree, the entry goes with it: zero purging, zero leaks. The canonical use
2 readableDate(iso) Map (or LruCache) The key is a string: WeakMap does not accept it. The keys are bounded (~200 distinct dates), so a normal Map is enough; if the range were unlimited, LruCache with a cap
3 id → Task index Map, with explicit purging Here retention is wanted: the board must keep its tasks alive. WeakMap would not even work, because the key is a number. The obligation is to keep removeTask() in sync with delete
4 The last 50 results LruCache(50) A free-text key, that is, unlimited: a Map would grow forever (section 7.5) and each value can be an array of 600 tasks. The cap is essential
5 Tasks already sent WeakSet The key is an object and only membership matters, not an associated value. If the task is removed from the board, its mark disappears on its own, which is exactly right

The rule that sums up the table: WeakMap/WeakSet when the key is an object and its lifetime should govern the entry's; Map when the retention is deliberate; LruCache when the key is a primitive from an unbounded domain.

Conclusion

Row 8 of the baseline is closed: from +37.7 MB retained and 41,828 detached nodes to +0.4 MB and zero, with the added benefit that major GC pauses have dropped from 14 (up to 84 ms) to 3 (up to 11 ms) per minute. Fixing the leak has made the application faster as well as more stable.

You understand the model: the stack for primitives and contexts, the heap for objects, and variables that hold references, not objects. You know that the collector decides by reachability from a set of roots —the global object, the active stack, the reachable DOM, module state, registered handlers and timers— and you know why reference counting is not enough: two objects pointing at each other would form an immortal island, and cycles are everywhere. You know the generational organization —a cheap nursery for the ephemeral, an expensive old generation for the long-lived— and the two consequences that follow: creating temporary objects is cheap, and retaining is expensive twice over, in memory and in collection cost.

You have the five classic causes with their real cases: accidental globals (from which ES modules and no-undef save you, except for the window.__debug added "just for a moment"), timers that are never cancelled like the BoardChannel heartbeat that doubled on every reconnection, handlers on removed nodes, which closes the warning from 06-05 about innerHTML = '' by explaining that the problem is not the clearing but who else points at the node, closures that retain the whole environment closing the warning from 03-04, and caches that grow without limit, solved with LruCache. And you know what a detached node is, why retaining a single child drags its entire tree along, and why the event delegation from 06-04 eliminates a whole family of leaks at the root: one handler instead of 600.

You know how to diagnose: the Memory panel with its three tools, reading a snapshot by Retained Size and above all by retainers —the literal chain that answers "why is this still alive?"—, the Detached filter, the Comparison view, the heap, node and listener curves in Performance, and the three-snapshot pattern with its four rules: the same base state, force collection, compare 3 with 1, and look for linear growth. With that method you found two real leaks in two minutes —an unpurged Map of nodes and an addEventListener inside render()— neither of which broke any of the 124 tests or was visible in a code review.

You know when weak references are appropriate: WeakMap for metadata attached to nodes, WeakSet for marking objects, and the clear warning that WeakRef and FinalizationRegistry are not everyday tools and must never carry necessary logic. And you have the habit that stops leaks from appearing at all: an idempotent destroy() method on every object that registers something outside itself, and an AbortController with a signal that removes dozens of handlers with a single abort() —the same controller from 07-03, applied here to events—, with pagehide instead of unload so as not to break the back/forward cache. All of it policed in continuous integration by cheap invariant tests in Jest and a nightly heap measurement with Puppeteer and thresholds with headroom.

Two fronts remain, and the next one dominates the table. Row 5 is still at 310 ms per render and row 9 at 7,812 nodes: we know from 09-01's Bottom-Up that 600 calls to paintCard cost 186 ms of self time and that there are 41 style recalculations where there should be one. That work is not slow JavaScript —you have already optimized that— nor retained memory: it is the cost of talking to the DOM, a cost with rules of its own, an expensive communication channel and a rendering pipeline that can be sabotaged without noticing by a single line that reads offsetHeight inside a loop. It is the debt outstanding since 06-02 and 06-05, and its turn has come: Efficient DOM Manipulation.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved