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:
- Crossing — the call itself is not inlineable into V8's optimised code the way a plain JS property is.
- Conversion — every string, number, dictionary and sequence is translated between representations. Strings are the expensive one.
- Checks — type coercion per Web IDL rules, plus security checks on cross-realm objects.
- Side effects — some getters flush style/layout.
offsetWidthis 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
.idlfile 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 attribute | Effect |
|---|---|
[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.
documentis not a JavaScript concept — it is a Blink object with a JS wrapper. Node.js has V8 and nodocument; that is the cleanest demonstration of the split.
Under the Hood
Navigation targets (derive them; do not memorise):
| Concern | Where to look |
|---|---|
| IDL definitions | .idl files throughout core/ and modules/ |
| The IDL compiler | bindings/scripts/, documented in bindings/IDLCompiler.md |
| Extended attribute semantics | bindings/IDLExtendedAttributes.md |
| Generated output listing | bindings/generated_in_core.gni, generated_in_modules.gni |
| Actual generated C++ | out/Default/gen/third_party/blink/renderer/bindings/ |
| Wrapper base class | the script-wrappable type in platform/bindings/ |
| Wrapper tracing / GC integration | platform/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.
| Tier | Directory | What it is | Trade |
|---|---|---|---|
| Ignition | src/interpreter | bytecode interpreter | starts instantly, runs slowest |
| Sparkplug | src/baseline | non-optimising baseline compiler | near-instant compile, modest speedup |
| Maglev | src/maglev | mid-tier optimising compiler | fast compile, good code |
| TurboFan | src/compiler | top-tier optimising compiler | slow 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 state | Shapes seen | Speed |
|---|---|---|
| monomorphic | 1 | fastest — direct offset |
| polymorphic | a few (~2–4) | a small check chain |
| megamorphic | many | falls 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.xis 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 tonullin 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 whybi-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
postMessageorwindow.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:
| Attribute | Effect | Why you care |
|---|---|---|
[CEReactions] | wraps the operation in the custom-element reaction stack | ordering guarantees (bi-04) |
[Unforgeable] / [LegacyUnforgeable] | non-configurable, non-shadowable | security primitive |
[CrossOrigin] | reachable across origins | a deliberate hole in the wall |
[SameObject] | must return the identical object each time | identity, not equality |
[Replaceable] | assignment shadows instead of calling a setter | legacy compatibility |
[RuntimeEnabled=X] | exists only when the flag is on | (bi-01 Technique 4) |
[SecureContext] | only in secure contexts | HTTPS-gated APIs |
[Exposed=Window,Worker] | which global(s) expose it | why an API is missing in a worker |
[PutForwards] | assignment forwards to a member of the returned object | e.g. location = url |
[LegacyLenientThis] | tolerate wrong this instead of throwing | pure 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, aDOMString, a dictionary. The surprising coercions in DOM APIs are conformance, not sloppiness. - Strings cost. Every
DOMStringcrossing the boundary is a conversion between V8's representation andWTF::String, potentially with allocation. In a hot loop readingel.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:
- A tiny IDL-like schema format: interface name, attributes with types, methods with argument
types, plus a
[ForcesLayout]and a[CrossOrigin]marker. - A generator that emits JS accessor definitions from the schema.
- A "C++ side" (plain JS objects standing in) with a wrapper map enforcing identity.
- Type coercion per your schema, including throwing on invalid input.
- A
[ForcesLayout]attribute whose getter runs aflush()— reuse the dirty-marking scheme from mini-browser M3. - 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
- Break wrapper identity — create a new wrapper per access. Show two failures: expandos
vanishing, and
===returning false. Explain which is worse and why. - Remove the world separation — show an "extension" expando becoming visible to "page" code. Write two sentences on what an attacker does with that.
- Layout thrash through a getter — mark an attribute
[ForcesLayout], read it in a loop that also writes. Measure the complexity change. - Live vs static collections — implement both; write the infinite loop; then write the version that terminates and explain the difference precisely.
Debugging Exercise
- In DevTools, profile a loop that reads
offsetWidtheach iteration. Find the layout cost. Then find it again in a Perfetto trace with theblinkcategory, and note which view made it obvious faster. - In the local checkout: pick a Web API, find its
.idl, predict the generated C++ name, then confirm withgit grep. Do this for one attribute and one method. - Find an interface member with
[Unforgeable]. Explain the attack it prevents. - Find an interface member with
[RuntimeEnabled]that is not on by default, and verify with--enable-blink-featureson 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
-
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.
-
Explain wrapper identity to a strong JS engineer in four sentences, including why the naive design fails.
-
Isolated worlds give extensions a separate view of the same DOM. Enumerate what this protects and what it explicitly does not.
-
[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. -
Argue that generated bindings are essential architecture. Then identify the specific navigability cost they impose and how you would onboard an engineer despite it.
-
offsetWidthforces layout. Design an alternative API for the same information that cannot cause forced synchronous layout. What do callers lose, and would you ship it? -
Given only a
.idlfile, what can you determine about a Web API's performance characteristics? What can you not? -
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.
-
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. -
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.