bi-05 — The Binding Layer: Web IDL, V8, and the JS↔Blink Boundary

Phase 1 · Spec areas §9 (binding layer), §17 (V8 integration). Prerequisites: bi-01, bi-02, bi-04.

Cross-track hook: frontend-principal-engineering.md §1 (JS runtime). That module covers what JS guarantees; this one covers what happens when JS calls something that is not JS.


Why a Principal Engineer needs this

1. It is where "JavaScript performance" stops being about JavaScript. A tight loop over element.style.width is not slow because of V8. It is slow because every property access crosses a boundary that does type conversion, security checks, and possibly a style flush. Engineers who profile only in the JS flame chart cannot see this and conclude the wrong thing.

2. Every Web API's shape is decided here, and the shape has consequences. Whether something is a getter or a method, whether it returns a live collection or a static list, whether it throws or returns null — these are IDL decisions with real performance and correctness implications. document.querySelectorAll returning a static NodeList while getElementsByTagName returns a live HTMLCollection is an IDL fact that has caused an enormous number of production bugs.

3. It is the security boundary inside the renderer. Cross-origin access checks on window and location are enforced in the bindings. Understanding this is what lets you reason correctly about what an iframe can and cannot touch.

4. It is the largest generated-code surface in the tree, and therefore the place where bi-01 Technique 4 earns its keep. bindings/generated_in_modules.gni — the listing of generated files — is over 300 KB by itself. If you try to navigate this subsystem by grepping for hand-written C++, you will conclude that most of the Web Platform does not exist.


Mental Model

The path of one property access

document.title
V8 executes bytecode; sees a property load on a JS object
  ↓  the object is a "wrapper" with an interceptor / accessor installed
GENERATED binding code (from document.idl)
  ↓  unwrap: JS object → C++ blink::Document*
  ↓  security check (is this realm allowed to touch this object?)
Blink C++: Document::title()
  ↓  returns WTF::String
GENERATED binding code
  ↓  convert WTF::String → v8::String (may allocate, may copy)
back to JS

Four costs live in that diagram, and naming them separately is the whole point:

  1. Crossing — the call itself is not inlineable into V8's optimised code the way a plain JS property is.
  2. Conversion — every string, number, dictionary and sequence is translated between representations. Strings are the expensive one.
  3. Checks — type coercion per Web IDL rules, plus security checks on cross-realm objects.
  4. Side effects — some getters flush style/layout. offsetWidth is not a field read; it is a command to make layout current.

Cost 4 is the one that produces order-of-magnitude surprises, and it is invisible in any JS-level mental model.

Web IDL is the contract, and it is executable

Every web-exposed interface is declared in .idl. The IDL compiler turns it into C++ that V8 calls. This means:

  • The .idl file is the authoritative statement of a Web API's surface. Argument types, nullability, whether something throws, whether it is [Replaceable], [SameObject], [CEReactions] — all declared, not implemented ad hoc.
  • Extended attributes are where the semantics hide. Blink documents these in bindings/IDLExtendedAttributes.md (~69 KB — that size is itself informative). A few that change behaviour dramatically:
Extended attributeEffect
[CEReactions]wrap the operation in the custom-element reaction stack
[CrossOrigin]accessible across origins — a deliberate hole in the wall
[SameObject]must return the identical object every time (identity, not equality)
[Replaceable]assignment shadows rather than calls a setter
[RuntimeEnabled=Foo]exists only when the feature flag is on
[Unforgeable] / [LegacyUnforgeable]non-configurable — cannot be shadowed by page script

[Unforgeable] is a security primitive: it is why a page cannot redefine window.location to fool code that reads it. When you see it, you are looking at a decision someone made because an attacker would otherwise have a hole.

Wrappers, identity and expandos

Each C++ DOM object has at most one JS wrapper per world, and that wrapper must be stable:

document.body === document.body   // true — same wrapper object
el.myExpando = 1;                 // stored on the wrapper

If the wrapper were recreated on each access, expandos would vanish and identity comparisons would fail. So the binding layer maintains a wrapper map and keeps the pair alive together (bi-04's two-GC problem). Wrapper identity is the requirement that forces most of the complexity in this layer — it is the answer to "why not just create a fresh object each time."

Worlds and realms

  • A realm/context is roughly one global object — one window.
  • A world is Blink's separation between the page's JS and isolated worlds, notably extension content scripts. The same C++ DOM node has a different wrapper per world, so expandos set by an extension are invisible to the page. This is a security feature and it is why "just use a content script" is a real isolation boundary rather than a convention.

Cross-origin window/location access goes through remote-object proxies with per-property checks — only the [CrossOrigin] members get through. This is the mechanism behind every "blocked a frame with origin ... from accessing a cross-origin frame" message.

V8, precisely bounded

You need only enough V8 to reason about the boundary. Know these and stop:

  • Isolate — one instance of the engine, one heap. One per thread; a worker has its own.
  • Context — one global object within an isolate.
  • Hidden classes / shapes — V8's representation of object layout. Monomorphic call sites are fast; polymorphic ones degrade. This is why consistent object shapes matter.
  • Inline caches — per-call-site memoisation of "what shape did I see here." Bindings interact with these: a DOM accessor is not a plain property, so it optimises differently.
  • Deoptimisation — the optimising compiler bails back to bytecode when an assumption breaks.
  • The GC boundary — V8's heap vs Oilpan's heap (bi-04).

The distinction you must never blur: V8 executes JavaScript. Blink implements the Web Platform. document is not a JavaScript concept — it is a Blink object with a JS wrapper. Node.js has V8 and no document; that is the cleanest demonstration of the split.


Under the Hood

Navigation targets (derive them; do not memorise):

ConcernWhere to look
IDL definitions.idl files throughout core/ and modules/
The IDL compilerbindings/scripts/, documented in bindings/IDLCompiler.md
Extended attribute semanticsbindings/IDLExtendedAttributes.md
Generated output listingbindings/generated_in_core.gni, generated_in_modules.gni
Actual generated C++out/Default/gen/third_party/blink/renderer/bindings/
Wrapper base classthe script-wrappable type in platform/bindings/
Wrapper tracing / GC integrationplatform/bindings/ + platform/heap/

Do this once and it will pay for itself: open a generated binding for a simple interface and read it end to end. You will see the unwrap, the security check, the argument conversion, the exception handling, and the return conversion, all explicit. After that, every performance claim about "DOM access is slow" becomes concrete rather than folkloric.

Deriving the mapping rules

Given partial interface Document { attribute DOMString title; }, you should be able to predict the generated C++ names and find Document::title() / Document::setTitle(). The mangling is mechanical (IDL camelCase → C++ PascalCase, attributes → getter/setter pairs). Once you know the rule, you can jump from any Web API name to its implementation in one step — which is bi-01 Technique 4 applied at its highest leverage.


Deep dive: V8's execution tiers, bounded to what you need

Verified present in the checkout (v8/src/): interpreter/, baseline/, maglev/, compiler/. That is four tiers, and knowing their names and trade-offs is the right amount of V8 for a browser engineer.

TierDirectoryWhat it isTrade
Ignitionsrc/interpreterbytecode interpreterstarts instantly, runs slowest
Sparkplugsrc/baselinenon-optimising baseline compilernear-instant compile, modest speedup
Maglevsrc/maglevmid-tier optimising compilerfast compile, good code
TurboFansrc/compilertop-tier optimising compilerslow compile, best code

Code moves up tiers as it gets hot, and falls back down on deoptimisation when an assumption the optimiser relied on turns out false.

Why four tiers rather than two

This is a scheduling problem wearing a compiler costume, and the shape should be familiar by now. Compiling is work that competes with running. A single optimising compiler is either too eager (you pay compile time for code that runs twice) or too lazy (hot code stays slow). Tiering is the same "spend a little to decide whether to spend a lot" pattern as tile prioritisation (bi-10) and speculative parsing (bi-03).

What this means for you as a web engineer: almost nothing directly, and that is the point. You cannot control tier-up. What you can control is whether your code is optimisable at all — which is about object shapes.

Hidden classes and inline caches, precisely enough

V8 does not store JS objects as hash maps when it can avoid it. Objects with the same shape (same properties, added in the same order) share a hidden class (a "map"), so a property access compiles to an offset load rather than a lookup.

A property-access site records the shapes it has seen — that is the inline cache:

Site stateShapes seenSpeed
monomorphic1fastest — direct offset
polymorphica few (~2–4)a small check chain
megamorphicmanyfalls back to a generic lookup

Consequences that are actually actionable:

  • Initialise all fields in the constructor, in a consistent order. Adding a property later creates a new shape and transitions the object.
  • delete obj.x is expensive — it can force the object into dictionary mode, losing the shape entirely.
  • Passing objects of many different shapes to one hot function makes its access sites megamorphic.

But the honest framing matters more than the tips:

This almost never matters in application code. It matters in library and framework internals — a reconciler's hot loop, a reactivity system's dependency map. Optimising object shapes in product code is usually a misuse of your time. Knowing the mechanism is for the day you are reading fw-11's production source and wondering why React's Fiber objects initialise every field to null in the constructor. That is why.

Deoptimisation

TurboFan compiles on assumptions ("this is always a small integer," "this object always has this shape"). When an assumption breaks, the code deoptimises: execution transfers back to the interpreter mid-function. Deopt loops — optimise, deopt, re-optimise — are a real pathology, and the reason a function can be mysteriously slow only in production data.

You will not usually debug this. You should recognise the name when it appears in a trace.


Deep dive: the unified heap, and why this layer exists

bi-04 established the two-heap problem. Here is the machinery, current as of 2026-08-10.

Oilpan is built on cppgc, which lives in V8 (v8/src/heap/cppgc-internal/, cppgc-js/). That location is the whole design: because the C++ collector is part of V8, one collector can trace both heaps and correctly collect cycles that span them.

JS object  ──references──►  C++ DOM object  ──references──►  JS callback
     ▲                                                            │
     └────────────────────────────────────────────────────────────┘
                      a cycle across two heaps

Two independent collectors can never collect that cycle: each sees an external root. A unified collector can. This is not an optimisation — it is the difference between "we leak every DOM node with an event listener" and not.

The historical mechanism was wrapper tracing with TraceWrapperMember<T>; it is deprecated and the type is gone. Today:

  • Member<T> for managed pointers, regardless of JS reachability,
  • TraceWrapperV8Reference<T> for references into V8 that this object must keep alive.

Verification lesson. This is the second time in this track a widely-cited mechanism turned out to be retired (ng_ prefixes were the first). Both would have been repeated confidently from memory. Both took one minute to check in the tree. That ratio is why bi-01's navigation discipline is the first module.


Deep dive: worlds, and the isolation you can observe

A world is Blink's separation between the page's JavaScript and isolated worlds — notably extension content scripts. DOMWrapperWorld is the type.

The same C++ DOM node has a different wrapper per world:

// page script
document.body.pageOnly = 1;

// extension content script, same document, different world
document.body.pageOnly    // undefined

Both see the same <body>; neither sees the other's expandos. Prototype pollution in one world does not reach the other.

This is a real security boundary, not a convention, and it explains several things at once:

  • why a content script cannot be trivially detected by page script reading its expandos,
  • why extensions must use postMessage or window.wrappedJSObject-style bridges to interact,
  • why "just inject a script tag" behaves differently from a content script — injected script runs in the main world.

Realms, and cross-origin access

A realm/context is roughly one global object. Cross-origin window and location access goes through remote-object proxies that permit only the [CrossOrigin]-marked members. Everything else throws. That is the mechanism behind:

Blocked a frame with origin "https://a.example" from accessing a cross-origin frame.

The allow-list is small and deliberate: window.postMessage, window.location.href (write-only for cross-origin), window.closed, window.frames, window.top, and a handful more. Every entry is a decision someone defended.


Deep dive: extended attributes that change behaviour

bindings/IDLExtendedAttributes.md runs to roughly 69 KB. That size is informative: it is two decades of accumulated requirements. The ones worth recognising on sight:

AttributeEffectWhy you care
[CEReactions]wraps the operation in the custom-element reaction stackordering guarantees (bi-04)
[Unforgeable] / [LegacyUnforgeable]non-configurable, non-shadowablesecurity primitive
[CrossOrigin]reachable across originsa deliberate hole in the wall
[SameObject]must return the identical object each timeidentity, not equality
[Replaceable]assignment shadows instead of calling a setterlegacy compatibility
[RuntimeEnabled=X]exists only when the flag is on(bi-01 Technique 4)
[SecureContext]only in secure contextsHTTPS-gated APIs
[Exposed=Window,Worker]which global(s) expose itwhy an API is missing in a worker
[PutForwards]assignment forwards to a member of the returned objecte.g. location = url
[LegacyLenientThis]tolerate wrong this instead of throwingpure compatibility

[Exposed=...] is the one that saves application engineers the most time. "Why is document undefined in my worker?" is answered by an IDL annotation, not by a bug.

[Unforgeable] on window.location is worth internalising as a security pattern: a page cannot redefine it, so code that reads location to make a trust decision cannot be fooled by script that ran earlier. When you design an API whose value is used for a security decision, ask whether an attacker can shadow it.


Deep dive: what a generated binding actually does

Read one once and this whole layer stops being mysterious. Every generated accessor performs roughly the same sequence:

1. Unwrap:      v8::Object  →  blink::C++ object      (type check; throw TypeError if wrong)
2. Check:       is this realm/world allowed to touch this object?   (cross-origin)
3. Convert:     JS values → IDL types, per Web IDL conversion algorithms
                (this is where "3" becomes 3, where undefined becomes "undefined", etc.)
4. Call:        the Blink implementation method
5. Convert:     the result back to a V8 value (may allocate — strings especially)
6. Exceptions:  translate Blink exceptions into JS exceptions on the right realm

Two things become obvious once you have seen this written out:

  • Type coercion is specified, not incidental. Web IDL defines exactly how a JS value becomes a long, a DOMString, a dictionary. The surprising coercions in DOM APIs are conformance, not sloppiness.
  • Strings cost. Every DOMString crossing the boundary is a conversion between V8's representation and WTF::String, potentially with allocation. In a hot loop reading el.className, that conversion is the cost.

The size of the generated surface

bindings/generated_in_core.gni is ~197 KB and generated_in_modules.gni ~338 KB — and those are merely the lists of generated files, not the files themselves. Half a megabyte of filenames.

Sit with that number for a moment. It is the most direct evidence available that bi-01 Technique 4 is not a curiosity: the majority of the Web Platform's C++ surface is not written by hand. Any navigation strategy that assumes otherwise fails on most of the platform.


Anti-Patterns

Profiling only the JS flame chart. Binding cost, style flushes triggered by getters, and GC across two heaps do not appear as "your function."

Assuming DOM property access is a field read. Some are. offsetWidth, getComputedStyle reads, and getBoundingClientRect are commands that may force layout.

Caching a live collection and treating it as a snapshot. getElementsByTagName returns a live view; mutating the DOM while iterating it is a classic infinite loop.

Assuming a feature exists because you found its C++. [RuntimeEnabled] means it may not be exposed. Check the flag before concluding anything about shipping behaviour.

Reasoning about extension content scripts as "same page." Different world, different wrappers, deliberately.


Trade-offs

Generated bindings vs hand-written. Generation guarantees uniform security checks, type coercion and exception behaviour across thousands of interfaces — no one forgets a check. The cost is navigability (bi-01 Technique 4) and build time. Given the security stakes, this is one of the clearest "complexity that is obviously worth it" cases in the tree, and worth contrasting with the two-GC split (bi-04), which is much less obviously worth it.

Wrapper-per-world vs shared wrappers. Per-world isolation is a real security boundary and costs memory plus a more complex wrapper map. Shared wrappers would be simpler and would let a page tamper with extension state.

IDL expressiveness vs implementability. Every extended attribute is a feature someone needed and a permanent cost in the compiler. 69 KB of documented attributes is what that accretion looks like after two decades — a good §46 subject.


Lab

Mini-binding layer. Build a toy that demonstrates the boundary rather than reimplementing V8:

  1. A tiny IDL-like schema format: interface name, attributes with types, methods with argument types, plus a [ForcesLayout] and a [CrossOrigin] marker.
  2. A generator that emits JS accessor definitions from the schema.
  3. A "C++ side" (plain JS objects standing in) with a wrapper map enforcing identity.
  4. Type coercion per your schema, including throwing on invalid input.
  5. A [ForcesLayout] attribute whose getter runs a flush() — reuse the dirty-marking scheme from mini-browser M3.
  6. Two worlds: two wrapper maps over the same backing objects. Demonstrate that expandos do not leak between them.

Then measure: property access through your wrapper vs a plain JS property, over 1e6 iterations. Explain the gap in terms of the four costs above.


Failure Lab

  1. Break wrapper identity — create a new wrapper per access. Show two failures: expandos vanishing, and === returning false. Explain which is worse and why.
  2. Remove the world separation — show an "extension" expando becoming visible to "page" code. Write two sentences on what an attacker does with that.
  3. Layout thrash through a getter — mark an attribute [ForcesLayout], read it in a loop that also writes. Measure the complexity change.
  4. Live vs static collections — implement both; write the infinite loop; then write the version that terminates and explain the difference precisely.

Debugging Exercise

  1. In DevTools, profile a loop that reads offsetWidth each iteration. Find the layout cost. Then find it again in a Perfetto trace with the blink category, and note which view made it obvious faster.
  2. In the local checkout: pick a Web API, find its .idl, predict the generated C++ name, then confirm with git grep. Do this for one attribute and one method.
  3. Find an interface member with [Unforgeable]. Explain the attack it prevents.
  4. Find an interface member with [RuntimeEnabled] that is not on by default, and verify with --enable-blink-features on stock Chrome that the behaviour appears.

Testing & QA Considerations

  • WPT has extensive IDL-conformance tests (idlharness). Find them. What do they check that a behavioural test does not?
  • Find a test asserting cross-origin access is blocked on window. What exact error is specified?
  • If you changed an IDL file, what would need rebuilding? Answer with gn refs.

Further Reading (primary sources first)

  • Web IDL specification (WHATWG) — types, extended attributes, conversion algorithms.
  • HTML Standard — WindowProxy, cross-origin property access, [Unforgeable] members.
  • third_party/blink/renderer/bindings/README.md, IDLCompiler.md, IDLExtendedAttributes.md.
  • third_party/blink/renderer/platform/bindings/ — the wrapper machinery.
  • V8 docs/blog on hidden classes, inline caches, and the interpreter/optimiser pipeline. Read only enough to reason about the boundary; V8's internals are a separate discipline.

Principal Engineer Review

  1. A colleague says "we removed jQuery so DOM access is fast now." What is right and wrong about this? Give a specific case where the change made no difference at all.

  2. Explain wrapper identity to a strong JS engineer in four sentences, including why the naive design fails.

  3. Isolated worlds give extensions a separate view of the same DOM. Enumerate what this protects and what it explicitly does not.

  4. [Unforgeable] makes a property non-configurable. Give the concrete attack it stops, and explain the cost of applying it liberally to every security-relevant property.

  5. Argue that generated bindings are essential architecture. Then identify the specific navigability cost they impose and how you would onboard an engineer despite it.

  6. offsetWidth forces layout. Design an alternative API for the same information that cannot cause forced synchronous layout. What do callers lose, and would you ship it?

  7. Given only a .idl file, what can you determine about a Web API's performance characteristics? What can you not?

  8. A team proposes exposing a new capability to the web. Walk through the layers touched, from IDL to browser process, and name the review gates it must pass.

  9. Live collections (HTMLCollection) were a design decision. Make the case they should never have existed, then the case they are the right default for the APIs that have them.

  10. You must explain to a backend engineer why "it's just a property read" took 400 ms in production. Do it in five sentences, and say what you would add to make it visible next time.