Garbage Collection in JavaScript - how the engine cleans up after your code

Every object you create in JS takes up space in computer memory. JavaScript removes unused objects automatically, making room for new ones. But the engine sometimes can't tell that a given object is no longer needed - and then the object stays in memory. That's how memory leaks happen. A post about how this cleanup works (mark-and-sweep), the most common pitfalls, and when you actually reach for WeakMap or WeakRef.
Every object, every array, every string in JavaScript takes up space in computer memory. When you write const arr = [], a fragment of RAM gets allocated to represent that object. Memory is finite - if your code only adds objects and never removes the old ones, eventually it runs out.
JavaScript performs this cleanup automatically. A mechanism in the engine called the garbage collector (henceforth: GC) periodically analyzes memory and removes objects that are no longer needed - making room for new ones. In most cases this happens without any input from the programmer: a function ends, local variables disappear, objects get cleaned up.
Until one day a browser tab is using 2GB of memory and the question “where is all this memory going” stops being academic. Automatic cleanup has one condition: GC can remove an object only when nothing is using it anymore. The engine decides when to clean up, but you decide whether it has any basis to. If your code is holding an object somewhere (even unintentionally), GC has no way to tell that the object is no longer needed - and it leaves it in memory. That’s a memory leak: an object that should have been removed but is still taking up space.
This post is about how the engine makes the “still needed / can be removed” decision, where programmers most often block its way, and when you actually reach for WeakMap instead of regular Map.
#Reachability - when an object is “still needed”
The key question: how does the engine recognize that an object can already be removed from memory?
The first algorithm that was historically tried is reference counting. Every object has a counter - the number of places in code that currently point to it (that is, have access to it). A new variable holding the object appears - the counter goes up. The old variable disappears - the counter goes down. When it drops to zero, it means nobody has access to the object anymore, so it can be removed from memory.
The mechanism looks correct. It has, however, one serious problem:
function f() {
const a = {};
const b = {};
a.partner = b;
b.partner = a;
}
f();After the function exits, nothing on the outside has access to a or b. But the reference counter for each of them shows 1 - because they point to each other. In an algorithm based on reference counting, both stay in memory forever. A cycle with no entry point from outside causes a memory leak. No modern JS engine still uses reference counting, exactly for this reason.
The algorithm that all engines use is based on a different intuition: reachability.
Picture all objects in memory as dots on a diagram, connected by arrows showing “who points to whom” (if variable a holds object b, there’s an arrow from a to b). This picture is called a reference graph. Some points on it are special - the so-called roots: the global object (window in the browser, globalThis generally), the current call stack, all active closures.
An object is reachable if there’s any path along arrows from a root to it. If no such path exists, the object is unreachable and nothing will ever access it again. GC, run from time to time, finds all reachable objects (by walking the arrows from the roots) and removes the rest from memory.
This algorithm is called mark-and-sweep and has two phases:
- Mark: starts from the roots and recursively visits everything reachable through references. Each visited object gets a “live” marker.
- Sweep: walks all of memory and removes every object without a “live” marker from the mark phase.
Going back to the a.partner = b; b.partner = a example - mark-and-sweep handles it without any trouble. Since there’s no way to reach this pair of objects from any root (the function they were created in has ended), nothing marks them in the mark phase. In the sweep phase they go to the trash together, despite holding each other. That’s enough to understand all memory leaks in JS: an object stays in memory as long as anything reachable from a root holds it.
The widget below lets you run mark-and-sweep on four scenarios of an object graph:
- Everything alive - all objects are reachable from a root.
- Cycle without root - two objects holding each other, but nothing on the outside has a way to reach them (the classic argument against reference counting we came back to above).
- Detached subgraph - a cluster of several objects cut off from the root.
- Listener holding DOM - a real-world memory leak pattern where a listener still holds a reference to a DOM element that’s no longer needed.
The “Run GC” button starts the mark phase - the engine begins at the root and step by step visits everything it can reach (the colors in the widget show what stage of marking each object is in). Then sweep removes everything that wasn’t visited. The takeaway: after the mark phase, unvisited objects are definitely unreachable - the order in which the engine walks the arrows doesn’t matter, only whether a path exists.
Every object has a path from a root. After mark-and-sweep nothing is freed - all reachable.
- white (not yet visited)
- gray (in mark queue)
- black (reachable from root)
- swept (collected by GC)
#What’s happening in V8 under the hood
In practice, engines don’t analyze all of memory (called the heap - the area where all JS objects are stored) every time mark-and-sweep runs. The algorithm is the same, but there are optimizations around it worth knowing about - they affect when and how long the application pauses for the engine to clean up.
The assumption underlying V8 is the so-called generational hypothesis: most objects have short lifespans. Temporary arrays in loops, local structures in functions, event objects - a large portion of them are unreachable after just a few event loop iterations. That’s why V8 splits the heap into a young generation and an old generation:
- Young generation (small space, cleaned often) - all new objects go here. The algorithm is Scavenger in the semi-space copying variant. It works like this: V8 splits the young generation into two equal halves. At any given moment only one is in use, the other sits empty. When the in-use half fills up, the engine copies reachable objects to the empty half and zeroes out the old one in one move (without analyzing what was left there). Roles swap. The operation is cheap because, given that most objects have short lifespans, there isn’t much to copy.
- Old generation (large space, cleaned less often) - objects that have survived several cycles in the young generation end up here. The algorithm is Mark-Compact - classic mark-and-sweep plus an extra phase called compaction. After the sweep, memory looks like a parking lot with gaps between cars (where deleted objects used to be). These gaps make it harder to insert new objects when they need contiguous space. Compaction shifts all surviving objects next to each other, leaving one large, contiguous free space for new allocations.
Additionally, V8 does tri-color marking: every object is white (not yet visited), gray (visited, but its children not yet) or black (processed). Marking ends when there are no more gray objects - the white ones are then unreachable. This colored state recording lets the engine do marking concurrently: part of the work is done by a worker thread while the main thread keeps running JS. In Chrome 64 and Node.js 10, V8 enabled concurrent marking, reducing the time the main thread waits on GC by about 70%.
V8 also performs garbage collection during the browser’s idle time - between animation frames, when the Blink scheduler has 16ms scheduled for rendering but the current frame rendered in 8ms. The result: about 43% of GC work happens without pausing animation.
The practical takeaway from this mechanism is the following: you can’t effectively “help” GC from application code. You can only get in its way by holding references that aren’t needed.
#Four classic memory pitfalls
GC can find objects that nobody is using anymore. The problem appears when the programmer accidentally holds an object they don’t need, without realizing it. From GC’s point of view the object is still reachable from a root, so it stays in memory. Four patterns that account for most memory leaks in JS:
#1. Detached DOM nodes
You hold a reference to a DOM element in a JS variable, then you remove that element from the page with element.remove(). The element disappears from the screen, but the reference in JS keeps holding it - along with its entire subtree:
let cachedNode = document.querySelector(".big-list");
cachedNode.remove(); // gone from DOM
// But `cachedNode` still holds the node and everything it holds:
// children, events, data. You have to manually break it:
cachedNode = null;In applications with large lists and node caching (e.g. for animations, undo/redo) it’s easy to end up with thousands of detached nodes sitting in memory.
#2. Listeners, intervals, timeouts
addEventListener, setInterval, setTimeout register a function with an external mechanism (DOM, scheduler). The mechanism keeps a reference to the function, and the function as a closure keeps its entire scope:
function setupChart() {
const data = new Array(1_000_000).fill(0);
window.addEventListener("resize", () => {
redraw(data); // closure holds `data`
});
}
setupChart();
// `data` lives as long as the listener is attached - which is forever
// (unless someone calls removeEventListener with the same function reference).The same applies to setInterval left running after a component unmounts, setTimeout scheduled for 5 minutes from now, or an EventEmitter subscription. Every add... has a matching remove... and a designated owner who calls it. In frameworks that owner is the cleanup in useEffect / onUnmounted.
#3. Closures with shared scope
The most insidious kind of leak, because it isn’t obvious from reading the code. All functions declared in the same scope share the same Environment Record. If even one of them stays alive, the whole scope stays - along with every variable any function in that scope references:
let theThing = null;
function replaceThing() {
const previousThing = theThing; // big object from previous call
const unused = () => {
// never called, but its presence keeps `previousThing` alive
if (previousThing) console.log("hi");
};
theThing = {
longText: new Array(1_000_000).join("*"),
someMethod: () => {}, // lives outside, so it holds the scope
};
}
setInterval(replaceThing, 1000);The key: unused is never called, but the fact that it references previousThing keeps previousThing alive in scope. And since someMethod lives outside and holds the same scope, the chain of past calls grows with each subsequent call to replaceThing. I cover the Environment Records mechanism in more detail in the closure post.
#4. The unbounded cache
A cache as a Map or plain object that only grows and is never cleared:
const cache = new Map();
function expensiveLookup(user) {
if (cache.has(user.id)) return cache.get(user.id);
const result = compute(user);
cache.set(user.id, result);
return result;
}Map holds strong references to keys. As long as cache is alive, every user.id (and the associated result) stays. If user is an object that should disappear (e.g. a logged-in user who logged out), the cache keeps holding it. The fix: the next section.
#Weak references - when to actually reach for them
The rule from the start of the post: an object stays in memory as long as anything holds it. Sometimes you want to deliberately opt out of this mechanism - have access to an object but not block GC. JS offers weak references for this - references that GC ignores when deciding “is this object still needed”. Practically, two constructs matter:
WeakMap/WeakSet- work likeMap/Set, but keys are held weakly. Classic use case: metadata attached to DOM elements. When an element disappears from the page and nothing else holds it, its entry in theWeakMapdisappears with it - no manualcache.delete(el)needed.WeakRefandFinalizationRegistry- a weak reference to a single object plus an optional callback after it’s collected. MDN warns directly: “best avoided if possible”. The spec deliberately makes no guarantee about when - or whether - the callback runs - don’t build application logic around it.
A practical rule: in 99% of cases that need a weak reference, what you need is WeakMap. You’ll see WeakRef maybe once in your career (e.g. an object cache with cleanup of external resources). In code review it should be a flag that prompts “do we really need this?”.
#What to take away
Three things that everything above rests on:
- An object stays in memory as long as anything reachable from a root holds it. This is the only mental model you need when debugging memory leaks. “Why is this thing still alive?” - the answer always takes the shape of a chain of references from a root.
- Every
add...has a matchingremove.... Listeners, intervals, subscriptions - if you register something, plan who unregisters it and when. In frameworks that’s cleanup in lifecycle hooks; in vanilla JS you keep track of it yourself. - Closures share scope with their siblings. If function
Areturns a long-lived function and another functionBthat references large variables sits next to it in the same scope - those variables stay in memory as long asA. It’s not a bug, it’s a consequence of how Environment Records work.
In practice: if you open Chrome DevTools and see a memory chart that only goes up, you go back to point 1. The heap snapshot will show you the reference path from a root to the problematic object. Removing one reference along that path is enough - the object stops being reachable and GC removes it.
Comments
Loading comments…