Garbage collection across engines & the DOM
Learn how JavaScript engines collect memory differently while tracing JavaScript and C++ DOM objects as one reachable graph.
- 01Compare how engines clean memoryName the main cleaning choices in V8, SpiderMonkey, JavaScriptCore, and Hermes.
- 02Follow a DOM button's two partsExplain why a button's JavaScript wrapper and its C++ data must be traced together.
- 03Explain conservative scanningExplain how a stack number that looks like an address can keep an object and pin it in place.
One language rule, many collectors
A garbage collector (GC for short) is the part of a JavaScript engine that frees memory your program no longer needs. An engine is the program that runs your JavaScript, like V8 in Chrome.
JavaScript itself gives only one rule. An object may be freed once your running program can no longer reach it. The language doesn't say when the cleaning happens, how memory is laid out, or whether objects get moved. Each engine chooses those things, together with the browser or app around it, called the host.
A tracing collector works in three steps:
- It starts from the roots. Roots are places your program can always use, like global variables and the variables of functions that are running now.
- It follows every reference, from one object to the next.
- It keeps every object it reached. Those objects and the references between them are called the reachable graph.
A collector strategy is one engine's own way to split up that work: by the age of objects, into short pauses, onto helper threads, and into separate areas of memory.
Why does this matter? Your code should never count on a cleanup at one exact moment. But knowing the main strategies helps you read a memory tool. It also helps you see why a button you removed from the page can still stay in memory, held by a listener or a wrapper.
Different families clean their homes on different schedules. One family does a quick tidy every day. Another family tidies one small part at a time, between other chores. Both families keep the things someone still uses.
- In real life: Every family keeps the things someone still uses
- In JavaScript: Every engine keeps reachable objects
- In real life: One family tidies new things every day
- In JavaScript: An engine may clean new objects often
- In real life: Another family tidies one room at a time
- In JavaScript: An engine may clean in small pieces
- In real life: The house rule stays the same
- In JavaScript: JavaScript's rule doesn't pick a cleaning method
When you read an article about an engine, sort each sentence into one of two groups:
- A language rule tells you what your program can see. Example: an object can no longer be used once the last path to it is gone.
- An engine detail tells you how one engine does the work. Example: it uses a helper thread, or it moves objects that are still in use.
This habit saves you from a common debugging mistake. A heap snapshot is a saved picture of everything in memory. It may show a removed button today. It can't promise that the next cleanup runs right after one particular click. In V8, SpiderMonkey, JavaScriptCore, and Hermes, the useful question is the same: which root can reach this object right now?
V8: Orinoco and Oilpan
V8 is the engine in Chrome and Node.js. Its garbage-collection project is called Orinoco. Orinoco shares out the work in three ways: parallel, incremental, and concurrent. You'll see what each word means just below. V8 also has two kinds of cleanup:
- A quick, frequent cleanup of new objects, called the scavenger. Many new objects are thrown away soon, so checking them often frees memory quickly.
- A bigger cleanup of all objects. It marks every reachable object, sweeps away the rest, and can compact some pages. Compacting means moving the objects that are left close together.
function priceWithTax(price) {
const total = price + 3;
console.log(total);
}
priceWithTax(10);Line 5 calls the function with 10. Line 2 makes total. Line 3 prints 13. When the call ends, no code can use total any more. JavaScript doesn't say when V8 will reuse that memory.
A browser is mostly written in C++, another programming language. The parts of a page, like buttons, are called the DOM, and they live in C++ memory. Each DOM button also has a JavaScript wrapper: the object your code holds, which stands for the C++ button. Chrome uses Oilpan, a garbage collector for C++ objects. Together with V8, Oilpan can follow references from JavaScript into C++ and back.
const work = ["mark", "sweep"];
console.log(work.join(" then "));Line 1 makes a small list with two pieces of cleaning work. Line 2 prints mark then sweep. This code doesn't show V8's insides. It only names the work. A thread is a separate line of work that the computer can run next to others. Orinoco can share out the work in three ways:
- Parallel: JavaScript pauses, and several helper threads do the work at the same time.
- Concurrent: a helper thread does some of the work while your JavaScript keeps running.
- Incremental: the main thread does the work in small pieces, between pieces of your code.
None of these choices changes which reachable objects are kept.
A pointer is a memory address that points at an object. Your JavaScript code never sees C++ pointers, but the browser's own code uses them all the time. Following both sides together makes sure neither collector decides one side is garbage while the other side can still reach it.
SpiderMonkey: memory split into zones
SpiderMonkey is the engine in Firefox. Mozilla describes its collector with a long list of words. Here is what each one means:
- Tracing: it follows references from the roots.
- Precise: it knows exactly where every reference is, so it never has to guess.
- Incremental: it can do the work in small pieces.
- Generational: it sorts objects by age and checks new ones more often.
- Partly concurrent and parallel: some of the work runs on helper threads.
- Compacting: it can move objects that are left close together.
const user = { cart: { item: "tea" } };
console.log(user.cart.item);Line 1 makes a path: from user to a cart object, then to the string "tea". Line 2 prints tea. A precise collector knows which properties hold references as it follows a path like this.
The heap is the memory where objects live. SpiderMonkey splits its heap into parts called zones. It can clean one zone on its own. Objects in different zones can still point to each other.
Inside a zone, objects sit in arenas: small blocks of heap memory of one fixed size. When SpiderMonkey compacts, it moves objects between arenas. So it must also update every reference to a moved object.
const zones = { account: ["user"], shop: ["cart"] };
console.log(Object.keys(zones).join(","));Line 1 makes a small model with two named zones. Line 2 prints account,shop. A real zone is not a JavaScript property. It is a split inside the engine. It lets SpiderMonkey clean one zone at a time.
const arenas = [["user"], [], ["cart"]];
console.log(arenas.filter((arena) => arena.length).length);Line 1 makes three arenas. The middle one is empty. Line 2 counts the arenas that hold something and prints 2. In a real compacting cleanup, the objects that are left may move, so empty arenas can be given back. SpiderMonkey says compacting updates every reference to moved objects. It also says compacting is not done in small pieces: it runs in one go.
2. Compacting moves user and cart next to each other, so the empty space can be given back.Split into zones doesn't mean cut off. A user object in one zone can still point to a cart in another zone. A zone is just a smaller piece the collector can clean on its own. The engine keeps notes about references between zones. So a path that crosses zones is never lost during a cleanup.
JavaScriptCore: Riptide and stack scanning
JavaScriptCore is the engine in Safari. The WebKit team built a collector for it called Riptide. Riptide is concurrent: most of its work runs while your JavaScript keeps running. WebKit describes these parts of it:
- Helper threads share the marking work, in parallel.
- Write barriers: small checks that run when your code changes a reference, so the collector doesn't miss the change.
- Short stops at safe points: places in the code where the program can pause safely. Some steps need everything to hold still for a moment.
const cart = { item: "tea" };
const savedCart = cart;
console.log(savedCart.item);Line 1 makes the cart. Line 2 makes a second reference to the same object. Line 3 prints tea. A real collector must find every path to an object, even while the app keeps changing those paths.
The stack is the memory that holds the local values of functions that are running. Native code is code the computer runs directly: the engine's own C++, or machine code the engine made from your JavaScript. On the stack, native code keeps plain numbers, and the collector can't always tell what a number means. If a number could be the address of an object, Riptide plays safe and treats it as a reference. This is called conservative scanning.
const words = [7, 42, 99];
console.log(words.includes(42) ? "pin" : "move");Line 1 stores three plain numbers in this model. Line 2 prints pin, because 42 is in the list. Real engines don't check JavaScript values this way. WebKit checks the numbers on the stack against where objects really are in memory. This code only shows the safe choice.
Riptide also never moves objects. To tell old objects from new ones, it keeps a mark on each object between cleanups. It doesn't move the objects that are left into another area. That fits well with conservative scanning. If native code might still hold an object's address, leaving the object where it is is safer than moving it.
Conservative means careful. The collector would rather keep an object a little too long than free or move an object that native code might still use. That can waste some memory. But native code never ends up holding an address that points to nothing, called a dangling pointer, which could crash the program.
Hermes and Hades
Hermes is the JavaScript engine most React Native apps use. Its collector is called Hades. Hermes calls Hades mostly concurrent. It splits objects into two age groups:
- New objects, the young generation, get quick, frequent cleanups.
- Older objects, the old generation, get a mark-and-sweep cleanup. Most of that work runs on a background thread. It decides what to keep from the state of memory at the start of the cleanup.
Background work can make long pauses shorter. It still doesn't let your JavaScript know when a cleanup will happen.
const order = { item: "tea", count: 3 };
console.log(order.count);Line 1 makes one object. Line 2 prints 3. Hermes might keep this object with the young objects, move it to the old ones later, or free it. Your JavaScript can't choose.
const youngOrders = ["tea", "book"];
console.log(youngOrders.length);Line 1 makes two order names. Think of them as new objects. Line 2 prints 2. A collector that sorts objects by age is called generational. It checks new objects more often, because many of them are thrown away soon. This only makes cleaning faster. It doesn't promise that one order is freed first.
Use the same good habits in a browser or in a mobile app:
- Remove listeners you no longer need.
- Clear timers you no longer need.
- Keep caches small.
- Measure a real problem with a memory tool before you change how your code makes objects.
"Mostly concurrent" is a reason not to trust one smooth test run. The collector may still need short pauses. It may also behave differently on another phone. Measure on the devices and screens that matter. Then remove whatever holds on to memory by mistake, before you change how your code makes objects.
Tracing JavaScript and C++ together
A button on a web page is not only a JavaScript object. Your code sees the wrapper, the JavaScript object. The browser also keeps its own C++ data for the button. A listener function can hold on to the wrapper. Before the collector frees either part, it must follow the references in both directions.
const button = document.createElement("button");
button.textContent = "Buy";
button.onclick = () => console.log(button.textContent);
console.log(button.textContent);Line 1 makes the button. Line 2 sets its text. Line 3 stores a listener function that uses button. So the listener holds a reference back to the wrapper. Line 4 prints Buy.
The wrapper also stands for the C++ object that is the real button. Each browser links the two in its own way. But the collector can't treat the JavaScript side and the C++ side as two separate piles. Oilpan, working with V8, is one documented way to follow references across both sides.
const page = { button: "button" };
const listener = { button: page.button };
delete page.button;
console.log(listener.button);Line 1 makes a small model of a page. Line 2 copies the button into a listener model. Line 3 deletes the page's property. Line 4 still prints button, because the listener has its own path to it.
A retaining path is the chain of references from a root that keeps an object alive. On a real page, look at the retaining path before you assume that removing a node removed every path to it.
The step-through and the playground below use a small model of a button. Each name stands for one part:
window: the root.js-button: the JavaScript wrapper.js-listener: the listener function.cpp-button: the browser's C++ button.
window. It follows every arrow, even the ones that cross into the browser's C++ side, so all four parts are kept.Replay a small model of tracing a JavaScript button and its C++ DOM object together.
script
const graph = { window: ["js-button"], "js-button": ["cpp-button", "js-listener"], "js-listener": ["js-button"], "cpp-button": ["js-button"],};const result = traceBothHeaps(roots, graph);console.log(result.reachable.join(","));const roots = ["window"];
const graph = {
window: ["js-button"],
"js-button": ["cpp-button", "js-listener"],
"js-listener": ["js-button"],
"cpp-button": ["js-button"],
};
console.log(traceBothHeaps(roots, graph).join(","));Kept: cpp-button, js-button, js-listener, window
Garbage: none
The root reaches the JavaScript wrapper, the listener, and the C++ button, so all are kept.
Two families share one car. Before they sell it, both families check that nobody still needs it. In the same way, a button and its listener become garbage only when the trace has looked at both sides and found no path from a root.
- In real life: One family holds the car key
- In JavaScript: A JavaScript root reaches the wrapper
- In real life: The other family keeps the car papers
- In JavaScript: The C++ button is part of the trace too
- In real life: Both families check before selling the car
- In JavaScript: References on both sides are followed
- In real life: Nobody in either family needs the car
- In JavaScript: The removed button and its listener are garbage
Conservative stack scanning
Conservative stack scanning works like this: when a number on the native stack looks like an object's address, the collector treats it as a reference. So it never frees an object that native code still uses. The cost is false retention: a plain number may just happen to look like an address, so an unused object is kept.
const objectAddress = 42;
const stackWords = [7, 42, 99];
const pinned = stackWords.includes(objectAddress);
console.log(pinned ? "kept and pinned" : "can move");Line 1 gives the object a pretend address, 42. Line 2 puts 42 among the stack numbers. Line 3 finds the match. Line 4 prints kept and pinned. This is a model. It doesn't show real addresses in your browser.
42, which is also the object's address. The collector can't tell if it is a real pointer, so it keeps the object and doesn't move it.If the engine can't tell whether a number is really a pointer, moving the object could make that pointer wrong. Keeping the object in place is safer. An object that must stay in place is called pinned. That is why conservative scanning can stop some objects from being compacted.
Replay a small model of a conservative scan. A matching number can keep an object even when it was never a real reference.
script
const stackWords = [7, 42, 99];const result = scanConservatively(stackWords, address);console.log(result.state);const address = 42;
const stack = [7, 42, 99];
console.log(stack.includes(address));42 matches the address, so the model says: kept and pinned.
Compare the engines
The names below are useful starting points. They don't promise that every version of an engine works the same way. All four engines may change how they clean, while JavaScript keeps working the same.
Read each row as: this engine, how its collector works, and why that matters to you.
| Engine | How its collector works | Why it matters |
|---|---|---|
| V8 / Orinoco | Helper threads do most of the work, in parallel or while JavaScript runs. The main thread can also work in small pieces. The big cleanup marks, sweeps, and can compact. | In Chrome, Oilpan lets V8 trace C++ objects and JavaScript objects together. |
| SpiderMonkey | Precise, incremental, generational, partly concurrent, parallel, and compacting. Its heap is split into zones. | It can clean one zone on its own, even when objects point across zones. |
| JavaScriptCore / Riptide | Concurrent, generational, and parallel. It scans the native stack conservatively and never moves objects. | A number that might be a pointer keeps its object where it is. |
| Hermes / Hades | Mostly concurrent. New objects get quick cleanups. Old objects get mark-and-sweep, mostly on a background thread. | Background work makes pauses shorter, but it is still the engine's choice, not a promise. |
The table only gives the big picture. It tells you which engine a memory report or an article is about. It doesn't let your code count on a certain pause, address, or cleanup time.
What web developers should do
Different collectors don't change the first question to ask when you debug memory: which root still reaches this object? A removed button with a listener can stay in memory because your code, the browser, or a cache still points to it.
- Keep an object that a root can reach.
- Move the objects that are left close together, to remove gaps.
- Clean one part of the heap at a time.
- Visit a JavaScript wrapper, then its C++ DOM object.
- A matching stack number keeps address 42 alive.
- Remove an unused listener that keeps data alive.
Put each sentence in the right group. Read the explanation after each choice.
Start with the memory tools in the browser or in React Native. Look at the retaining paths, remove references you didn't mean to keep, and then measure again. A WeakRef holds an object without keeping it alive. Don't add a WeakRef just to hide a problem you don't understand yet.
Common misconceptions
- "All engines clean memory the same way." They all follow the same JavaScript rules. But each engine cleans in its own way.
- "A button removed from the page is freed at once." A listener, a wrapper, a cache, or a browser reference can still hold it.
- "A number that matches an address proves the object is in use." It is only a safe guess. It can keep memory that nobody needs.
- "Forcing a cleanup fixes a leak." A leak is usually a path you didn't mean to keep. A cleanup can't free anything that path still reaches.
Each row below names a term, says what it means, and says what it does not mean.
| Term | What it means | What it does not mean |
|---|---|---|
| Same JavaScript rules | Every engine keeps what a root can reach, and your code sees the same JavaScript behavior. | Every engine cleans memory in the same steps. |
| Conservative root | A stack number that might be a pointer, so the collector keeps its object to be safe. | Proof that the number really was a reference to the object. |
| DOM wrapper | The JavaScript object that stands for the browser's C++ DOM object. | A separate object that can be freed without looking at the C++ side. |
Practice exercises
Predict what this tiny graph prints.
const graph = { root: ["js"], js: ["cpp"], cpp: [] };
console.log(graph.root.length + graph.js.length + graph.cpp.length);It prints 2: one reference from root to js, and one from js to cpp.
What does this stack model print?
const words = [3, 42];
console.log(words.includes(42) ? "pinned" : "movable");It prints pinned, because the stack list holds 42.
Name the V8 garbage-collection project from this lesson.
V8 calls its garbage-collection project Orinoco.
What does SpiderMonkey call the parts of its heap that it can clean on their own?
SpiderMonkey calls the parts of its heap that it can clean on their own zones.
A checkout button was removed from the page. It still has a listener that uses a large order object. What should you remove?
Remove the event listener you no longer need. Then nothing keeps the removed button and its large order reachable.
Your app uses more memory after each page change. What should you do before you rewrite the component?
Measure first with a heap snapshot or an allocation profile. Then remove the path from a root that you find.
Check your understanding
Start with the shared rule: what can a root reach? Then name the engine feature or browser link that changes how the collector follows that rule.
Question 1 of 8This model lists the references from a JavaScript wrapper. What does it print?
Read the code, then predictconst js = ["cpp"]; console.log(js.length);Choose an answer to see the explanation.
Question 2 of 8What is Orinoco in this lesson?
Choose an answer to see the explanation.
Question 3 of 8Why can a conservative scan keep extra memory?
Choose an answer to see the explanation.
Question 4 of 8Which SpiderMonkey feature lets it clean parts of the heap on their own?
Choose an answer to see the explanation.
Question 5 of 8What does this print?
Read the code, then predictconst words = [7, 42]; console.log(words.includes(42) ? "pinned" : "movable");Choose an answer to see the explanation.
Question 6 of 8Why must a DOM wrapper and its C++ object be traced together?
Choose an answer to see the explanation.
Question 7 of 8What does this zone example print?
Read the code, then predictconst zones = { account: ["user"], shop: ["cart"] }; console.log(zones.shop[0]);Choose an answer to see the explanation.
Question 8 of 8A number on the stack matches an object's address. What is the safe conclusion?
Choose an answer to see the explanation.
Key takeaways
- JavaScript sets the rules your code can see. Each engine chooses how to clean memory.
- Orinoco (V8), SpiderMonkey's zones, Riptide (JavaScriptCore), and Hades (Hermes) are four different documented designs.
- A DOM button has a JavaScript wrapper and C++ data, so a full trace follows both sides.
- Conservative scanning picks safety over certainty, and it can pin objects in place.
- For a real leak, look at the retaining paths and remove references you didn't mean to keep.
Remember the one-liner.
Engines clean memory in different ways, but every safe trace keeps everything a root can reach.
Coming next: Heap snapshots in depth, where you read retaining paths and object sizes in a memory tool.