Mert Özen Further With Every Line
Frontend JavaScript Advanced — #09

JavaScript Memory Management: Garbage Collection, Memory Leaks and WeakRef

Mert Özen Nov 3, 2026 10 min 7 views
JavaScript Memory Management: Garbage Collection, Memory Leaks and WeakRef

We look at the stack and heap, reachability and garbage collection, then cover leaks from forgotten listeners, timers, closures and detached DOM, plus WeakMap, WeakRef and heap snapshots in DevTools.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Let's create an object, store it in two different places, and then set the variable to null. What does this code print to the console?

let user = { name: "Ada" };
const list = [user];
const visits = new WeakMap();
visits.set(user, 3);

user = null;

console.log(list[0].name);
console.log(visits.has(list[0]));

Make a guess before you keep reading. And a second question: after the user = null line, is the { name: "Ada" } object a candidate for removal from memory? We'll answer once we've talked about what the garbage collector actually looks at.

Stack and Heap: The Desk and the Warehouse

In JavaScript you don't allocate and free memory by hand; the engine does that for you. But if you don't know what it's doing, you'll end up writing code that bloats memory without noticing. Let's start by meeting the two regions.

Think of the stack as a work desk. When a function is called, a small workspace opens up on the desk (a frame on the call stack); local variables, parameters and small primitive values go there. When the function finishes, that space is cleared away in one go, no questions asked. The heap is the warehouse in the back: objects, arrays, functions and the values closures hold on to live there. A variable on the desk doesn't hold the object itself, just a label pointing to its spot in the warehouse.

function createOrder() {
  const quantity = 2;
  const item = { name: "Pen", price: 15 };
  return item;
}

const order = createOrder();
console.log(order.name); // Pen

When the function returns, its frame leaves the desk; both quantity and the item label are gone. But the { name: "Pen", price: 15 } object on the heap lives on, because order now points to it. The value versus reference distinction we saw earlier comes straight from here. A small honesty note: engines decide for themselves where to put what, and they optimize heavily. This split is a mental model, and it's accurate enough to understand what's going on.

Reachability: What Does the Garbage Collector Look At?

The garbage collector (GC) decides what gets thrown out of the warehouse, and it asks a single question: "Can this object be reached, starting from the roots?" Roots are the starting points the engine always considers alive: the global object, the local variables of the functions currently running, and things the engine holds on to itself, such as the callback of a pending timer or event listeners attached to an element on the page.

The core algorithm is called mark-and-sweep. The GC starts from the roots, follows references and marks every object it reaches, then sweeps away everything that wasn't marked. That's why circular references aren't a problem:

let a = { name: "a" };
let b = { name: "b" };
a.neighbor = b;
b.neighbor = a;

a = null;
b = null;
// The two objects still point to each other, but they can't be reached from the roots.
// Both are candidates for collection.

Modern engines go one step further: most objects die young. Temporary objects created inside a function and dropped right away get a separate, small area that is cleaned in frequent, cheap rounds. Objects that survive for a long time are moved to an "old" area that is scanned less often. This is called generational GC. The important part: the engine decides when the GC runs; you can't trigger it from JavaScript code.

Now back to the opening question. The console prints Ada first, then true. user = null only removed one label; the object can still be reached through the list array. So it isn't a candidate for removal, and the entry in the WeakMap stays put too. Assigning null doesn't mean "free the memory", it only means "I'm no longer holding this object".

Common Memory Leaks: Listeners and Timers

A memory leak is memory you no longer need that can still be reached from the roots. The GC doesn't make mistakes; a leak is always a reference we forgot about. The classic one is the forgotten event listener:

function openPanel() {
  const bigData = new Array(1_000_000).fill("data");
  window.addEventListener("resize", () => {
    console.log("Panel data:", bigData.length);
  });
}

Every time openPanel is called, a new listener is added to window, and each listener holds its own bigData through a closure. window lives as long as the page is open; even after the panel closes, those arrays are never collected. The fix is to remove the listener when you're done. The AbortController we saw earlier does this very cleanly:

function openPanel() {
  const bigData = new Array(1_000_000).fill("data");
  const controller = new AbortController();

  window.addEventListener("resize", () => {
    console.log("Panel data:", bigData.length);
  }, { signal: controller.signal });

  return () => controller.abort();
}

const closePanel = openPanel();
// When the panel closes:
closePanel();

When abort() is called, every listener added with that signal is removed at once. If a panel listens to five different events, you clean up all five with one line.

Timers leak the same way. A callback set up with setInterval is kept alive by the engine until you call clearInterval, and everything the callback can reach lives on with it:

const state = { messages: [] };

const timer = setInterval(() => {
  state.messages.push(new Date().toISOString());
}, 1000);

// When this part of the page closes:
clearInterval(timer);

This example has two problems at once: the timer keeps state alive, and the messages array grows without limit. A structure that grows without limit behaves like a leak over time, even when the references are correct.

Common Memory Leaks: Closures and Detached DOM

Closures, as we saw earlier, keep access to the variables in the scope where they were defined. If a long-lived closure touches a large piece of data, that data lives long too:

function prepareReport() {
  const rawData = new Array(1_000_000).fill(42);
  return {
    summary: () => `Record count: ${rawData.length}`
  };
}

const report = prepareReport();
console.log(report.summary()); // rawData stays in memory as long as report lives

All we need is a number, yet we're carrying an array with a million elements. The fix is simple: compute what you need up front and give only that to the closure. If you write const count = rawData.length; and use count in summary, the array can be collected. Engines generally keep only the variables a closure actually uses, but closures created in the same scope can share that context. The safest approach is to never let large data into the view of a closure that will live a long time.

The fourth classic is detached DOM. You removed an element from the page, but you're keeping a reference to it somewhere in JavaScript:

const removed = [];

function deleteRow(row) {
  row.remove();
  removed.push(row); // kept for an "undo" feature
}

The element is no longer on screen, but the removed array keeps it and its entire subtree alive. An undo feature is a good idea; the problem is that it has no limit. Keeping the last twenty actions and letting go of the rest, or storing a small piece of data that can rebuild the element instead of the element itself, is usually enough.

WeakMap and WeakSet: Attaching Data Without Holding On

WeakMap, which we briefly mentioned when covering Map and Set, exists for exactly this kind of trouble. It holds its keys weakly: if the key object can't be reached from anywhere else, the entry disappears along with it. A WeakMap key doesn't keep an object alive.

It's a perfect fit for storing extra information about an element without touching the element itself. Let's count how many times elements on the page are clicked:

const clicks = new WeakMap();

document.addEventListener("click", (e) => {
  const counter = clicks.getOrInsertComputed(e.target, () => ({ count: 0 }));
  counter.count++;
  console.log(e.target.tagName, counter.count);
});

Here we used getOrInsertComputed, which came with ES2026: if the key exists it returns its value, otherwise it runs the function, stores the result and returns it. It has been available in all current browsers since February 2026; for older browsers you do the same with has, get and set. When an element is removed from the page and isn't held anywhere else, its counter goes away on its own. Had you done the same with a Map, every removed element would live inside the Map forever.

WeakSet is the set version of the same idea; it's used to mark "have I seen this object?". Both have deliberate limits: keys must be objects (ES2023 also allowed symbols that aren't in the global registry; Firefox added this in December 2025), they can't be iterated, and they have no size property. Since their contents depend on when the GC runs, the engine won't give you an answer to "how many entries are there right now".

WeakRef and FinalizationRegistry: Handle with Care

ES2021 brought two low-level tools. WeakRef is a reference that doesn't keep an object alive; you call deref() and get the object if it's still alive, or undefined if it has been collected. FinalizationRegistry lets you define a callback to be called after an object you registered has been collected. Let's see both together in a cache that holds large objects without forcing them to stay alive:

const cache = new Map();

const cleanup = new FinalizationRegistry((key) => {
  const ref = cache.get(key);
  if (ref && !ref.deref()) cache.delete(key);
});

function fetchCached(key, create) {
  const existing = cache.get(key)?.deref();
  if (existing) return existing;
  const fresh = create();
  cache.set(key, new WeakRef(fresh));
  cleanup.register(fresh, key);
  return fresh;
}

const r1 = fetchCached("january", () => ({ rows: new Array(100_000).fill(0) }));
const r2 = fetchCached("january", () => ({ rows: [] }));
console.log(r1 === r2); // true

Because the key is a string, a WeakMap won't help here; it's the value that needs to be held weakly. The code works, but as MDN plainly says, these tools are best avoided where possible. Here's why. GC timing varies from engine to engine, and even from situation to situation in the same engine. An object may never be collected even when nothing holds it, so deref() may never return undefined. The moment you put an object into a WeakRef or get it back with deref(), it's kept alive until the current job finishes; you'll never see it collected within the same synchronous run. And the FinalizationRegistry callback may run late, or not at all if the page closes. Never tie work that absolutely must happen, like saving user data or closing a connection, to it.

Heap Snapshots with the DevTools Memory Tab

Theory is nice, but to find a leak you have to measure. Let's write a small page that leaks on purpose. Save this as an HTML file and open it in Chrome or Edge:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Leak hunt</title>
</head>
<body>
  <button id="add">Add and remove 1000 rows</button>
  <ul id="list"></ul>

  <script>
    class Row {
      constructor(el) {
        this.el = el;
      }
    }

    const list = document.querySelector("#list");
    const archive = [];

    document.querySelector("#add").addEventListener("click", () => {
      for (let i = 0; i < 1000; i++) {
        const li = document.createElement("li");
        li.textContent = `Row ${i}`;
        list.append(li);
        archive.push(new Row(li));
      }
      list.replaceChildren();
      console.log("Detached elements in archive:", archive.length);
    });
  </script>
</body>
</html>

Now let's go step by step. Open DevTools and switch to the Memory tab. With "Heap snapshot" selected, press "Take snapshot"; that's your first measurement. Before taking a snapshot, the engine runs a garbage collection itself, so everything you see in the list really is reachable. Then click the button on the page three times and take a second snapshot.

In the second snapshot, type Detached into the filter box at the top. In the row whose name starts with Detached and shows li elements you'll see 3000 entries (depending on your DevTools version it appears as Detached <li> or Detached HTMLLIElement). These are the lis that aren't on screen but are still in memory. Click one; in the Retainers panel below you'll see what holds it, namely the Row object and the archive array that holds that. You can also search for your own class by typing Row in the filter; that's one small benefit of using named classes. If you pick "Comparison" from the view menu, you can see side by side what was added between the two snapshots. Firefox's Memory panel also lets you take and compare snapshots; in Safari, similar information is in the JavaScript Allocations recording under the Timelines tab.

Common Pitfalls

The console holds references too. DevTools keeps the objects you print with console.log alive so you can inspect them. If you dump large objects into the console while hunting a leak, you've created a leak yourself. Clear the console before you measure.

Accidental globals. In sloppy mode, assigning to an undeclared variable adds a property to the global object, and that value lives for the whole page. Modules and "use strict" turn this mistake into a ReferenceError, which is one more good reason to use modules.

Unbounded caches. "Let's compute it once and keep it in a Map" starts out as a good idea, and then the Map never shrinks. A cache needs a size limit, a time limit, or, if its keys are objects, a self-cleaning structure like WeakMap.

Nulling everything. Local variables inside a function disappear when the function finishes anyway; setting them to null one by one clutters the code and gains you nothing. Focus on long-lived structures: global or module-level variables, listeners added to window and document, timers and caches.

A Small Experiment

★☆☆ Predict the output: let a = { data: "x" }; const ws = new WeakSet([a]); const copy = a; a = null; console.log(ws.has(copy)); Is the object a candidate for collection? Why?

★★☆ On the leak hunt page, make archive keep at most the last 100 entries. Take two snapshots with the same steps and see how the number of detached li elements changes.

★★★ On a small HTML page, hold an object with a WeakRef, drop your normal reference to it and register a FinalizationRegistry callback. Trigger the GC by hand with the trash can icon ("Collect garbage") in the Memory tab and observe when the callback runs. Explain why you still get the object back when you call deref() on the same line.

Memory leaks rarely show up as an error message; more often they show up as a page that gets slower over time. In the next post we'll focus on slowness itself: first how to measure it, then how to speed a page up with debounce, throttle, requestAnimationFrame and by avoiding layout thrashing.