bi-04 — DOM Internals & Object Lifetime
Phase 1 · Spec areas §8 (DOM internals), part of §4 (Blink GC types). Prerequisites: bi-01, bi-02, bi-03.
Cross-track hook: frontend-principal-engineering.md §3 (HTML platform), §5 (React
internals — the cost model every VDOM diff is optimising against lives here).
Why a Principal Engineer needs this
1. Every framework you evaluate is a bet about DOM cost. React's virtual DOM, Vue's dependency tracking, and signals' fine-grained updates are three different answers to "DOM mutation is expensive, so avoid it." You cannot judge those answers without knowing which operations are expensive and why. Most engineers carry a folk model here ("the DOM is slow") that is wrong in detail and therefore produces wrong optimisations.
2. Memory leaks in long-lived SPAs are DOM lifetime bugs. Detached subtrees kept alive by a JS reference, listeners never removed, observers never disconnected. Diagnosing these requires knowing how DOM objects are owned and when they die — which spans two garbage collectors that must agree.
3. Invalidation starts here. Every style, layout and paint recalculation is triggered by a DOM mutation. bi-07–bi-09 are downstream of this module; "why did this cause a relayout" is a question about what the mutation marked dirty.
Mental Model
The tree, and the several trees
Document
└── Element (html)
├── Element (head)
└── Element (body)
├── Text
└── Element (div) ── shadowRoot ──► ShadowRoot
└── (its own tree)
The DOM is not one tree. It is a node tree, plus shadow trees attached to hosts, and the
composed/flat tree that results from slotting. Style and layout operate on the flat
tree, not the node tree. Almost every confusing shadow-DOM behaviour — inheritance across
boundaries, ::slotted, event retargeting — is a consequence of those being different
trees.
If you take one thing from this module: "the DOM tree" is ambiguous, and the ambiguity is where the bugs are.
Attributes are not properties
element.setAttribute("class", "foo") and element.className = "foo" reach the same place,
but the model is: attributes are the serialised, spec-defined store; IDL attributes
("properties") are a reflected view with type coercion. Some reflect, some don't
(input.value is the classic divergence — attribute is the default, property is the
current value). Frameworks that set properties and frameworks that set attributes behave
differently on exactly these, which is a recurring source of "works in React, breaks in
Vue"-style reports.
Two garbage collectors
This is the part that surprises people, and it is the key to lifetime reasoning.
- V8's GC manages JavaScript objects, including the JS wrapper for a DOM node.
- Oilpan (Blink's GC, in
platform/heap/) manages the C++ DOM objects themselves.
A <div> therefore has two objects: a C++ HTMLDivElement on Oilpan's heap, and a V8
wrapper on V8's heap. Neither collector can independently decide the pair is dead — if JS
holds the wrapper, the C++ object must live; if the C++ object is reachable from the
document, the wrapper must live so that expando properties survive a round trip:
const d = document.createElement('div');
d.myExpando = 42;
document.body.appendChild(d);
// ... later, with no JS reference retained
document.body.firstChild.myExpando // must still be 42
That requirement — wrapper identity and expando survival — is why the two heaps must be traced together rather than separately. It is a genuinely hard problem, and it is the reason the bindings layer (bi-05) is as complicated as it is.
Terminology, verified 2026-08-10. The mechanism that used to do this was called wrapper tracing, annotated with
TraceWrapperMember<T>. That is deprecated and the type no longer exists. Blink now uses V8's unified heap: Oilpan is built oncppgc, which lives in V8, so one collector traces C++ and JavaScript objects together. In current code you use plainMember<T>for managed pointers regardless of whether a JS object is transitively reachable, andTraceWrapperV8Reference<T>for references into V8 that this object must keep alive. A blog post aboutTraceWrapperMemberis describing a browser that no longer exists.
Oilpan's handle vocabulary
You cannot read core/dom/ without these. From platform/heap/BlinkGCAPIReference.md:
| Type | Meaning |
|---|---|
GarbageCollected<T> | base class: this type lives on Oilpan's heap |
Member<T> | strong reference from one GC object to another |
WeakMember<T> | weak; cleared automatically when the target dies |
UntracedMember<T> | not traced — you are asserting lifetime some other way |
Persistent<T> | strong root from non-GC code into the heap |
WeakPersistent<T> | weak root |
CrossThreadPersistent<T> | root usable from another thread |
STACK_ALLOCATED() | this class may only exist on the stack |
DISALLOW_NEW() | may only exist inline in another object |
Trace(Visitor*) | how an object declares its outgoing edges |
Reading rules that pay off immediately:
Member<T>in a class means "part of the object graph." TheTracemethod is the authoritative list of what an object keeps alive — read it first when reasoning about leaks. It is the ownership documentation, and unlike a comment it cannot silently rot.Persistent<T>held by something long-lived is the classic leak shape. A root that is never dropped keeps an arbitrary subgraph alive.STACK_ALLOCATED()is a strong hint about intended lifetime, enforced by a clang plugin. If you find yourself wanting to store one, your design is fighting the framework.
Blink also constrains ordinary types: String/AtomicString and WTF containers rather
than STL, KURL not GURL, SecurityOrigin not url::Origin. This is enforced by
DEPS, by audit_non_blink_usage.py, and by a clang plugin. When a codebase enforces a
convention with three separate mechanisms, that is a signal about how often people got it
wrong — and about how seriously reviewers will take it in your first CL.
Mutation → invalidation
The step that connects this module to the rest of the track:
DOM mutation
→ node marked dirty (style invalidation: "which elements might need recalculation?")
→ ancestors/descendants marked as needing work, per invalidation sets
→ at the next rendering opportunity: style recalc → layout → paint
The essential insight is that mutation does not recompute anything. It records that something must be recomputed later. This is why a thousand DOM writes in a loop are not a thousand layouts — and why one interleaved read of a geometry property forces a synchronous flush and turns the loop quadratic. That is the forced-synchronous-layout bug, and it is Vertical Trace #2.
Under the Hood
Re-derive rather than memorise (bi-01 Techniques 1–4). Places to be able to find:
| Concern | What to look for |
|---|---|
| Node/Element/Document base classes | core/dom/ |
| Child list manipulation and its checks | the container-node insertion/removal paths |
| Attribute storage | element attribute collections; note the space optimisation for few attributes |
| Shadow trees, slotting, flat-tree traversal | shadow root + flat tree traversal helpers |
| Mutation observers | the mutation observer registration/queueing machinery |
| Custom element reactions | the reaction stack/queue |
| Lifetime | Trace() methods, platform/heap/ |
Two questions worth answering by reading, because they teach the design:
- Why does
appendChilddo so much work before mutating? (Hierarchy checks, adoption into the right document, removal from the old parent, ordering guarantees around observers and custom element reactions.) The naive "set a pointer" model is wrong in about six ways, each of which is a spec requirement. - Why are custom element reactions queued rather than run immediately? Because running author code in the middle of a DOM mutation would let it observe — and mutate — a tree in an inconsistent intermediate state. This is the same reason bi-03's construction site queues tasks. Notice that you have now met the same design pressure twice. That pattern-recognition is the actual skill.
Deep dive: Oilpan, as actually configured
From platform/heap/BlinkGCAPIReference.md, with the general design living in V8's cppgc README —
that inheritance is the whole story: Blink's GC is V8's C++ GC with Blink-specific extensions,
which is exactly what makes one unified collector over both heaps possible.
Threading
"Oilpan assumes heaps are not shared among threads."
Threads that allocate Oilpan objects must be attached (ThreadState::AttachMainThread() /
AttachCurrentThread()). Blink creates heaps and root sets per thread, and an object belongs
to the thread it was allocated on.
This is the enforcement behind renderer/README.md's rule that cross-thread communication is
message passing, not shared memory. It is not a style preference — the heap is structured so that
sharing objects across threads is the exceptional path, requiring explicit CrossThreadPersistent
/ CrossThreadHandle types that announce themselves in the code.
Heap partitioning — and what the partitions disclose
Blink assigns certain types to custom spaces:
- collection backings to compactable custom spaces (so vectors and hash tables can be moved and the heap defragmented),
Node,CSSValue, andLayoutObjectto typed custom spaces.
Read that second line as a performance disclosure. Those three types get dedicated spaces because
they are the highest-volume allocations in the renderer. A page is, in allocation terms, mostly
nodes, CSS values, and layout objects — a useful corrective to any mental model where "the DOM" is
the only thing that costs memory. Your stylesheet allocates too, and CSSValue earning its own
heap space is the evidence.
Mode of operation
- Concurrent marking and sweeping (except during thread termination and heap destruction).
- GCs are scheduled through the message loop, at points where no objects are referenced from the native stack — so the collector can be precise rather than conservative.
- Under memory pressure, Blink triggers a conservative GC on allocation.
The precise/conservative distinction is worth holding. A precise GC knows exactly which words are
pointers; a conservative one must treat anything that looks like a pointer as one, and therefore
retains garbage. Blink prefers precise collection and schedules for it — the message loop is not
only a scheduler, it is a GC safepoint mechanism. That is a genuinely non-obvious coupling
between bi-11 and this module, and it is the kind of cross-subsystem constraint that never
appears in documentation about either one alone.
ActiveScriptWrappable: reachability is not the whole story
/**
* Classes deriving from ActiveScriptWrappable will be kept alive as long as
* they have a pending activity. Destroying the corresponding ExecutionContext
* implicitly releases them to avoid leaks.
*/
This solves a problem pure reachability cannot:
new XMLHttpRequest().addEventListener('load', () => console.log('done'));
// nothing holds a reference to the XHR
Reachability says garbage. Correctness says it must survive until the request completes and fires
its event. ActiveScriptWrappable is the mechanism — "I have pending activity, keep me alive."
The same applies to a playing <audio>, an open WebSocket, a pending animation.
Two transferable lessons:
- "Unreachable" and "collectable" are not synonyms in a system with external observable
effects. Any framework with subscriptions has this problem in miniature — which is why
fw-02's effect-cleanup lab matters. - The
ExecutionContextteardown clause is the leak guard: when the document goes away, pending activity is released regardless. Without it a navigated-away page could pin memory forever. Every "keep alive" mechanism needs a matching "and here is when we stop."
Deep dive: wrappers are lazy, and what that costs
A DOM node has no JavaScript object until JavaScript observes it. Wrappers are created on demand.
This is the detail most people miss, and it changes the cost model:
- A 50,000-node document that script never touches costs far less on the V8 side than one where a framework has walked the whole tree.
- A gratuitous
document.querySelectorAll('*')does not merely cost the query — it materialises wrappers for everything it returns, permanently (until GC), on both heaps. - Frameworks that hold references to every DOM node they created keep every wrapper alive.
Rules of thumb (anchors, not measurements — verify on your own pages):
| Quantity | Rough scale |
|---|---|
| Per-element C++ cost | hundreds of bytes to low kB, depending on what is attached |
| Per-element JS wrapper | zero until touched, then a real object on V8's heap |
| Style recalc | proportional to invalidated elements, not total (bi-07) |
| Layout | proportional to dirty boxes plus intrinsic-sizing pre-passes (bi-08) |
Deep dive: the mutation-observation family, ordered
Several mechanisms observe DOM change and they do not run at the same time. Getting the order right explains a large class of "why did my callback see stale state" bugs.
| Mechanism | When it runs |
|---|---|
| Custom element reactions | reaction queue, drained at defined checkpoints during the mutating algorithm |
MutationObserver | microtask checkpoint after the current task |
ResizeObserver | inside update the rendering, after layout — and may loop |
IntersectionObserver | inside update-the-rendering, delivered asynchronously |
| mutation events (legacy) | synchronously, mid-mutation — which is why they were deprecated |
The historical arc is the lesson. Mutation events fired synchronously in the middle of DOM
algorithms, so author code could observe and mutate a tree in an inconsistent intermediate state.
They were catastrophic for correctness and performance alike, and MutationObserver replaced them
with batched, microtask-delivered records.
You have now met the same design pressure three times: the parser's construction site queues tasks (
bi-03), custom element reactions are queued, and mutation observation is batched. Each time the requirement is identical — author code must never observe a half-built tree. When you design any API that notifies on change, this is the first question to ask.
ResizeObserver is the instructive outlier: it deliberately runs inside the rendering steps and
is permitted to loop, with a depth limit and an error when exceeded, because resize-driven layout
changes genuinely need to reach a fixed point before paint. Sometimes the correct answer is a
bounded loop, not a ban — and the bound is what makes it shippable.
Deep dive: the trees, precisely
"The DOM tree" is ambiguous. Name which one you mean:
| Tree | What it is | Who consumes it |
|---|---|---|
| Node tree | as authored; parentNode / childNodes | DOM APIs |
| Shadow tree | a ShadowRoot and its descendants | encapsulation |
| Flat tree | node tree with shadow content slotted in | style and layout |
| Layout tree | boxes; no display:none; anonymous boxes added | layout (bi-08) |
| Fragment tree | immutable geometry results | paint (bi-09) |
| Accessibility tree | semantic representation | assistive tech, and testing (fw-12) |
Behaviours that make no sense without the flat tree:
- Inheritance crosses shadow boundaries — inherited properties flow host to shadow content, because inheritance follows the flat tree.
::slotted()and::part()exist because ordinary selectors cannot cross the boundary, even though the flat tree has already merged the content.- Event retargeting — a listener outside a shadow root sees the host as
target, not the internal element.composedPath()reveals the real path.
And the fact that surprises people most: slot assignment does not move nodes. parentNode is
unchanged; only the flat tree differs. Any code reasoning about layout from parentNode is
reasoning about the wrong tree.
Deep dive: attributes, properties, and the reflection table
| Case | Attribute | Property | Relationship |
|---|---|---|---|
id, class | id, class | id, className | reflected both ways |
<input value> | the default value | the current value | diverges after user input |
<input checked> | default checkedness | current checkedness | same divergence |
href on <a> | as authored | resolved absolute URL | property is not the attribute |
data-* | attribute | dataset | live view |
style | serialised text | CSSStyleDeclaration | an object, not a string |
The href row is the classic gotcha: el.getAttribute('href') and el.href differ for a relative
URL, because the property resolves against the document base.
Why frameworks disagree here. A framework that sets properties and one that sets attributes
behave differently on exactly the diverging rows. "It works in React but not in my web component"
is very often this: React setting a property where the component expected an attribute change
and an attributeChangedCallback that consequently never fired.
Anti-Patterns
"The DOM is slow." DOM writes are cheap; they mark dirty. What is expensive is forcing a synchronous recomputation, and creating enormous trees. A model that says "DOM = slow" cannot predict which of two loops is 100× worse.
Reasoning about shadow DOM using the node tree. Style and layout use the flat tree.
Assuming property ↔ attribute equivalence. Especially for form controls.
Treating MutationObserver as free. It queues microtasks; a chatty observer on a hot
subtree can dominate a frame.
Using detached DOM nodes as a cache. A detached subtree held by a JS variable keeps the whole subtree — and everything its listeners close over — alive across both heaps.
Trade-offs
Two GCs vs one. Unifying them would simplify lifetime enormously. It is not done because V8 is a separate project with its own release cadence, embedders other than Blink, and its own performance constraints. The cost of the split is the entire wrapper-tracing mechanism — a large, permanent complexity budget spent on an organisational boundary as much as a technical one. Notice that: some production complexity exists because of who owns what, not because of what the machine needs. That is a §46 entry worth writing carefully.
Queued reactions vs immediate callbacks. Queuing preserves tree consistency but makes ordering subtle and hard to reason about. Immediate callbacks would be simpler to explain and impossible to make safe.
Attribute storage compactness vs access speed. Elements overwhelmingly have few attributes, so Blink optimises for the small case. Find the actual data structure and decide whether you would have made the same call.
Lab
mini-browser M3 — a real DOM.
Node,Element,Text,Document; parent/child/sibling links.appendChild,insertBefore,removeChild,removewith correct ordering and hierarchy checks (including: appending a node that already has a parent removes it first; appending an ancestor to its own descendant must throw).- Attributes with a separate reflected-property layer for at least
classandid. - A dirty-marking scheme: mutations mark nodes, and nothing is recomputed until a
flush(). MutationObserver-alike with microtask-queued delivery.- A deliberate second implementation of dirty-marking: naive (recompute everything) vs scoped. Measure both on a 10,000-node tree.
Deliverable: a table showing operations/second for both schemes, and a written explanation of where the crossover is and why.
Failure Lab
- Forced synchronous layout. Write a loop that writes then reads geometry each iteration. Measure. Then batch reads and writes. Explain the complexity change, not just the wall clock.
- The detached-subtree leak. Build one deliberately. Find it in DevTools' heap snapshot. Then find the retaining path — the skill is reading the retainer chain, not noticing the growth.
- Observer storm. Attach a
MutationObserverthat mutates the DOM in response to mutations. Predict the outcome before running: infinite loop, or something subtler? Explain in terms of the microtask checkpoint. - Property/attribute divergence. Build a form where setting the attribute after user input does nothing visible. Explain to a hypothetical junior engineer in three sentences.
Debugging Exercise
- Breakpoint on the container-node insertion path. From
document.body.appendChild(el)in the console, capture the full stack: how many frames between the binding and the actual pointer update? - Find the
Trace()method of anElement. List what it keeps alive. Predict what happens to a listener closure when the element is removed but a JS reference is retained — then verify with a heap snapshot. - Use tracing to observe that a batch of DOM mutations produces exactly one style recalc. Then insert a geometry read into the loop and observe the change.
Testing & QA Considerations
- Find the WPT for
appendChildhierarchy-check errors. What exception types are specified, and does Blink match on every branch? - Find a
core/dom/unit test that asserts an ordering property (observers vs custom element reactions). What would break if the order changed? - Write a test that would catch a regression in the property/attribute reflection of one form control.
Further Reading (primary sources first)
- DOM Standard (WHATWG) — node tree, mutation algorithms,
MutationObserver. - HTML Standard — reflection of IDL attributes to content attributes; form control state.
- DOM Parts / shadow DOM sections for the flat tree and slotting.
third_party/blink/renderer/platform/heap/BlinkGCAPIReference.md— the Oilpan reference. Read "Handles" and "Tracing" properly; skim the rest.third_party/blink/renderer/README.md§"Type dependencies".third_party/blink/renderer/core/dom/— and itsTrace()methods specifically.
Principal Engineer Review
-
"The DOM is slow" — replace this with a model precise enough to predict which of two implementations is faster. Give a case where the folk model predicts the wrong answer.
-
Explain wrapper tracing to a senior engineer who knows JS GC but not Blink. Why can't the two collectors run independently? What breaks first if they do?
-
A long-lived SPA grows 40 MB per hour. Enumerate causes in order of likelihood, and give the fastest discriminating observation for each.
-
Style and layout use the flat tree, not the node tree. Give two developer-visible behaviours that make no sense without this fact.
-
Custom element reactions and mutation observers are both queued rather than immediate. Reconstruct the requirement that forces this. What would a synchronous design break — specifically?
-
Argue for merging V8's heap and Oilpan. What would improve, what would become impossible, and what non-technical constraint is doing most of the work in the actual decision?
-
A framework claims it "batches DOM updates for performance." Given this module, what is it actually saving — and in what situation does it save nothing at all?
-
Design a DOM API that makes forced synchronous layout impossible to express. What do you lose? Would you ship it as a replacement, or alongside?
-
Blink enforces its type conventions with
DEPS, a presubmit script, and a clang plugin. What does three-mechanism enforcement tell you, and when is that level of enforcement worth it on a team you lead? -
You must explain to a product manager why a "simple" DOM change caused a 30 % regression in a rendering benchmark. Do it in five sentences, without using the words "reflow" or "repaint."