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 on cppgc, which lives in V8, so one collector traces C++ and JavaScript objects together. In current code you use plain Member<T> for managed pointers regardless of whether a JS object is transitively reachable, and TraceWrapperV8Reference<T> for references into V8 that this object must keep alive. A blog post about TraceWrapperMember is describing a browser that no longer exists.

Oilpan's handle vocabulary

You cannot read core/dom/ without these. From platform/heap/BlinkGCAPIReference.md:

TypeMeaning
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." The Trace method 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:

ConcernWhat to look for
Node/Element/Document base classescore/dom/
Child list manipulation and its checksthe container-node insertion/removal paths
Attribute storageelement attribute collections; note the space optimisation for few attributes
Shadow trees, slotting, flat-tree traversalshadow root + flat tree traversal helpers
Mutation observersthe mutation observer registration/queueing machinery
Custom element reactionsthe reaction stack/queue
LifetimeTrace() methods, platform/heap/

Two questions worth answering by reading, because they teach the design:

  1. Why does appendChild do 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.
  2. 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, and LayoutObject to 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:

  1. "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.
  2. The ExecutionContext teardown 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):

QuantityRough scale
Per-element C++ costhundreds of bytes to low kB, depending on what is attached
Per-element JS wrapperzero until touched, then a real object on V8's heap
Style recalcproportional to invalidated elements, not total (bi-07)
Layoutproportional 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.

MechanismWhen it runs
Custom element reactionsreaction queue, drained at defined checkpoints during the mutating algorithm
MutationObservermicrotask checkpoint after the current task
ResizeObserverinside update the rendering, after layout — and may loop
IntersectionObserverinside 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:

TreeWhat it isWho consumes it
Node treeas authored; parentNode / childNodesDOM APIs
Shadow treea ShadowRoot and its descendantsencapsulation
Flat treenode tree with shadow content slotted instyle and layout
Layout treeboxes; no display:none; anonymous boxes addedlayout (bi-08)
Fragment treeimmutable geometry resultspaint (bi-09)
Accessibility treesemantic representationassistive 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

CaseAttributePropertyRelationship
id, classid, classid, classNamereflected both ways
<input value>the default valuethe current valuediverges after user input
<input checked>default checkednesscurrent checkednesssame divergence
href on <a>as authoredresolved absolute URLproperty is not the attribute
data-*attributedatasetlive view
styleserialised textCSSStyleDeclarationan 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.

  1. Node, Element, Text, Document; parent/child/sibling links.
  2. appendChild, insertBefore, removeChild, remove with 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).
  3. Attributes with a separate reflected-property layer for at least class and id.
  4. A dirty-marking scheme: mutations mark nodes, and nothing is recomputed until a flush().
  5. MutationObserver-alike with microtask-queued delivery.
  6. 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

  1. 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.
  2. 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.
  3. Observer storm. Attach a MutationObserver that mutates the DOM in response to mutations. Predict the outcome before running: infinite loop, or something subtler? Explain in terms of the microtask checkpoint.
  4. 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

  1. 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?
  2. Find the Trace() method of an Element. 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.
  3. 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 appendChild hierarchy-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 its Trace() methods specifically.

Principal Engineer Review

  1. "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.

  2. 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?

  3. A long-lived SPA grows 40 MB per hour. Enumerate causes in order of likelihood, and give the fastest discriminating observation for each.

  4. Style and layout use the flat tree, not the node tree. Give two developer-visible behaviours that make no sense without this fact.

  5. 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?

  6. 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?

  7. 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?

  8. 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?

  9. 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?

  10. 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."