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 engine strategiesName the well-established collection choices behind V8, SpiderMonkey, JavaScriptCore, and Hermes.
- 02Trace DOM ownershipExplain why a JavaScript wrapper and its C++ DOM object must be considered together.
- 03Reason about conservative rootsExplain how an ambiguous native-stack value can retain and pin an object.
One language rule, many collectors
JavaScript gives you one observable rule: an object may be reclaimed when the running program can no longer reach it. The ECMAScript language does not choose a collection schedule, a heap layout, or a moving algorithm. Those are choices made by the engine and its host.
A tracing collector starts from roots, follows references, and retains the reachable graph. A collector strategy is the engine-specific way it divides that work into generations, pauses, helper threads, and heap regions.
That distinction is useful. Your code should not depend on a collection happening at one exact moment. But knowing the broad strategies helps you read a profiler and understand why a removed DOM node can still stay alive through a listener or a wrapper.
Different families clean their homes on different schedules. One does a quick pass each day, while another does small parts between other chores. Both keep the things somebody still uses.
- In real life: Every family keeps things still in use
- In JavaScript: Every engine retains reachable objects
- In real life: One family cleans rooms daily
- In JavaScript: An engine may collect young objects often
- In real life: Another cleans a room in small turns
- In JavaScript: An engine may do incremental work
- In real life: The rule stays the same
- In JavaScript: JavaScript behavior does not choose an algorithm
Where the analogy stops: Families can decide what is useful. A collector follows references; it cannot know your product intent.
When you read an engine article, sort each statement into two buckets. A language-level statement tells you what your program may observe, such as an object becoming unavailable after the last usable path disappears. An implementation statement tells you how one engine tries to do that work, such as using a helper thread or moving survivors.
This habit prevents a common debugging mistake. A heap snapshot may show a detached button today, but it cannot promise that the next collection runs after one particular click. The durable question is still the same in V8, SpiderMonkey, JavaScriptCore, and Hermes: which root can reach the object now?
V8: Orinoco and Oilpan
V8 calls its garbage-collection project Orinoco. Its published overview describes parallel work, incremental slices, and concurrent work. V8 also documents a young-generation scavenger and a major collection that marks, sweeps, and can compact selected pages.
function priceWithTax(price) {
const total = price + 3;
console.log(total);
}
priceWithTax(10);Line 2 creates total. Line 3 prints 13. When the call finishes, no JavaScript code can use that local through this call frame. The language does not promise exactly when V8 reuses its memory.
For Chromium's DOM implementation, Oilpan is the C++ garbage collector used with V8's unified heap support. V8's Oilpan documentation says this lets JavaScript and C++ object graphs be fully connected, which matters when a DOM wrapper has an edge to native DOM memory.
const work = ["mark", "sweep"];
console.log(work.join(" then "));Line 1 makes a tiny list of two kinds of collection work. Line 2 prints mark then sweep. This does not show V8 internals. It gives names to work that Orinoco can arrange with parallel helpers, short incremental slices, or concurrent background tasks.
Parallel means helpers work at the same time while JavaScript is paused. Concurrent means a helper can do supported collector work while JavaScript continues. Incremental means the main thread performs small collection slices between ordinary work. These choices do not change which reachable objects survive.
Oilpan adds an important host-side detail. JavaScript itself has no C++ DOM pointers in its syntax, but the browser implementation does. Unified tracing is about preserving the graph across that implementation boundary, so neither collector decides that one side is dead while the other side still reaches it.
SpiderMonkey's partitioned heap
Mozilla documents SpiderMonkey as a precise, incremental, generational, partially concurrent, parallel, compacting tracing collector. Precise means the engine knows where its managed references and stack roots are, rather than guessing from arbitrary words.
const user = { cart: { item: "tea" } };
console.log(user.cart.item);Line 1 creates a path from user to a cart and then to a string. Line 2 prints tea. A precise tracer knows which fields contain managed references as it follows this sort of graph.
SpiderMonkey's heap is partitioned into zones. A zone is a separately collectable heap partition, though objects in different zones can still refer to each other. Its documentation also says compaction moves allocations between arenas and therefore needs pointer updates.
const zones = { account: ["user"], shop: ["cart"] };
console.log(Object.keys(zones).join(","));Line 1 gives a teaching model two named partitions. Line 2 prints account,shop. A zone is not a JavaScript property. It is an engine boundary that lets SpiderMonkey schedule some collection work one partition at a time.
const arenas = [["user"], [], ["cart"]];
console.log(arenas.filter((arena) => arena.length).length);Line 1 shows two occupied arenas with an empty one between them. Line 2 prints 2. In a real compacting pass, survivors may move so empty arenas can be released. SpiderMonkey documents that compaction updates pointers to moved data and is not incremental.
“Partitioned” does not mean isolated. A user object in one zone can still point to a cart in another zone. The partition gives the collector a smaller unit it can schedule, while its remembered information makes sure cross-zone paths are not silently lost during a collection.
JavaScriptCore: Riptide and stack scanning
WebKit introduced Riptide as a concurrent garbage collector for JavaScriptCore. Its description covers concurrent draining, parallel marking work, write barriers, and short stops at safe points for operations that need a stable program state.
const cart = { item: "tea" };
const savedCart = cart;
console.log(savedCart.item);Line 1 creates the cart. Line 2 creates another JavaScript reference to the same object. Line 3 prints tea. A real collector must account for all reachable paths, even while an application changes its graph.
Riptide's published design also explains conservative native-root scanning. Native C++ and generated code can have stack words whose meaning is not fully described to the collector. A word that could point inside the managed heap is treated carefully.
const words = [7, 42, 99];
console.log(words.includes(42) ? "pin" : "move");Line 1 stores three plain words in this model. Line 2 prints pin because 42 is present. Real JavaScript values are not inspected this way. WebKit documents conservative scans of stack words and registers against object bounds, while this code only makes the safety decision visible.
Riptide is non-copying: its documented generational design uses sticky mark bits rather than moving survivors for an eden collection. If native code might still hold a location, retaining an object in place is safer than changing its location behind that possible pointer.
The word conservative sounds cautious because it is. The collector would rather keep an object a little longer than free or move an object that native code might still use. That can mean extra retained memory, but it avoids turning an uncertain native word into a dangerous dangling pointer.
Hermes and Hades
Hermes is the JavaScript engine commonly used by React Native. Its Hades collector is mostly concurrent: Hermes documents the young generation as generational work and the old generation as snapshot-at-the-beginning mark-sweep. Background old-generation work can reduce long pauses, but it does not make collection timing a JavaScript promise.
const order = { item: "tea", count: 3 };
console.log(order.count);Line 1 creates one object. Line 2 prints 3. Whether Hermes keeps it in a young region, promotes it later, or collects it is not a JavaScript API decision.
const youngOrders = ["tea", "book"];
console.log(youngOrders.length);Line 1 creates two order labels in a teaching stand-in for young allocations. Line 2 prints 2. A generational collector often examines recently created values more often because many do not survive for long. This is a performance strategy, not a promise that a particular order disappears first.
Use the same healthy habit in a browser or a mobile app. Release unneeded listeners, clear timers, keep caches bounded, and profile a real problem before changing an allocation pattern.
For an app author, “mostly concurrent” is a useful reason to avoid drawing conclusions from one smooth run. A collector may still need short coordinated pauses or different work on a different device. Measure on the device and workload that matter, then fix ownership paths before trying to tune allocation style.
Tracing JavaScript and C++ together
A browser button is not only a JavaScript object. JavaScript sees a wrapper, while the browser also has native DOM data. A listener can close over the wrapper. The collector must follow edges in both directions before it decides whether either representation can go away.
const button = document.createElement("button");
button.textContent = "Buy";
button.onclick = () => console.log(button.textContent);
console.log(button.textContent);Line 1 creates the button. Line 2 gives it visible text. Line 3 stores a function that reads button, so the listener closure has a JavaScript-to-JavaScript edge back to the wrapper. Line 4 prints Buy.
The wrapper also represents a C++ DOM object. The exact internal links differ by browser, but the lifetime decision cannot pretend that the JavaScript and C++ sides are two unrelated piles. Oilpan's unified-heap support is one documented example of crossing that boundary.
const page = { button: "button" };
const listener = { button: page.button };
delete page.button;
console.log(listener.button);Line 1 makes a small page model. Line 2 stores the button value in a listener model. Line 3 removes the page's property, yet line 4 prints button because the listener still has its own path. In a real page, inspect the retaining path before assuming that removing a node removed every route to it.
Replay an instrumented model of tracing across JavaScript and C++ DOM objects.
script
const graph = { window: ["js-button"], "js-button": ["cpp-button", "js-listener"], "js-listener": ["js-button"], "cpp-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": [],
};
console.log(traceBothHeaps(roots, graph).join(","));Reachable: cpp-button, js-button, js-listener, window
Garbage: none
The root reaches the JavaScript wrapper, listener, and C++ button.
Think of two families sharing one car. Before selling it, both must check that nobody in either family still needs it. A listener cycle becomes garbage only when the trace crosses both families and finds no root path.
- In real life: One family holds the car key
- In JavaScript: A JavaScript root reaches a wrapper
- In real life: The other family keeps the car paperwork
- In JavaScript: A native object participates in tracing
- In real life: Both check before selling the car
- In JavaScript: Both heap edges are considered
- In real life: No family needs it
- In JavaScript: The detached cross-heap graph is garbage
Where the analogy stops: Families can speak and negotiate. Tracing uses recorded references and host rules, not conversations.
Conservative stack scanning
Conservative stack scanning treats a native stack value as a possible pointer when it looks like one. This avoids freeing a live native reference by mistake. The trade-off is false retention: a plain number may happen to resemble an address.
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 model an address, 42. Line 2 puts 42 among stack words. Line 3 detects the match. Line 4 prints kept and pinned. This is a model, not a view of your browser's addresses.
When an engine cannot tell whether a word is really a pointer, moving the object could make that possible pointer invalid. Keeping the object in place is safer. That is why conservative scanning can limit compaction for affected objects.
Replay a conservative-root model. A matching number may retain an object even when it was not an intentional 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));The model says: kept and pinned.
Compare the engines
The names below are useful landmarks, not promises that every release behaves identically. All four engines may change their implementation while keeping JavaScript semantics stable.
| Engine | Documented approach | Useful connection |
|---|---|---|
| V8 / Orinoco | Mostly parallel and concurrent, with incremental fallback; major collection marks, sweeps, and may compact. | Oilpan can trace managed C++ and JavaScript graphs together in Blink. |
| SpiderMonkey | Precise, incremental, generational, partially concurrent, parallel, compacting, and partitioned into zones. | Zones can be collected independently even when objects cross zones. |
| JavaScriptCore / Riptide | Concurrent, generational, parallel, conservative, non-copying mark-sweep collection. | A possible native pointer is retained, so it can limit moving affected objects. |
| Hermes / Hades | Mostly concurrent: young collection remains generational and the old generation uses snapshot-at-the-beginning mark-sweep. | Background old-generation work reduces pauses but remains an implementation detail. |
The table is deliberately broad. It tells you where a profile or engine document belongs, but it does not give application code permission to assume a particular pause, address, or collection time.
What web developers should do
Different collectors do not change the first debugging question: what root still reaches this object? A detached button with a listener can remain live because application code, a host handle, or a cache still points into its graph.
- Keep an object that a root can reach.
- Move survivors to reduce fragmentation.
- Collect one heap partition 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 retains data.
Place each claim under the right kind. Read the explanation after every choice.
Start with browser or mobile profiling tools. Inspect retaining paths, remove accidental roots, and then measure again. Do not add WeakRef merely to make an uncertain ownership problem disappear.
Common misconceptions
- “All engines collect in the same way.” They share language semantics, not one implementation.
- “A removed DOM node is immediately free.” A listener, wrapper, cache, or host edge can still retain it.
- “A conservative match proves a live object.” It is a safe guess and can retain memory unnecessarily.
- “Forcing collection fixes a leak.” A leak is usually an unintended reachable path.
| Term | Meaning | Not this |
|---|---|---|
| Same JavaScript rules | Reachability and observable JavaScript behavior remain the language-level model. | Every engine using identical collection phases. |
| Conservative root | A stack word that might be a pointer is retained for safety. | Proof that the number was intentionally an object reference. |
| DOM wrapper | A JavaScript object representing a host C++ DOM object. | A second independent lifetime that can be collected without cross-heap tracing. |
Practice exercises
Predict the printed output from this tiny graph.
const graph = { root: ["js"], js: ["cpp"], cpp: [] };
console.log(graph.root.length + graph.js.length + graph.cpp.length);It prints 2: one root-to-JavaScript edge and one JavaScript-to-C++ edge.
What does the conservative model print?
const words = [3, 42];
console.log(words.includes(42) ? "pinned" : "movable");It prints pinned because the stack model includes 42.
Name the V8 garbage-collection project discussed in this lesson.
V8 calls the project Orinoco.
What is the name for SpiderMonkey's independently collectable heap partitions?
SpiderMonkey calls its separately collectable heap partitions zones.
A removed checkout button still has a listener that closes over a large order. What should you remove?
Remove the unused event listener so its closure no longer keeps the detached graph reachable.
Your app memory grows after navigation. What should you do before rewriting the component?
Measure first with a heap snapshot or allocation profile, then remove the root path you find.
Check your understanding
Use the common reachability rule first. Then name the engine feature or host edge that changes how the collector can perform that rule.
Question 1 of 8What does the cross-heap model 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 retain extra memory?
Choose an answer to see the explanation.
Question 4 of 8Which SpiderMonkey feature lets parts of the heap be collected independently?
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 8What is the safe conclusion from a matching conservative stack word?
Choose an answer to see the explanation.
Key takeaways
- JavaScript specifies observable behavior, while engines choose collection strategies.
- Orinoco, SpiderMonkey, Riptide, and Hades illustrate different documented engineering choices.
- DOM lifetime can cross JavaScript and C++ wrappers, so a complete trace crosses both heaps.
- Conservative native-root scanning chooses safety over certainty and can pin objects.
- For a real leak, inspect retaining paths and remove accidental roots.
Remember the one-liner.
Different engines collect differently, but every safe trace must keep the whole graph that roots can reach.
Coming next: Heap snapshots in depth, where you will read retaining paths and sizes the way memory tools show them.