JavaScript Advanced #5 Memory Model and GC

If #4 Event Loop covered how JavaScript handles time, this post is about memory. JavaScript manages it automatically — which also makes it a place where accidents happen often.

JavaScript has automatic memory management #

In languages like C, you allocate/free memory yourself. JavaScript has a garbage collector (GC) that cleans up automatically.

The GC’s rule for cleanup is simple.

Keep reachable objects; reclaim unreachable ones.

Reachability is the key.

Reachability #

Any object reachable from the following roots is considered reachable.

  • The global object (browser: window, Node: global/globalThis)
  • Function parameters and locals on the current call stack
  • Every object referenced from those
reachable vs unreachable
let user = { name: 'Curtis' };
// user references the object → reachable

user = null;
// no more references → unreachable → GC candidate

user = null cuts the reference, removing the path to the object. GC reclaims it on the next round.

Two objects referencing each other still get reclaimed when isolated #

The old GC algorithm (reference counting) couldn’t free memory in cycles. JavaScript’s modern GC (mark-and-sweep) doesn’t have that limit.

cycles are OK
function makeCycle() {
  const a = {};
  const b = {};
  a.ref = b;
  b.ref = a;   // mutually referenced
}

makeCycle();
// after the function ends, both a and b are unreachable → both GC'd

Nothing outside references a or b — even though they reference each other, they aren’t reachable, so they’re reclaimed.

Common leak patterns #

Even with automatic GC, unintentionally keeping references alive causes memory to accumulate. Here are the common patterns.

1) Piling up in globals #

global leak
window.cache = window.cache || {};

function fetchUser(id) {
  // store result in a global cache — survives forever
  window.cache[id] = result;
}

window.cache is global and always reachable — every object inside it is never GC’d. Clear the cache periodically, or cap its size.

size-limited cache
const cache = new Map();
const MAX = 100;

function setCached(key, value) {
  if (cache.size >= MAX) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  cache.set(key, value);
}

For a real LRU cache, a library (lru-cache) is safer.

2) References to detached DOM nodes #

DOM leak
const buttons = [];

function setup() {
  const btn = document.createElement('button');
  document.body.appendChild(btn);
  buttons.push(btn);   // JavaScript array references it
}

function cleanup() {
  document.body.innerHTML = '';   // gone from DOM
  // but the buttons array still references it → not GC'd
}

Once removed from the DOM tree, if JavaScript still holds a reference to it, the element stays in memory. Detached node leaks are very common.

The fix: when the element is no longer needed, sever the JavaScript-side reference as well.

solution
function cleanup() {
  document.body.innerHTML = '';
  buttons.length = 0;   // empty the array
}

3) Not unregistering event handlers #

handler leak
function attach() {
  const onClick = () => { /* ... */ };
  button.addEventListener('click', onClick);
  // no removeEventListener
}

If you don’t call removeEventListener on unmount or when the handler is no longer needed, the handler and its closure stay alive. React’s useEffect cleanup is exactly the pattern that addresses this.

React cleanup pattern
useEffect(() => {
  const onClick = () => { /* ... */ };
  button.addEventListener('click', onClick);

  return () => {
    button.removeEventListener('click', onClick);
  };
}, []);

4) Not clearing timers #

timer leak
const id = setInterval(() => {
  fetchData();
}, 1000);
// without clearInterval, runs forever

Same pattern — without cleanup, the interval survives indefinitely. Always clear it on component unmount.

5) Closures capturing big objects #

The case lightly mentioned in #1 Closures.

closure holds a heavy object
function setup() {
  const huge = new Array(1_000_000);   // big array
  process(huge);

  return function() {
    console.log('hi');   // huge unused, but may still be captured
  };
}

const fn = setup();   // huge may survive via fn

Modern engines try to release unused captured variables, but it’s not guaranteed. To release explicitly:

explicit release
function setup() {
  let huge = new Array(1_000_000);
  process(huge);
  huge = null;   // release once not needed

  return function() {
    console.log('hi');
  };
}

WeakRef, WeakMap, WeakSet — weak references #

Tools to opt out of GC reachability. Weak references don’t keep objects alive.

WeakMap — keys are weak #

WeakMap
const cache = new WeakMap();

function attach(user, data) {
  cache.set(user, data);
}

let u = { id: 'u1' };
attach(u, 'data');

u = null;   // u becomes unreachable → WeakMap entry is auto-removed

Map keeps key objects alive; WeakMap doesn’t. It’s a good fit for attaching metadata to an object so that the metadata goes away automatically when the object is reclaimed.

Differences:

  • Map — keys can be any value, keeps key objects alive, iterable
  • WeakMap — keys must be objects, doesn’t keep key objects alive, not iterable

WeakSet — values are weak #

WeakSet
const visited = new WeakSet();

function process(node) {
  if (visited.has(node)) return;
  visited.add(node);
  // ...
}

The weak version of Set. Well suited for visited tracking — the same pattern used in JSON stringify’s circular-reference detection (Intermediate #7).

WeakRef — ES2021 #

A weak reference to an object itself.

WeakRef
let user = { name: 'Curtis' };
const ref = new WeakRef(user);

ref.deref();   // the user object (if still alive)

user = null;
// ... after GC happens
ref.deref();   // undefined (reclaimed)

WeakRef is uncommon. You’ll see it building caches or unusual data structures. Rarely used in regular code.

FinalizationRegistry — know the reclamation moment #

A tool to receive a callback when an object is GC’d.

FinalizationRegistry
const registry = new FinalizationRegistry((token) => {
  console.log(`cleaned: ${token}`);
});

let user = { name: 'Curtis' };
registry.register(user, 'user-token');

user = null;
// after GC moment — 'cleaned: user-token'

When it will be called is not guaranteed — the GC timing is up to the engine. It’s meaningful only in very specific cases such as cleaning up external resources (native handles), and is rarely used in application code.

Memory debugging — browser tools #

Chrome DevTools’ Memory tab is the most powerful tool.

Heap snapshot #

Takes a snapshot of every object in memory. You can see how many instances of each class exist, and follow the reference path that explains why an object wasn’t GC’d.

Allocation timeline #

Graphs how memory grows over time. The standard tool for tracking down memory leaks.

Detailed usage deserves its own guide, but remember: when you suspect a memory leak, this is the first place to look.

JavaScript memory model — primitives vs reference types again #

The primitive-vs-reference distinction from Basics #2 takes new meaning in the memory model.

  • Primitives — the value itself sits in the variable. Small and light.
  • Objects — only the location of the object is in the variable. The actual object lives on the heap.

A function’s local primitives live on the call stack and disappear when the function returns. Objects live separately on the heap and survive as long as something holds a reference to them.

Wrap-up #

What we covered:

  • JavaScript has automatic memory management (GC)
  • GC is based on reachability — reachable from globals/stack
  • Modern GC is mark-and-sweep — even cycles get collected
  • Common leaks — global caches, detached DOM, uncleaned handlers/timers, heavy closures
  • WeakMap/WeakSet don’t keep keys/values alive
  • WeakRef / FinalizationRegistry for very specific cases
  • Memory debugging with the DevTools Memory tab

In the next post (#6 Symbol, WeakRef, Proxy) we cover JavaScript’s more exotic tools — Symbol for collision-free keys, and Proxy for intercepting object operations.

X