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
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.
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 #
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.
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 #
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.
function cleanup() {
document.body.innerHTML = '';
buttons.length = 0; // empty the array
}3) Not unregistering event handlers #
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.
useEffect(() => {
const onClick = () => { /* ... */ };
button.addEventListener('click', onClick);
return () => {
button.removeEventListener('click', onClick);
};
}, []);4) Not clearing timers #
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.
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:
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 #
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, iterableWeakMap— keys must be objects, doesn’t keep key objects alive, not iterable
WeakSet — values are weak #
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.
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.
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/WeakSetdon’t keep keys/values aliveWeakRef/FinalizationRegistryfor 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.