Frontend Principal Journey

This repository is also a book. Build it with bash build.sh (or mdbook serve for live reload) and read it at http://localhost:3000. Start with the Introduction, the Phases & Modules index, and Toolchain Setup.

Modules fe-01fe-06 are built and measured: 22 experiments drive a real browser over CDP, and every table in the book is regenerated by npm run all inside that module's src/. See PHASES.md for status.

A structured learning repository for developing from frontend foundations through Principal/Distinguished-level frontend and web platform engineering.

Two tracks run in parallel, each driven by a specification file and organised as numbered module directories.

frontend-principal-journey/
├── frontend-principal-engineering.md    # spec: broad frontend track
├── browser-framework-internals.md       # spec: systems-construction track
│
├── fe-00-roadmap/ … fe-NN-…/            # FRONTEND track modules
├── fe-00-roadmap/docs/progress.md       #   ← its tracker
│
├── bi-00-roadmap/ … bi-16-capstone/     # BROWSER INTERNALS track modules
├── fw-01-… … fw-12-…/                   #   framework-internals strand
├── mini-browser/                        #   the spine, threaded through bi-03…bi-12
└── PROGRESS.md                          #   ← browser/framework tracker

Each module directory contains CONCEPTS.md (the explanation), docs/ (execution steps, observations, verification checkpoints), and src/ (runnable code).

A module is complete when its docs/verification.md checkpoints pass against measured or observed output, not when its prose has been read.


Track 1 — Frontend Principal Engineering

Spec: frontend-principal-engineering.md · Map: fe-00-roadmap/docs/curriculum-map.md · Tracker: fe-00-roadmap/docs/progress.md

Web foundations, JS/TS, HTML/CSS, React and framework architecture, state, APIs, networking, performance, accessibility, security, testing, design systems, platform engineering, build tooling, monorepos, microfrontends, rendering architectures, observability, reliability, CI/CD, i18n, mobile web, workers, WASM, migrations, ADRs, technical decision-making.

Track 2 — Browser & Framework Internals

Spec: browser-framework-internals.md · Map: bi-00-roadmap/CONCEPTS.md · Tracker: PROGRESS.md · Build guide: bi-00-roadmap/docs/chromium-build-debug-trace.md

Browser strand — navigating Chromium · architecture and process model · HTML parsing · DOM internals · bindings and V8 · Chromium C++ · CSS engine · layout · paint · compositor and GPU · scheduling · debugging and tracing · tests and WPT · contribution · vertical traces · capstone.

Framework strand — mini-redux · mini-react (core, then scheduling) · reactivity and signals · Vue-like renderer · compilers · bundler · router · query cache · virtualized list · production source apprenticeship · testing library and DevTools.

The two strands run in parallel and join at the vertical traces.


The learning loop

Use it -> predict (written, BEFORE observing) -> build a small version
-> intentionally break it -> debug it -> inspect production source
-> compare architectures -> explain why the complexity exists
-> modify production source where practical

Two rules make this work:

  • Predictions are written before observation. A clearly-stated wrong prediction is worth more than a vague right one.
  • Source paths are never handed to you. State a hypothesis, search, confirm or falsify, and record the query that worked. Queries transfer between subsystems; paths rot — see the verification log in PROGRESS.md §7 for dated evidence of exactly that.

How the tracks connect

Frontend Principal Engineering        Browser & Framework Internals
   architectural + org judgment          removes abstraction boundaries
              └──────────┬──────────────────────┘
                         ▼
                Principal Frontend SME

Cross-track sequencing is recorded in bi-00-roadmap/CONCEPTS.md §6 and fe-00-roadmap/docs/curriculum-map.md §2. It runs in both directions: the frontend track's execution-model module precedes Blink scheduling, while the internals track's mini-React precedes any reading about Fiber.

Shared across both tracks: learning-log.md (source readings, struggles, open questions) and decisions/ (ADRs).


Using this with coding agents

Browser/framework work: work from browser-framework-internals.md; continue the next bi- or fw- module; verify all Chromium paths, commands and class names against current upstream before relying on them, and log the result in PROGRESS.md §7.

Framework implementation: teach the conceptual model, implement a minimal version, then compare against production source. Never start from production source.

Application/platform work: work from frontend-principal-engineering.md; use the internals track only when underlying behaviour materially affects the topic.

Frontend Principal Engineering — Measured

"When advice is repeated confidently and cheaply, measure it before you design around it." — heuristic earned in Phase 1 of this curriculum

A lab-based curriculum for becoming the frontend subject-matter expert for a large engineering organisation. Every claim in it is measured in a real browser, and every measurement is reproducible with one command.

What makes this different

Most frontend curricula are prose. This one is a measurement harness with prose attached.

Twenty-two experiments drive Chrome for Testing 149 over the Chrome DevTools Protocol — forcing garbage collection, parsing heap snapshots, dumping the real accessibility tree, sampling the renderer's own style and layout accounting, and dispatching trusted keyboard input. Every table in every chapter came out of a script in that module's src/, and npm run all regenerates it.

That constraint produced results that contradict widely-repeated advice:

Common beliefMeasured
Chunking work with await keeps the page responsive0 frames rendered over 400 ms; 49 for task-based chunking
Moving work to a Web Worker makes it fasterThe naive worker was the worst of seven strategies — worse than doing nothing
A closure only retains what it referencesAn unused, never-called sibling retained 38 MB vs 0.01 MB
performance.memory will show you a leakFreeing 508 KB of detached DOM moved the JS heap 0.10 MB
Semantic HTML is verboseDiv soup was 1.38× larger — with zero landmarks, headings or controls
Property order is the hidden-class hazard1.14×. delete is 11.9×
Flex and grid are slow; absolute is fast1.4× across six modes, and absolute was slowest
Large DOM is inherently slowIdentical 261,011-node DOM: 401.9 ms → 7.9 ms with one CSS declaration

Each of those had a real effect somewhere, misremembered as a general rule. Learning to tell the difference is the actual curriculum.

The loop

Every module runs the same sequence, and the order is not negotiable:

Understand → Predict → Implement → Break → Debug → Measure → Compare → Explain

Predict is the load-bearing step. A prediction you get right needs no correction; a prediction you get wrong localises the defect in your model precisely. Every step file opens with a prediction you are asked to commit to in writing before running anything.

Measurement honesty

Three practices are enforced throughout, because performance work fails quietly:

Harness bugs are documented, not fixed silently. Every module hit at least one, and all produced clean, plausible, wrong data — a reused JavaScript realm that voided 39 of 40 trials, INP computed per-event instead of per-interaction, a shared page letting one variant's leak contaminate the next, shared inline-cache state that reported doubles as 4× faster than integers. They live in each module's docs/measured-results.md under "harness failure modes", because the reflex they teach outlasts any individual number.

Unexplained results stay unexplained. One measurement in fe-03 reproduced, had its leading hypothesis tested and rejected, and was then abandoned because the effect is 0.6 nanoseconds. It is recorded as an open question, not dressed up as a finding. Knowing when to stop investigating is the same judgement as knowing when to stop optimising.

Every module states what its numbers do not prove. One machine, one browser version, synthetic content, no network. The ratios transfer; the milliseconds do not.

How to use this book

  1. Read Toolchain Setup — Node 18+ and a Chrome binary is the whole dependency list.
  2. Read the Curriculum Map for the dependency graph and why the ordering is what it is.
  3. Start at fe-01. Each module is self-contained:
fe-NN-<name>/
├── CONCEPTS.md          # the "why" and the mental model — read first
├── references.md        # specifications first; excluded sources named and justified
├── docs/
│   ├── measured-results.md  # every number, with the browser version
│   ├── analysis.md          # trade-offs, decision rules, what breaks at scale
│   ├── execution.md         # tool versions, quick start, method notes
│   ├── verification.md      # pass/fail checkpoints with expected output
│   ├── observation.md       # how to read the evidence, and its limits
│   └── broader-ideas.md     # where the mechanism reappears later
├── steps/               # predict → run → expected output → what just happened
└── src/                 # the experiments. `npm install && npm run all`
  1. Do the Principal Engineer Review at the end of each module in writing. Several questions are about refusing work with arithmetic, which is most of the practical value.

Three tracks

TrackPrefixModulesFocus
Frontend Principal Engineeringfe-NN6 built of 51judgment, architecture, measured trade-offs
Browser Internalsbi-NN17Chromium, Blink, V8 — source to pixels
Framework Internalsfw-NN12build a React, signals, bundler, router, query cache

Siblings, not stages. The frontend track develops judgment; the internals tracks remove abstraction boundaries. The measured-evidence discipline described above is fully applied to the fe- track so far; the bi- and fw- tracks share the structure and are at an earlier stage of that treatment.

Source of truth

This book is derived from two specification files, which remain authoritative:

Where the book and the specifications disagree, the specifications win.

Phases & Modules

This repository holds three parallel tracks, all sharing the same module shape:

TrackPrefixModulesWhat it builds
Frontend Principal Engineeringfe-NN51 planned, 6 builtArchitectural and organisational judgment, measured
Browser Internalsbi-NN17Chromium, Blink and V8 — from source to pixels
Framework Internalsfw-NN12Build a React, a signals runtime, a bundler, a router, a query cache

They are siblings, not stages. The frontend track develops judgment; the internals tracks remove abstraction boundaries. Cross between them when a topic genuinely stalls without the other — the hooks are enumerated in the Curriculum Map §2.

The phase structure below applies to the frontend track. The bi- and fw- tracks are ordered but not phased; see their own roadmap modules (bi-00, fw-01).


Seven phases, 51 modules, plus a continuous AI-assisted-engineering spine. The ordering is derived from real dependencies, not topic popularity — see the Curriculum Map for the dependency graph and the five load-bearing chains.

Legend: ✅ built & measured · 🟡 scaffolded · ⬜ planned


Phase 1 — Platform Substrate · Foundation → Senior

Before any abstraction, know what the abstraction is hiding.

ModuleTitleStatusHeadline measured result
fe-01Execution Model, Scheduling & the Rendering PipelineMicrotask chunking rendered 0 frames vs 49; naive worker worst of 7 strategies
fe-02Memory Model, Retention & LeaksAn unused sibling closure retained 38 MB vs 0.01 MB (5744×)
fe-03Object Shapes, Hidden Classes, ICs & JITOne forced layout = 1,448 de-megamorphised property reads
fe-04HTML as an Application PlatformNative <dialog> 7/7 behaviours in 222 bytes; custom 6/7 in 5.4×
fe-05CSS Architecture, Containment & Invalidationcontent-visibility cut initial layout 98%, identical DOM
fe-06Layout: Flex, Grid, Intrinsic Sizing, Container Queries1.4× across six layout modes; absolute positioning slowest
fe-07Networking: HTTP/1.1→3, Caching, ETags, CDN
fe-08TypeScript as a Contract System
Capstone 1 — FoundationProduction-quality accessible responsive application

Gate 1 → 2: predict execution order and rendering timing of unfamiliar async code unaided · find a leak from a heap-snapshot diff · read a waterfall and name the bottleneck class.


Phase 2 — Framework Mechanics · Senior

A framework is a scheduling and diffing policy with ergonomics attached.

ModuleTitleStatus
fe-09Component model, reconciliation, Fiber, render vs commit
fe-10Hooks mechanics, stale closures, the useEffect pathology
fe-11Memoization economics
fe-12Concurrent rendering, transitions, Suspense as scheduling
fe-13Reactivity models compared (Vue / Svelte / Solid / Web Components)
fe-14UI algorithms: diff, LRU, tries, intervals, virtualization

Phase 3 — Architecture & Data · Senior → Staff

A frontend is a distributed system with a rendering engine attached.

ModuleTitleStatus
fe-15State taxonomy: local / lifted / URL / server / global / derived
fe-16Server state ≠ client state; query caches & invalidation
fe-17Races, cancellation, idempotency, optimistic update + rollback
fe-18API integration as a distributed-systems problem
fe-19Application architecture — and its overengineering critique
fe-20Designing for the unhappy path
fe-21Rendering architectures: CSR / SSR / SSG / ISR / streaming / islands / RSC
Capstone 2 — Intermediate

Phase 4 — Quality Attributes · Staff

These are properties of the architecture, and they are measurable.

ModuleTitleStatus
fe-22Performance engineering & budgets
fe-23Loading architecture: splitting, tree shaking, preload, Early Hints
fe-24Accessibility as engineering
fe-25Frontend security
fe-26Internationalisation
fe-27Mobile web & PWA
fe-28Workers & parallelism
fe-29WebAssembly
fe-30Large-scale UI performance
Capstone 3 — Senior

Phase 5 — Quality Engineering · Staff

Thousands of tests can still produce low confidence.

ModuleTitleStatus
fe-31Test strategy economics
fe-32UI testing
fe-33E2E & flaky-test prevention
fe-34Integration & contract testing
fe-35Visual regression
fe-36AI-assisted QA
fe-37Principal quality engineering

Phase 6 — Platform & Delivery · Staff → Principal

You stop building applications and start building the conditions under which others build them.

ModuleTitleStatus
fe-38Design systems
fe-39Build tooling
fe-40Monorepos
fe-41Microfrontends — taught critically
fe-42Frontend platform engineering
fe-43Observability
fe-44Reliability
fe-45CI/CD & release engineering
Capstone 4 — Staff

Phase 7 — Principal Judgment · Principal → Distinguished

The work is now decisions, influence, and being right about reversibility.

ModuleTitleStatus
fe-46Architecture Decision Records
fe-47Technical decision-making: one-way vs two-way doors
fe-48Technical debt
fe-49Migrations
fe-50Principal skills, strategy, technology radar
fe-51Failure case-study gauntlet — all 12 incidents, cold
Capstone 5 — Principal
Capstone 6 — Distinguished / SME

The spine — AI-assisted engineering

Specification areas §20–24 are not a phase. They run continuously from Phase 1, because deferring them means practising the workflows only after your judgement is good enough not to need practice. The governing rule: an agent may produce anything whose failure modes you can enumerate yourself.


Sibling tracks

Browser Internals (bi-00bi-16) — Chromium architecture, the HTML parser, DOM internals, the V8 binding layer, the CSS engine, layout, paint, the compositor, scheduling, tracing, Web Platform Tests, and the contribution workflow. Start at bi-00.

Framework Internals (fw-01fw-12) — build a Redux, a React core, a signals runtime, a concurrent scheduler, a Vue renderer, a template compiler, a bundler, a router, a query cache and a virtualised list, then read the production sources. Start at fw-01.

Both derive from Browser & Framework Internals and are tracked in PROGRESS.md.

Toolchain Setup

What you need, in the order you need it. Phases 0–2 of the browser track require no local Chromium build — that is deliberate, and it is what keeps the track moving while a 100 GB checkout and a toolchain upgrade happen in the background.


Tier 0 — needed on day one (zero cost)

ToolWhy
Chrome / Chromium (stock)every observation lab; DevTools; --enable-blink-features
Chromium Code Searchsource.chromium.org/chromium/chromium/srcsource navigation with cross-references and blame, no checkout
Perfetto UIui.perfetto.devfull traces across processes and threads
Node.jsthe framework-track labs and their spec harnesses
The specs — WHATWG HTML/DOM, CSS, Web IDLthe contract the implementation is written against

That is enough for bi-01, bi-03, and all of fw-*.

DevTools settings worth changing once

  • CPU throttling 4×/6× — unthrottled desktop results are not evidence about your users.
  • Rendering panel: paint flashing, layer borders, scroll-performance issues, frame rendering stats.
  • Performance panel: enable "Screenshots" and "Memory".
  • Experiments: enable the timeline's advanced rendering instrumentation if offered.

Tier 1 — the checkout (large, but useful before it compiles)

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git ~/depot_tools
export PATH="$PATH:$HOME/depot_tools"          # add to ~/.zshrc
mkdir ~/chromium && cd ~/chromium
caffeinate fetch --git-cache chromium

Budget the disk honestly. Measured on the reference machine (2026-08-10):

ItemSize
~/chromium/src26 GB
git cache mirror24 GB
one component build, modest symbols15–25 GB

The mirror location comes from cache_dir in ~/chromium/.gclient — it is not ~/.cache, and confusing the two sends you deleting the wrong thing. fetch --git-cache roughly doubles peak storage; the mirror is a cache and can be deleted once src is synced.

What the checkout gives you before it ever compiles

This is the part people miss. Without a working compiler you still get:

git grep -n 'CausesFosterParenting' -- third_party/blink/renderer/core/html/parser/
git log -S'mac_sdk_official_version = "26.5"' -- build/config/mac/mac_sdk.gni
git log --follow --oneline -- <file>          # survives the ng_* renames

git grep over 30M lines returns in well under a second — faster than Code Search — and git log -S is the archaeology tool the source-reading ladder depends on. Plus every in-tree document, every .json5, .idl, and .mojom.


Tier 2 — the build (needed from Phase 3)

cd ~/chromium/src
gn gen out/Default

out/Default/args.gn:

is_debug = false                    # release codegen: much faster builds
is_component_build = true           # small dylibs, fast incremental links — essential
symbol_level = 1                    # function names + lines
blink_symbol_level = 2              # full symbols where you set breakpoints
dcheck_always_on = true             # keep assertions: the highest-value learning flag
autoninja -C out/Default content_shell    # prefer this over `chrome`
gn ls out/Default | grep -E ':(content_shell|blink_tests)$'   # never guess target names

dcheck_always_on = true is the flag most people omit and the one that matters most for learning: DCHECKs are Blink's invariants written as executable assertions, so breaking one produces a message naming the invariant instead of a confusing misrender.

Known blocker on the reference machine

Chromium trunk requires macOS 26.2+ / Xcode 26.5+ / SDK 26.5 (the requirement landed 2026-05-13). On macOS 15.0 / Xcode 16.2 the build fails on missing SDK symbols such as posix_spawn_file_actions_addchdir, and no gn flag can fix it — __builtin_available is a runtime check but the symbol must exist at compile time.

Full diagnosis, the two gn workarounds that get gn gen through, and the ranked options are in the build guide §2.0.


Tier 3 — debugging and tests

echo "command script import ~/chromium/src/tools/lldb/lldbinit.py" >> ~/.lldbinit

Attach to a renderer, not the browser process:

out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell \
  --renderer-startup-dialog --disable-hang-monitor <url>
lldb -p <pid printed by the dialog>

--disable-hang-monitor matters: without it, sitting at a breakpoint for 30 seconds gets your renderer killed and you lose the state you were inspecting.

Tests:

autoninja -C out/Default blink_tests
third_party/blink/tools/run_web_tests.py -t Default fast/forms
out/Default/content_shell --run-web-tests <path>

Known failures live in third_party/blink/web_tests/TestExpectations (9,418 lines) — which is also your candidate pool for a first contribution.


Building this book

bash build.sh          # installs a pinned mdBook if absent, writes ./book and ./dist/book
mdbook serve           # live reload at http://localhost:3000
python3 tools/gen-summary.py   # regenerate SUMMARY.md after adding modules or steps

SUMMARY.md is generated, not hand-maintained: a module has up to 13 pages across ~35 modules, and a mistyped link silently drops a page from the book.


A standing rule

Every Chromium path, class name, command, and flag in this curriculum has a date attached in PROGRESS.md §7. Re-verify anything older than about six months.

This is not pedantry. In the course of writing this book, two widely-repeated facts turned out to be retired — the ng_ prefix on Blink layout classes, and TraceWrapperMember<T> for DOM/JS heap synchronisation — and both would have been asserted confidently from memory. Each took under a minute to check in the tree.

Glossary

Terms used across all three tracks, with the precision the modules rely on. Where a definition is quoted or measured from the Chromium tree, the date is given — Chromium moves, and a stale definition is worse than none. See PROGRESS.md §7 for the verification log.


Browser architecture

Browser process — the privileged process. Owns UI, navigation, permissions, disk, cookies, and process allocation. Trusts nothing a renderer tells it.

Renderer process — sandboxed, hosts Blink and V8 for one or more same-site documents. Assumed to be compromised; this assumption generates most of Chromium's architecture.

GPU process — executes GL/Vulkan commands and runs the display compositor (viz). Isolated because graphics drivers are both fragile and a large attack surface.

Network service — owns sockets, TLS, and the HTTP cache. Renderers never hold a socket.

Blink — the rendering engine: HTML, DOM, CSS, style, layout, paint, events, Web APIs. Lives in third_party/blink/renderer. Not the same thing as "the renderer process".

V8 — the JavaScript and WebAssembly engine. A separate project. document is not a V8 concept; Node.js has V8 and no DOM.

cc — the compositor, in //cc. Runs in the renderer process, across the main thread and the compositor ("impl") thread. Not the GPU process.

viz — the display compositor, in the GPU process. Aggregates CompositorFrames from every renderer plus browser UI into the frame that is presented.

Mojo — Chromium's IPC system. A message pipe is a pair of endpoints; a .mojom file declares interfaces of messages; a Remote sends and a Receiver receives. Dispatch is a scheduled task, not a function call — ordering is guaranteed per pipe and not across pipes.

Associated interface — a Mojo interface sharing a pipe with a parent, which restores ordering between them. Seeing one usually means somebody was bitten by an ordering bug.


Process model (verified 2026-08-11, docs/process_model_and_site_isolation.md)

Site — scheme + eTLD+1 (https://example.com). Not an origin. a.example.com and b.example.com are the same site, because document.domain historically let them script each other synchronously.

SiteInfo — the security principal: what data an execution context may access.

SiteInstance — the principal instance, and the core unit of the process model. Any two documents with the same principal in the same browsing context group must share a process, because they have synchronous access to each other. Corresponds roughly (not exactly) to the HTML spec's agent cluster.

BrowsingInstance — the browsing context group: tabs and frames that can reach each other (window.opener, named targets, nested frames). rel="noopener" exists to keep a new page out of the group.

ProcessLock — the enforcement point. Restricts which sites may load in a RenderProcessHost and which data it may access. Granularity may be a site, an origin, a scheme, or "any site".

Site isolation — one renderer per site, including out-of-process iframes. Defends against a compromised renderer, not merely against script: after Spectre, "the data was never in that process" replaced "the checks stop it".

Rule of Two — code must not do all three of: process untrustworthy input, in an unsafe language, without a sandbox. Pick at most two. Explains most of Chromium's utility processes.


Rendering pipeline

Flat tree — the node tree with shadow content slotted in. Style and layout operate on this, not the node tree. Slot assignment does not change parentNode.

ComputedStyle — the fully-resolved property set for an element. Immutable and shared between elements that resolve identically; inline styles and unique selectors defeat the sharing.

RuleFeatureSet — the index built from stylesheets: for each feature (class, id, attribute, pseudo-class) it records what would need invalidating if that feature changed. Its entries are invalidation sets (descendant, sibling, nth, part/slotted), with a fallback to whole-subtree invalidation when precision is impossible.

invalidate: — a field in core/css/css_properties.json5 declaring, per property, which pipeline stage a change dirties. Measured 2026-08-11: 822 property entries, 311 declare it — 95 ["layout","paint"], 50 ["paint"], 34 ["layout"], 4 ["compositing"].

LayoutObject / LayoutBox — boxes participating in layout. Not one-per-element: display:none produces none, and anonymous boxes are synthesised.

ConstraintSpace — the immutable input to a layout algorithm (available size, fragmentation state, writing mode). Explicit inputs are what make layout results cacheable.

Fragment — an immutable geometry result. A box may produce several (pagination, multicol, inline splitting), which is why getClientRects() can return more than one rectangle.

Logical vs physical coordinates — layout thinks in inline/block, start/end; paint thinks in top/left. Code written physically breaks in RTL and vertical writing modes.

PrePaint — the phase that walks the layout tree to do paint invalidation and build paint property trees.

Display item — one recorded drawing command. Paint produces a recording, not pixels.

PaintChunk — "sequential display items that share a common property tree state". Change a property-tree node and the chunk's state changes while its contents do not — which is the entire transform/opacity fast path.

Property trees — four separate trees: transform, clip, effect, scroll. Separate so a scroll or a fade changes one node rather than re-recording the world.

Stacking context — an atomic unit of paint order. Created by opacity < 1, transform, filter, will-change, contain: paint, isolation, and others. Why opacity: 0.99 "fixes" z-index bugs.


Compositor (verified 2026-08-11, cc/README.md)

Layer / LayerImpl — the same concept on the main thread / the compositor thread. The type name tells you the thread.

Active tree — the layers and property trees used to submit a frame. Composited effects — scrolling, pinch, animation — are done by modifying the active tree, which is why they survive a blocked main thread.

Pending tree — the tree being rastered. Becomes active only when its tiles are ready, so half-rastered content is never shown.

CompositorFrame — a set of RenderPasses (lists of DrawQuads) plus metadata: instructions for drawing a scene, not pixels.

ElementID — a stable identifier across updates, used to attribute composited animations. The same role key plays in a reconciler.

Tile — a subdivision of a layer, rastered independently and prioritised by viewport proximity. Outrunning raster produces checkerboarding, which is deliberate: a consistent frame now beats a correct frame late.

Damage rect — the region that actually changed, so the GPU redraws only that.


Scheduling (verified 2026-08-11, platform/scheduler/TaskSchedulingInBlink.md)

Rendering opportunity — the point at which the browser runs rAF, observers, style, layout, paint, and commit. Not a task you can post.

Microtask checkpoint — drains the microtask queue exhaustively, including microtasks enqueued during the drain. Not a yield point: no rendering, no input.

PausingScopedPagePauser enters a nested run loop during alert(), print(), or a debugger breakpoint. No JavaScript runs.

Deferring — tasks are deferred for 2 seconds after a user gesture, on the bet that another gesture is coming.

Freezing — background pages are frozen after 5 minutes on mobile; on desktop by heuristics based on which APIs the page uses.

Throttling — currently applies to JS timers only (setTimeout/setInterval).

INP — Interaction to Next Paint = input delay + processing + presentation. Two of the three terms are not your handler.

LoAF — Long Animation Frames API; reports long frames with blockingDuration and a scripts[] attribution.


Object lifetime

Oilpan — Blink's garbage collector, built on V8's cppgc. Heaps are per-thread; Node, CSSValue, and LayoutObject get dedicated typed spaces. Marking and sweeping are concurrent.

Unified heap — one collector tracing both the C++ and JavaScript heaps, which is what makes cross-heap cycles collectable. Supersedes "wrapper tracing"; TraceWrapperMember<T> no longer exists (verified 2026-08-11).

Member<T> / Persistent<T> / WeakMember<T> — graph edge / root from non-GC code / weak edge. An object's Trace() method is the authoritative list of what it keeps alive.

ActiveScriptWrappable — keeps an object alive while it has pending activity, because reachability alone would collect a pending XMLHttpRequest.

Wrapper — the JS object for a C++ DOM object. Created lazily, on first observation by script, and must be stable so expandos and === survive.

World (DOMWrapperWorld) — an isolated JS view of the same DOM, used for extension content scripts. Different wrappers, different expandos: a real security boundary.


Framework internals

Reconciliation — computing the minimal DOM operations between two descriptions of a UI.

Key — author-supplied identity for a list child. The runtime cannot infer it: [A,B,C] → [B,C] is ambiguous between "deleted A" and "renamed everything".

Fiber — an explicit, heap-allocated work-loop node (child / sibling / return) replacing the call stack, so render work can be paused, inspected, resumed, and discarded.

Render phase / commit phase — interruptible and side-effect-free / atomic and DOM-touching. Two statements of one requirement: discardable work must have done nothing, and output must be applied all at once.

Tearing — one render observing two different values of external mutable state. The reason useSyncExternalStore exists.

Glitch — a reactive system transiently computing a value from inconsistent inputs, e.g. a diamond a → b → d, a → c → d evaluated in the wrong order.

Invalidation set / dependency set — the same idea in a browser and in a reactive runtime: an index from "what changed" to "what must be recomputed".

Patch flag — a compiler-emitted annotation of which parts of a node can change, so the runtime skips comparisons. Compile-time knowledge substituting for runtime work.

Author-supplied guarantee — a promise the runtime cannot derive, which unlocks an otherwise impossible optimisation: key, {passive: true}, contain, sideEffects, aria-setsize, Origin-Agent-Cluster. This pattern recurs across every layer of the stack.

Browser & Framework Internals — Progress

Track: Browser & Framework Internals (browser-framework-internals.md). The sibling track (frontend-principal-engineering.md) is tracked in fe-00-roadmap/docs/progress.md. Neither claims progress on the other.

Map, phases and sequencing rationale: bi-00-roadmap/CONCEPTS.md. Build/debug/trace procedures: bi-00-roadmap/docs/chromium-build-debug-trace.md.

Current phase: 0 — Instrumentation Current module: bi-03-html-parsing (parsing lab) and bi-01-navigating-chromium (drills) Status: materials complete through the whole track; awaiting learner execution of Phase 0.

Status values: todo · in progress · lab done · complete · revisit

Shared with the sibling track, do not duplicate: learning-log.md §3 is the source-reading log for both tracks · decisions/ holds ADRs from either.


0. The loop (non-negotiable)

Use it -> predict (written, BEFORE observing) -> build a small version
-> intentionally break it -> debug it -> inspect production source
-> compare architectures -> explain why the complexity exists
-> modify production source where practical

A prediction written after observation is worth nothing. A clearly-stated wrong prediction is worth more than a vague right one.

0b. Source-reading gate (§44)

Not complete until all eight are answered in writing: why does this code exist · what invariant does it maintain · who calls it · what does it call · which process/thread runs it · what happens if removed · how is it tested · what simpler design would fail, and why.

0c. Navigation rule

Source paths are never handed to you. State a hypothesis, search, confirm or falsify, and record the query that worked. Queries transfer; paths rot — see §8.


1. Phase status

Phase 0 — Instrumentation (no build required)

ModuleStatusLabNotes
bi-01-navigating-chromiumtododrills8 timed drills; deliverable is the query log
bi-03-html-parsingtodoparsing labstart here; fixtures in src/
Chromium checkout✅ done~/chromium/src @ 7e6a84f5165fd, 26 GB
Chromium buildblockedtoolchain floor — see build guide §2.0
Architecture map from memory, then correctedtodo
DevTools → Perfetto: name 5 trace eventstodo

Phase 1 — Parser, DOM, Bindings

ModuleStatusLab
bi-02-architecture-process-modeltodo
bi-04-dom-internalstodo☐ mini-browser M3
bi-05-bindings-v8todo☐ mini-binding layer
fw-01-mini-reduxtodo☐ + reading ladder L1

Phase 2 — Style

ModuleStatusLab
bi-06-chromium-cpptodo☐ (JIT reference)
bi-07-css-enginetodo☐ mini-browser M4–M6
fw-02-mini-react-coretodo☐ stages 1–7
Vertical trace 1classList.addtodo

Phase 3 — Layout & Paint

ModuleStatusLab
bi-08-layouttodo☐ mini-browser M7–M9
bi-09-painttodo☐ mini-browser M10–M11
fw-03-reactivity-signalstodo☐ both, back to back
Vertical traces 2, 3getBoundingClientRect, DOM insertiontodo

Phase 4 — Compositor, Scheduling, Debugging

ModuleStatusLab
bi-10-compositor-gputodo
bi-11-schedulingtodoneeds fe-01 first
bi-12-debugging-tracingtodoneeds a build
fw-04-mini-react-schedulingtodo☐ derive Fiber
fw-10-virtual-listtodo
Vertical traces 4, 5, 8 — click, scroll, rAFtodo

Phase 5 — Compilers, Tooling, Production Source

ModuleStatus
fw-05-vue-renderertodo
fw-06-compilerstodo
fw-07-bundlertodo
fw-08-routertodo
fw-09-query-cachetodo
fw-11-production-sourcetodo
fw-12-testing-devtoolstodo
Vertical traces 6, 7setState, fetchtodo

Phase 6 — Tests, WPT, Contribution

ModuleStatus
bi-13-tests-wpttodo — needs a build
bi-14-contributiontodo — needs a build

Phase 7 — Capstone

ModuleStatus
bi-15-vertical-tracesongoing across phases
bi-16-capstonetodo

2. Vertical trace ledger (§43)

Record the deepest layer genuinely resolved with evidence, not the deepest you can name.

#OperationAfterStatusDeepest resolvedEvidence type
1classList.addbi-07
2getBoundingClientRectbi-08
3DOM insertionbi-04
4clickbi-10/11
5scrollbi-10
6setCount(count+1)fw-04
7fetchbi-02
8requestAnimationFramebi-11

2b. Archaeology missions (§25)

document.createElement ☐ · classList.add ☐ · getBoundingClientRect ☐ · requestAnimationFrame ☐ · fetch ☐ · CSS Grid ☐ · click/input ☐ · accessibility tree ☐


3. Complexity notebook (§46)

Observed complexity:
My simpler design:
What requirement breaks my design:
Production constraint:
Resulting architecture:
Classification: essential architecture | production hardening | accretion  (defend it)
#TopicModuleStatus
1HTML parser insertion modesbi-03
2Redux middleware vs enhancersfw-01
3Style invalidation setsbi-07
4Layout fragmentationbi-08
5Property treesbi-09
6Pending/active tree splitbi-10
7Mojo / multiprocess IPCbi-02
8Two garbage collectorsbi-04
9Vue schedulerfw-03
10Fiber (flagship)fw-04
11Hydrationfw-11
12Event delegation / synthetic eventsfw-11

4. Contribution ladder (§24)

RungTargetStatusCL
0Build Chromium⛔ blocked on toolchain
1Docs or test-only
2Small isolated correctness fix
3Blink behaviour bug + regression test
4Small rendering/style/layout improvement
5Cross-component change

Per rung retain: bug · reproduction · spec · subsystem · source path · call path · test · proposed fix · reviewer feedback · architectural lesson.

Candidate pool: build it in bi-13 lab step 4 from TestExpectations.


5. Lab log

LabDatePrediction correct?TimeBiggest surprise
parsing (bi-03)
navigation drills (bi-01)

Source-code readings go in fe-00-roadmap/docs/learning-log.md §3.


6. Environment

Checkout~/chromium/src @ 7e6a84f5165fd (VERSION 8002), 26 GB
git cache~/Library/Caches/depot_tools/git_cache, 24 GB — deletable
depot_tools~/depot_tools, on PATH via ~/.zshrc
Free disk~90 GB
HostmacOS 15.0 · Xcode 16.2 · SDK 15.2 · M2 Pro / 12 core / 32 GB
Build⛔ trunk requires macOS 26.2 / Xcode 26.5

What still works without a compiler: git grep over the full tree (faster than Code Search), git log -S archaeology, all in-tree docs, .json5/.idl/.mojom reading — i.e. everything through Phase 2.


7. Verification log

Chromium moves. Anything unchecked older than ~6 months is suspect.

DateClaimVerdict
2026-08-10Chromium trunk builds with Xcode 16.2 / SDK 15.2FALSE — requires Xcode 26.5 / SDK 26.5 (landed 2026-05-13), hence macOS 26.2+. gn gen can be forced with use_clang_modules=false + use_unified_system_module=false, but compilation fails on missing SDK symbols (posix_spawn_file_actions_addchdir).
2026-08-10Blink layout classes are prefixed ng_STALE — prefix removed; block_layout_algorithm.cc, constraint_space.h. layout_ng.md doc remains.
2026-08-10Chromium bugs live at bugs.chromium.orgSTALE — now issues.chromium.org. In-tree docs/contributing.md still has the old reference.
2026-08-10fetch --git-cache mirror lives in ~/.cacheFALSE — location is cache_dir in ~/chromium/.gclient; here ~/Library/Caches/depot_tools/git_cache. Roughly doubles peak disk.
2026-08-10Blink's HTML fastpath accelerates document parsingFALSETryParsingHTMLFragment is the fragment (innerHTML) path; bails out to the general algorithm on unsupported tags.
2026-08-10BackgroundHTMLScanner == the old threaded HTML parserFALSE — it runs on a worker thread scanning all body data for inline scripts to stream-compile; HTMLPreloadScanner is the main-thread, first-chunk preload scanner. Tree building remains main-thread.
2026-08-11Blink keeps DOM/JS heaps in sync via wrapper tracing (TraceWrapperMember<T>)STALE - deprecated, type removed. Blink uses V8's unified heap: Oilpan is cppgc, which lives in V8, so one collector traces both. Use Member<T> + TraceWrapperV8Reference<T>. Source: platform/bindings/TraceWrapperReference.md.
2026-08-11css_properties.json5 declares per-property pipeline invalidationConfirmed - invalidate: field. 822 property entries, 311 declare it. Distribution: 95 ["layout","paint"], 50 ["paint"], 34 ["layout"], 13 ["layout","scroll-anchor"], 4 ["compositing"], plus reshape, color, text-decoration, ax-style, transform-data.
2026-08-11Blink HTML tokenizer/tree-builder sizeConfirmed - 76 tokenizer states (html_tokenizer.h), 21 insertion modes (html_tree_builder.h). 7 of 21 modes are table-related; 3 are frameset.
2026-08-11Chromium process-model vocabularyConfirmed - SiteInfo (security principal), SiteInstance (principal instance, ~= agent cluster), BrowsingInstance (browsing context group), ProcessLock (enforcement). Source: docs/process_model_and_site_isolation.md.
2026-08-11Blink scheduler policy constantsConfirmed - tasks deferred 2 s after a user gesture; mobile freezes background pages after 5 min; only JS timers are currently throttleable; ScopedPagePauser stops all JS during alert()/print()/breakpoints. Source: platform/scheduler/TaskSchedulingInBlink.md.
2026-08-11Oilpan heap partitioningConfirmed - Node, CSSValue, LayoutObject get typed custom spaces; collection backings get compactable spaces; concurrent marking+sweeping; GC scheduled via the message loop for precision.
2026-08-11V8 has four execution tiersConfirmed in-tree: v8/src/interpreter (Ignition), baseline (Sparkplug), maglev (Maglev), compiler (TurboFan).
2026-08-11TestExpectations scale and policyConfirmed - 9,418 lines. Results vocabulary [ Timeout Crash Pass Failure Skip ]. Policy: a bare [ Skip ] is not allowed; failing tests keep running on bots so flakiness data accrues.
2026-08-11Paint architectureConfirmed - PrePaintTreeWalk does paint invalidation + property-tree building; one PaintController per LocalFrameView; a PaintChunk is "sequential display items sharing a common property tree state"; caching at display-item and subsequence level.
2026-08-11cc vocabularyConfirmed - CompositorFrame = RenderPasses of DrawQuads + metadata; Layer main-thread only, LayerImpl compositor-thread; composited effects are done by modifying the active tree; ElementID is the stable cross-update identifier.
2026-08-11Blink generated-input surfaceConfirmed - 57 .json5 files under third_party/blink/renderer. runtime_enabled_features.json5 status vocabulary is exactly ["stable","experimental","test"], optionally per-platform.
2026-08-11Trace categories are enumerable in-treeConfirmed - base/trace_event/builtin_categories.h (~500 strings). In core/ alone: blink 204 uses, navigation 41, loading 20, input 17, devtools.timeline 4.
2026-08-10fetch --git-cache chromium is the fast-checkout path on macOSConfirmed — docs/mac_build_instructions.md
2026-08-10Parser sources in third_party/blink/renderer/core/html/parser/Confirmed — 95 files incl. html_tokenizer.*, html_tree_builder.*, html_construction_site.*
2026-08-10Foster parenting: HTMLConstructionSite::{FosterParent,FindFosterSite,ShouldFosterParent}, HTMLStackItem::CausesFosterParenting, HTMLTreeBuilder::ProcessStartTagForInTableConfirmed against main
2026-08-10Build targets //content/shell:content_shell, //:blink_tests, //third_party/blink/renderer/controller:blink_unittestsConfirmed via gn ls
2026-08-10Style invalidation machinery: RuleFeatureSet, PendingInvalidationsMap, SelectorChecker::MatchSelector, Element::StyleForLayoutObjectConfirmed — core/css/style-invalidation.md, style-calculation.md

Concepts — The Frontend Principal Journey

1. What is it

A structured progression from platform fundamentals to Principal/Distinguished-level frontend and web-platform engineering, driven by frontend-principal-engineering.md (the specification). The specification lists 46 subject areas plus failure case studies, capstones, and heuristics. This module converts that list into an executable order: which subjects gate which, what can run in parallel, and what "done" means at each stage.

The unit of work is a numbered module directory (fe-NN-topic/) containing concepts, runnable source, step-by-step labs, verification checkpoints, and analysis. A module is complete when its verification checkpoints pass against measured output, not when its prose has been read.

2. Why it matters

Studied in specification order, the 46 areas look like 46 independent subjects. They are not, and treating them as independent produces three specific, expensive failures:

  • Optimising symptoms. Performance engineering (§11) attempted before the execution model (§1) and rendering pipeline (§2) produces work that moves numbers without moving experience. The engineer cannot distinguish input delay from processing from presentation, so one fix gets applied to three different problems.
  • Cargo-culted architecture. Microfrontends (§30) attempted before build tooling (§28), monorepos (§29), and design systems (§26) yields a distributed system built to solve a problem the organisation does not have. The specification asks the right question — "is this solving an organizational problem or a technical problem?" — and it is unanswerable without the prerequisites.
  • Compliant, unusable accessibility. Accessibility (§12) attempted before semantic HTML (§3) produces ARIA-first interfaces that pass automated checks and fail real users.

The ordering is the deliverable. Anyone can enumerate topics; sequencing them against real dependencies is what makes the curriculum executable.

3. How it works

SPECIFICATION (46 areas, flat list)
  |
  v
+------------------------------------------------------------------+
|  CLUSTERING — 46 areas collapse into 8 groups by dependency       |
|                                                                   |
|   A Platform substrate     §1,2,3,4,10      <- root, gates all    |
|   B Framework mechanics    §5,6,40          <- needs A            |
|   C Architecture & data    §7,8,9,19,31     <- needs A+B          |
|   D Quality attributes     §11,12,13,36-39,41                     |
|   E Quality engineering    §14-18,25        <- needs a real system|
|   F AI-assisted eng.       §20-24           <- SPINE, not a phase |
|   G Platform & delivery    §26-30,32-35     <- needs C+D+E        |
|   H Principal judgment     §42-46           <- needs lived A-G    |
+------------------------------------------------------------------+
  |
  v
+------------------------------------------------------------------+
|  FIVE LOAD-BEARING CHAINS (see docs/curriculum-map.md)            |
|                                                                   |
|   1. Scheduling      event loop -> INP -> concurrent -> streaming |
|   2. Async correctness  promises -> races -> optimistic/rollback  |
|   3. Cache coherence    HTTP -> CDN -> ISR -> query invalidation  |
|   4. Semantics       DOM -> a11y tree -> design-system contract   |
|   5. Org topology    boundaries -> monorepo -> MFE -> governance  |
+------------------------------------------------------------------+
  |
  v
+------------------------------------------------------------------+
|  7 PHASES + CONTINUOUS AI SPINE                                   |
|                                                                   |
|   Phase 1  Platform substrate      fe-01 .. fe-08   Foundation    |
|   Phase 2  Framework mechanics     fe-09 .. fe-14   Senior        |
|   Phase 3  Architecture & data     fe-15 .. fe-21   Senior/Staff  |
|   Phase 4  Quality attributes      fe-22 .. fe-30   Staff         |
|   Phase 5  Quality engineering     fe-31 .. fe-37   Staff         |
|   Phase 6  Platform & delivery     fe-38 .. fe-45   Staff/Princ.  |
|   Phase 7  Principal judgment      fe-46 .. fe-51   Principal+    |
|                                                                   |
|   Each phase boundary = a promotion gate, not a checkbox          |
+------------------------------------------------------------------+
  |
  v
PER-MODULE LOOP (non-negotiable)
  Understand -> Predict -> Implement -> Break -> Debug -> Measure -> Compare -> Explain

The loop matters more than the content. A module read without the Predict and Break steps produces recognition, not capability — you will recognise the concept in a code review and be unable to derive its consequences under pressure.

4. Core terminology

TermDefinition
ModuleA fe-NN-topic/ directory: concepts + runnable source + steps + verification
PhaseA group of modules sharing a dependency level and a competence target
Promotion gateA demonstrable capability required to advance a phase; not a quiz
Load-bearing chainA dependency path where skipping a link makes later work dishonest
SpineContent applied continuously across all phases (AI-assisted engineering, §20–24)
CapstoneIntegration deliverable at a phase boundary; six total, Foundation → Distinguished
Failure case studyOne of 12 realistic incidents from the specification, run under a 7-question protocol
Verification checkpointA measurable pass/fail with expected output; the definition of module completion
Cross-track hookA point where a frontend topic genuinely requires browser-framework-internals.md
Two-way doorA decision cheap to reverse; the specification's §43 framing for risk-appropriate speed

5. Mental models

A dependency graph, not a syllabus. A syllabus is ordered by convenience; a dependency graph is ordered by necessity. When tempted to reorder, the test is not "does this interest me now" but "can I make an honest claim in this module without the prerequisite?" Microfrontends before build tooling fails that test; TypeScript alongside almost anything passes it.

Depth is earned per phase, not per topic. Every phase touches performance, accessibility, and testing — at the depth that phase can support. Accessibility in Phase 1 is semantic HTML; accessibility in Phase 4 is an engineering discipline with automation limits; accessibility in Phase 6 is a design-system contract with a CI gate. Same subject, three different competences.

The curriculum is a strangler migration of your own mental model. You already have working models for most of these subjects. The programme's job is incremental replacement — find where the existing model produces a wrong prediction, replace that piece, keep the rest running. This is why every module opens with prediction: a prediction you get right needs no replacement, and a prediction you get wrong localises the defect precisely.

Tutorial density falls as phases rise. The specification requires this explicitly: later phases must "increasingly resemble actual Principal Engineer work." Phase 1 has expected output blocks; Phase 7 has ambiguous situations, competing stakeholders, and no answer key.

6. Common misconceptions

  1. "Finishing the modules completes a phase." False. Phases end at promotion gates, which are capabilities demonstrated on unfamiliar input. Completing every Phase-1 module while still unable to predict execution order of code you have not seen means Phase 1 is not complete.

  2. "The AI content is a phase to get to." False, and this is the most common structural error. §20–24 are a spine applied from Phase 1. Deferring them means practising the workflows only after your judgment is good enough to not need practice.

  3. "Parallel means simultaneous." Parallel-safe modules have no dependency edge between them; it does not follow that studying six at once is effective. Parallelism exists to remove false ordering constraints, not to license context-switching.

  4. "The internals track must come first to understand the platform." False. The two tracks are siblings, not stages. Cross into browser-framework-internals.md when a frontend topic stalls without it — the hooks are enumerated in docs/curriculum-map.md §2 — and not on principle.

  5. "Measured numbers are the point." False, and dangerous. Numbers are evidence for a claim; the claim is the deliverable. A module that produces a benchmark table without a stated conclusion, an explicit limit of what the numbers prove, and a decision that changed as a result, has produced decoration.

7. Interview talking points

  • "I sequence frontend curricula by dependency, not by topic popularity. Performance engineering before the execution model produces engineers who can move a metric but can't tell input delay from presentation delay — that's three different fixes behind one number."
  • "The question I ask about microfrontends is the specification's: is this an organisational problem or a technical one? If teams can't deploy independently for organisational reasons, Module Federation won't fix it and will add a distributed system to the list of things that can break."
  • "I treat AI-assisted engineering as a cross-cutting practice, not a topic. The rule I hold is that an agent may produce anything whose failure modes I can enumerate myself — otherwise I'm outsourcing judgment, not leverage."
  • "Promotion gates in my teams are capability demonstrations on unfamiliar input, not checklists — the difference between recognising a concept and deriving its consequences under pressure is exactly the Senior/Staff boundary."

8. Connections to other modules

  • fe-01-execution-model-scheduling — the root of the scheduling chain; gates fe-11 (perf), fe-12 (concurrent rendering), fe-16/17 (server state and races), fe-21 (rendering architectures).
  • browser-framework-internals.md — the sibling track. Cross-track hooks are enumerated in docs/curriculum-map.md §2. That track's own tracker is the repository-root PROGRESS.md; the two never claim progress on each other.
  • docs/curriculum-map.md — the full map: dependency graph, all seven phases, parallelisation table, hard prerequisites, failure case-study schedule, and promotion gates.
  • docs/progress.md — live status across all 51 modules and 12 case studies.
  • docs/learning-log.md — continuity: struggles, open questions, source readings, verification log, and the heuristics-under-test table the specification requires.

References — Programme Design

Source specifications (this repository)

  • frontend-principal-engineering.md — the authoritative curriculum specification
  • browser-framework-internals.md — the sibling depth track
  • README.md — track overview and intended agent usage

Engineering ladders and level definitions

Technical decision-making and architecture records

Learning method

  • Ericsson & Pool, Peak — deliberate practice; why prediction-before-verification works
  • Bjork & Bjork, Making Things Hard on Yourself, But in a Good Way — desirable difficulties
  • Hattie & Timperley, The Power of Feedback (Review of Educational Research, 2007)

Organisational topology (Phase 6–7 grounding)

  • Skelton & Pais, Team Topologies (IT Revolution, 2019)
  • Conway, How Do Committees Invent? (Datamation, 1968) — the original Conway's Law paper

Curriculum Map, Dependency Graph & Phase Plan

Derived from frontend-principal-engineering.md (source of truth) and browser-framework-internals.md (depth track). This file is navigation, not specification. If it conflicts with the spec files, the spec files win.


1. The map: 46 spec areas → 8 clusters

The spec lists 46 numbered areas plus failure case studies, capstones, heuristics and AI rules. Studied in spec order they look like 46 independent subjects. They are not. They collapse into 8 clusters with very different dependency behaviour.

ClusterSpec areasWhat it actually isDependency behaviour
A. Platform substrate1, 2, 3, 4, 10JS runtime, browser architecture, HTML, CSS, networkingRoot. Nearly everything depends on it. Almost nothing depends on the rest.
B. Framework mechanics5, 6, 40React internals, framework comparison, UI algorithmsDepends on A. Gates C and parts of D.
C. Architecture & data7, 8, 9, 19, 31App architecture, state, API integration, unhappy path, rendering architecturesDepends on A + B. Gates E, F, G.
D. Quality attributes11, 12, 13, 36, 37, 38, 39, 41Performance, a11y, security, i18n, mobile, workers, WASM, large-scale UIDepends on A; deepens with C. Mostly parallelisable within the cluster.
E. Quality engineering14, 15, 16, 17, 18, 25Test strategy through org-wide quality policyNeeds a real system (C) to be worth testing. 25 needs all of 14–18.
F. AI-assisted engineering20, 21, 22, 23, 24Agent workflows, test gen, edge-case discovery, exploratory testing, reviewSpine, not a phase. Runs continuously from Phase 1. Quality gated by your own expertise.
G. Platform & delivery26, 27, 28, 29, 30, 32, 33, 34, 35Design systems, platform eng, build tooling, monorepos, microfrontends, observability, reliability, CI/CD, releaseDepends on C + D + E. This is where Staff→Principal happens.
H. Principal judgment42, 43, 44, 45, 46 + case studies + capstones + heuristicsADRs, decision-making, debt, migrations, principal skillsDepends on having lived A–G. Cannot be front-loaded honestly.

The three things that are not clusters

  • Failure case studies (12 listed incidents) — these are assessments, not content. Each one is scheduled against the phase that gives you the tools to solve it. See §5.
  • Capstones (6 levels) — one per phase boundary. They are the promotion gate.
  • Heuristics (10 listed) — the spec says "for every heuristic include counterexamples and limitations." That is a running exercise, tracked in learning-log.md, not a module.

2. Dependency graph

The real edges. A → B means "A must be solid before B is honest."

graph TD
  subgraph SUB["A · Platform substrate"]
    JS["§1 JS runtime<br/>event loop · closures · GC · object shapes"]
    BR["§2 Browser architecture<br/>processes · rendering pipeline · scheduling"]
    HTML["§3 HTML platform<br/>semantics · forms · web components"]
    CSS["§4 CSS architecture<br/>cascade · layout · containment"]
    NET["§10 Networking<br/>HTTP/2·3 · caching · CDN"]
    TS["§1b TypeScript<br/>structural typing · contracts"]
  end

  subgraph FW["B · Framework mechanics"]
    REACT["§5 React deep dive<br/>fiber · render/commit · hooks"]
    ALT["§6 Vue/Svelte/Solid/WC"]
    ALGO["§40 UI algorithms<br/>diff · LRU · virtualization"]
  end

  subgraph ARCH["C · Architecture & data"]
    APP["§7 App architecture"]
    STATE["§8 State management"]
    API["§9 API integration"]
    EDGE["§19 Unhappy path"]
    SSR["§31 SSR/SSG/ISR/islands"]
  end

  subgraph QA["D · Quality attributes"]
    PERF["§11 Performance"]
    A11Y["§12 Accessibility"]
    SEC["§13 Security"]
    I18N["§36 i18n"]
    MOB["§37 Mobile web"]
    WORK["§38 Workers"]
    WASM["§39 WASM"]
    BIGUI["§41 Large-scale UI"]
  end

  subgraph TEST["E · Quality engineering"]
    TSTRAT["§14 Test strategy"]
    UIT["§15 UI testing"]
    E2E["§16 E2E"]
    INT["§17 Integration"]
    VIS["§18 Visual regression"]
    QEP["§25 Org quality policy"]
  end

  subgraph PLAT["G · Platform & delivery"]
    DS["§26 Design systems"]
    FPE["§27 Platform engineering"]
    BUILD["§28 Build tooling"]
    MONO["§29 Monorepos"]
    MFE["§30 Microfrontends"]
    OBS["§32 Observability"]
    REL["§33 Reliability"]
    CICD["§34 CI/CD"]
    RELE["§35 Release engineering"]
  end

  subgraph PRIN["H · Principal judgment"]
    ADR["§42 ADRs"]
    DEC["§43 Decision making"]
    DEBT["§44 Technical debt"]
    MIG["§45 Migrations"]
    PSK["§46 Principal skills"]
  end

  JS --> BR
  JS --> REACT
  JS --> ALGO
  JS --> WORK
  BR --> REACT
  BR --> PERF
  BR --> A11Y
  HTML --> A11Y
  HTML --> CSS
  HTML --> REACT
  CSS --> PERF
  CSS --> DS
  NET --> API
  NET --> PERF
  NET --> SSR
  NET --> SEC
  TS --> API
  TS --> APP

  REACT --> APP
  REACT --> STATE
  REACT --> SSR
  ALGO --> BIGUI
  ALT --> APP

  APP --> STATE
  STATE --> API
  API --> EDGE
  STATE --> EDGE
  SSR --> PERF
  REACT --> SSR

  PERF --> OBS
  PERF --> BIGUI
  WORK --> BIGUI
  WORK --> WASM
  A11Y --> DS
  MOB --> PERF

  EDGE --> TSTRAT
  APP --> TSTRAT
  TSTRAT --> UIT
  TSTRAT --> E2E
  TSTRAT --> INT
  TSTRAT --> VIS
  UIT --> QEP
  E2E --> QEP
  INT --> QEP
  VIS --> QEP

  CSS --> BUILD
  BUILD --> MONO
  MONO --> MFE
  DS --> MFE
  DS --> FPE
  MONO --> FPE
  QEP --> CICD
  CICD --> RELE
  OBS --> REL
  RELE --> REL
  FPE --> MFE

  MFE --> ADR
  REL --> ADR
  FPE --> ADR
  ADR --> DEC
  DEC --> MIG
  DEBT --> MIG
  MIG --> PSK
  DEC --> PSK

The five load-bearing chains

Everything else is decoration around these. If you only get five things right:

  1. Scheduling chain — event loop → task/microtask semantics → long tasks → INP → concurrent rendering → Suspense/transitions → streaming SSR → hydration cost. Rendering the heuristic "performance problems are often scheduling problems" legible.

  2. Async-correctness chain — promises/cancellation → request lifecycle → server-state cache → race conditions → optimistic update + rollback → offline/conflict. This is where "stale response overwrites newer data" lives.

  3. Cache-coherence chain — HTTP cache semantics → ETag/Cache-Control → CDN → SSR/ISR revalidation → query-cache invalidation → stale-while-revalidate UX. One concept, four layers, four different invalidation vocabularies.

  4. Semantics chain — DOM → semantic HTML → accessibility tree → accessible name computation → focus management → a11y as a design-system contract → a11y CI gate. "Accessibility is architecture, not polish" is only true if you can walk this chain.

  5. Org-topology chain — package boundaries → build graph → monorepo → independent deployability → microfrontends → governance → standardisation vs autonomy. "Frontend architecture frequently reflects organizational architecture."

Cross-track hooks into browser-framework-internals.md

Only cross when the frontend topic genuinely stalls without it:

Frontend topicInternals sectionCross when
§1 event loop / §11 INP§16 Browser Schedulingyou need to know why a queue is prioritised, not just that it is
§2 rendering pipeline§5 Complete Rendering Pipeline, §12–15 layout/paint/compositora layout/paint cost is inexplicable at the CSS level
§4 CSS containment§10 CSS Engine Internals, §12 Layout Engineyou need invalidation scope, not spec prose
§5 React fiber§27 Build a React-Like Runtime, §28 React Source Apprenticeshipreconciliation behaviour contradicts your model
§8 signals/stores§29 Vue-like reactive runtime, §33 Redux-like store, §36 signalscomparing reactivity models architecturally
§8/§9 server state§35 Build a Server-State Query Cachedesigning a cache invalidation policy
§13 security§19 Browser Security Architecturereasoning about process/origin boundaries
§28 build tooling§38 Minimal Bundler, §39 JSX compilerevaluating bundler trade-offs
§41 large-scale UI§37 Virtualized List Enginevirtualization correctness/perf
§16 E2E§41 Browser Automation Layerdebugging automation flakiness at the protocol level

3. Phases

Seven phases plus a continuous spine. Each phase ends in a capstone-shaped deliverable and a promotion gate. Depth target rises per phase; tutorial density falls per phase, per the spec's closing instruction.

Spine (continuous, from Phase 1 onward)

F · AI-assisted engineering (§20–24) + §46 writing practice Not a phase. Every module carries an AI-assisted exercise. The rule from the spec holds throughout: AI increases leverage, never replaces understanding. You may not use an agent to produce an artifact whose failure modes you cannot enumerate yourself.


Phase 1 — Platform Substrate

Spec areas: 1, 2, 3, 4, 10 · Level: Foundation → Senior Thesis: Before any abstraction, know what the abstraction is hiding.

ModuleFocusDepth target
fe-01Execution model: event loop, task/microtask, rendering pipeline couplingcan predict ordering + explain scheduling priority
fe-02Memory model: closures, GC, retention, leak taxonomy, heap snapshotscan find a leak from a snapshot diff, unaided
fe-03Object shapes, hidden classes, ICs, JIT — and when it actually matterscan say when this is irrelevant, which is most of the time
fe-04HTML as a platform: semantics, forms, dialog/popover, web componentscan delete a component and replace it with a primitive
fe-05CSS: cascade, layers, custom properties, containment, content-visibilitycan reason about invalidation scope
fe-06Layout: flex, grid, subgrid, container queries, intrinsic sizing, stackingcan predict layout without running it
fe-07Networking: HTTP/1.1→3, connection reuse, cache headers, ETags, CDN, prioritiescan read a waterfall and name the bottleneck class
fe-08TypeScript as a contract system: structural typing, variance, branded types, runtime validation boundarycan design a type-safe API client that fails loudly

Capstone 1 (Foundation): production-quality accessible responsive application, built with deliberately minimal framework surface. Budget-constrained.


Phase 2 — Framework Mechanics

Spec areas: 5, 6, 40 · Level: Senior Thesis: A framework is a scheduling and diffing policy with ergonomics attached. Prereq: fe-01, fe-02, fe-04, fe-05.

ModuleFocus
fe-09Component model, reconciliation, Fiber, render vs commit phase
fe-10Hooks mechanics: state, effects, stale closures, refs, the useEffect overuse pathology
fe-11Memoization economics: when useMemo/memo/compiler pays, when it costs
fe-12Concurrent rendering, transitions, Suspense — as scheduling, not magic
fe-13Reactivity models compared: VDOM vs signals vs compiled (Vue/Svelte/Solid/WC)
fe-14UI algorithms: tree diff, LRU, tries, interval trees, virtualization, debounce/throttle

Deliverable: a written architectural comparison of three reactivity models with a measured micro-benchmark and an explicit statement of what the benchmark does not prove.


Phase 3 — Architecture & Data

Spec areas: 7, 8, 9, 19, 31 · Level: Senior → Staff Thesis: A frontend is a distributed system with a rendering engine attached. Prereq: Phase 2 + fe-07.

ModuleFocus
fe-15State taxonomy: local / lifted / URL / server / global / derived / cached / persistent
fe-16Server state ≠ client state: query caches, invalidation, dedup, SWR
fe-17Race conditions, cancellation, idempotency, optimistic update + rollback
fe-18API integration as distributed systems: retries, timeouts, backpressure, partial failure
fe-19Application architecture: feature slices, dependency direction, ports/adapters, state machines — and when this is overengineering
fe-20Designing for the unhappy path (§19 in full — the 29-item edge-case matrix)
fe-21Rendering architectures: CSR/SSR/SSG/ISR/streaming/islands/RSC and their failure modes

Capstone 2 (Intermediate): complex application with API integration, state management, full testing — built against an intentionally unreliable API.


Phase 4 — Quality Attributes

Spec areas: 11, 12, 13, 36, 37, 38, 39, 41 · Level: Staff Thesis: These are not features. They are properties of the architecture, and they are measurable. Prereq: Phase 3. fe-22 is the anchor; the rest parallelise heavily.

ModuleFocus
fe-22Performance engineering: CWV, LCP/INP/CLS/TTFB, budgets, quantitative claims
fe-23Loading architecture: code splitting, tree shaking, preload/prefetch, Early Hints
fe-24Accessibility as engineering: a11y tree, names, focus, ARIA, the limits of automation
fe-25Frontend security: XSS taxonomy, CSP, CORS, cookies, tokens, Trusted Types, supply chain
fe-26i18n/l10n: Unicode, RTL, pluralization, text expansion, timezones
fe-27Mobile web: CPU/memory/network reality, viewport, virtual keyboards, PWA, service workers
fe-28Workers & parallelism: message passing, transferables, SAB/Atomics — and offload economics
fe-29WASM: enough for architectural judgment, not enough to write a compiler
fe-30Large-scale UI: 100k rows, virtualization, editors, real-time feeds, Canvas/SVG

Capstone 3 (Senior): high-performance application with SSR, caching, observability, defended with numbers against a stated budget.


Phase 5 — Quality Engineering

Spec areas: 14, 15, 16, 17, 18, 25 · Level: Staff Thesis: Quality is an architecture problem. Thousands of tests can still produce low confidence. Prereq: Phase 3 (need a system worth testing) + fe-20 (need to know what to test for).

ModuleFocus
fe-31Test strategy: for each type — what it catches, misses, costs, and how hard failures are to diagnose
fe-32UI testing: user-centric, async, forms, keyboard, a11y assertions; anti-patterns
fe-33E2E with Playwright: contexts, fixtures, interception, traces, flaky-test prevention
fe-34Integration & contract testing: schema validation, service virtualization, ephemeral envs
fe-35Visual regression: tolerance, false positives, browser differences
fe-36AI-assisted QA (§21–23): test matrices, edge-case discovery, exploratory agents
fe-37Principal quality engineering (§25): org policy, gates, budgets, browser matrix, release criteria

Deliverable: an organization-wide testing policy document with explicit confidence claims and explicit un-covered risk.


Phase 6 — Platform & Delivery

Spec areas: 26, 27, 28, 29, 30, 32, 33, 34, 35 · Level: Staff → Principal Thesis: You stop building applications and start building the conditions under which others build them. Prereq: Phases 4 and 5.

ModuleFocus
fe-38Design systems: tokens, primitives, versioning, adoption, governance, contribution models
fe-39Build tooling: module graphs, tree shaking, source maps, incremental builds, HMR; bundler architectures compared
fe-40Monorepos: workspaces, boundaries, dependency graphs, remote caching, affected builds, ownership
fe-41Microfrontends — taught critically. Is this an organizational or a technical problem?
fe-42Frontend platform engineering: templates, standards, codegen, DX, feature flags, preview envs
fe-43Observability: RUM, Web Vitals, tracing, correlation IDs, session replay. Diagnose "it feels slow" with evidence
fe-44Reliability: SLIs/SLOs, error budgets, graceful degradation, kill switches, circuit breakers
fe-45CI/CD + release engineering: risk-based testing, pipeline economics, canaries, rollout, rollback, schema evolution

Capstone 4 (Staff): shared design system + frontend platform used by multiple applications.


Phase 7 — Principal Judgment

Spec areas: 42, 43, 44, 45, 46 + failure case studies + heuristics · Level: Principal → Distinguished Thesis: The work is now decisions, influence, and time horizons — and being right about reversibility. Prereq: Phase 6.

ModuleFocus
fe-46ADRs: context, constraints, options, decision, consequences, reversibility, migration
fe-47Technical decision-making: one-way vs two-way doors, blast radius, optionality, build vs buy, standardisation vs autonomy
fe-48Technical debt: taxonomy, quantification, prioritisation, and how to sell paying it down
fe-49Migrations: strangler patterns, incremental cutover, the 8 migration projects in §45
fe-50Principal skills: strategy, RFCs, influence without authority, incident analysis, radar, mentoring
fe-51Failure case-study gauntlet: all 12 incidents, cold, under the 7-question protocol

Capstone 5 (Principal): frontend architecture for dozens of teams — RFC, diagrams, budgets, testing strategy, observability, security model, a11y policy, migration strategy, CI/CD, DX, operational model.

Capstone 6 (Distinguished/SME): multi-year frontend platform strategy — standardisation, technology radar, migration roadmap, platform APIs, governance, org topology, adoption, build-vs-buy, measurable outcomes.


4. Parallelisation

Safe to study concurrently

GroupWhy it's safe
fe-03 (JIT/shapes) ‖ fe-04 (HTML) ‖ fe-05 (CSS) ‖ fe-07 (networking)Disjoint mechanisms; no shared prerequisite beyond fe-01/fe-02
fe-08 (TypeScript) ‖ anything in Phase 1–2TS is a contract discipline, not a runtime dependency
fe-13 (framework comparison) ‖ Phase 3Comparison is analytical; doesn't gate architecture work
fe-24 (a11y) ‖ fe-25 (security) ‖ fe-26 (i18n) ‖ fe-27 (mobile)Independent quality attributes with independent tooling
fe-28 (workers) ‖ fe-29 (WASM)Related but not dependent; WASM only deepens with workers
fe-32 ‖ fe-33 ‖ fe-34 ‖ fe-35 (test types)Independent given fe-31
fe-38 (design systems) ‖ fe-39/fe-40 (build/monorepo)Different axes of the same platform
Spine F (AI workflows) ‖ everythingBy design
§46 writing practice (RFCs/ADRs) ‖ everything from Phase 3Start writing early; quality compounds

Hard prerequisites — do not parallelise

BlockedRequiresWhy the shortcut fails
fe-22 Performancefe-01 (scheduling), fe-02 (memory), fe-05/fe-06 (layout), fe-07 (network)Without these you optimise symptoms and report noise as wins
fe-12 Concurrent React / Suspensefe-01"Concurrent" is meaningless without task/microtask/frame semantics
fe-16–fe-17 Server state & racesfe-01 (async ordering), fe-07 (HTTP caching)You'll build a cache with no invalidation theory and races you can't name
fe-21 Rendering architecturesfe-09 (render/commit), fe-07 (caching), fe-12 (streaming)Hydration cost and streaming behaviour are unexplainable otherwise
fe-24 Accessibilityfe-04 (semantic HTML)ARIA-first a11y produces compliant-looking, unusable UI
fe-30 Large-scale UIfe-14 (algorithms), fe-22 (perf method), fe-28 (workers)Virtualization without measurement is cargo cult
fe-37 Org quality policyfe-31–fe-35You cannot set a policy over test types you haven't paid the cost of
fe-41 Microfrontendsfe-38, fe-39, fe-40Microfrontends are a consequence of build + design-system + org constraints
fe-43 Observabilityfe-22You'll instrument metrics you can't interpret
fe-49 Migrationsfe-46, fe-47Migration without a decision record is a rewrite with better branding
Capstone 5/6Phases 1–6These are integration exercises; there is nothing to integrate otherwise

Explicitly deferred (common temptation to start early)

  • Microfrontends (§30) — the most requested, least ready topic. Deferred to Phase 6 on purpose.
  • Design systems (§26) — feels like a Phase 1 topic. It isn't; it needs a11y, CSS architecture, versioning and governance to be real.
  • State machines / DDD / clean architecture (§7) — Phase 3, and taught with its overengineering critique attached.

5. Failure case-study schedule

The 12 incidents from the spec, assigned to the earliest phase that gives you the tools to solve them honestly. Each runs under the spec's 7-question protocol.

IncidentPhaseAnchor module
Rendering loop freezes browser1fe-01
Memory leak after hours1fe-02
Stale response overwrites newer data3fe-17
Global rerenders from context misuse2fe-11
Hydration mismatch3fe-21
CSS bundle explosion4fe-23
Accessibility regression4fe-24
Cache poisoning4fe-25
Flaky E2E suite5fe-33
Design-system upgrade breaks many apps6fe-38
Dependency compromise6fe-42
Poor observability hides an outage6fe-43

Phase 7 re-runs all twelve, cold, in fe-51.


6. Promotion gates

You do not advance a phase by finishing modules. You advance by clearing the gate.

GateCriterion
1 → 2Predict execution order and rendering timing of unfamiliar async code without running it. Find a memory leak from a heap snapshot diff. Read a network waterfall and name the bottleneck class.
2 → 3Explain any re-render in a real app from evidence. Argue VDOM vs signals with measured data and stated limits of that data.
3 → 4Ship a feature that survives the full unhappy-path matrix. Produce a state-ownership diagram a reviewer can attack.
4 → 5Defend a performance claim quantitatively against a stated budget. Pass a keyboard-and-screen-reader review with no automated tooling.
5 → 6Write a test policy that states, explicitly, what it does not give confidence in.
6 → 7Own a platform decision with a documented blast radius and reversibility analysis.
7 → doneProduce a multi-year strategy that another Principal engineer cannot trivially dismantle.

Learning Log

Continuity file. Append-only in spirit; prune only when something is genuinely resolved. Four sections: struggles, open questions, source readings, architecture decisions & revisits.


1. Concepts struggled with

Record the shape of the confusion, not just the topic. "Didn't get microtasks" is useless in three months. "Believed await yields to the event loop the way setTimeout does" is a diagnosis.

DateModuleConceptNature of the confusionResolved?How
2026-08-10fe-01Microtask checkpoint placementNatural but wrong model: "checkpoint runs at the end of each task." Predicts interleaving for setTimeout(() => el.click()), which does not happen.yesCorrect rule is stack-emptiness. Measured 4 dispatch paths.
2026-08-11fe-02Leak detection statisticsAssumed a clean workload would show R² near zero. It measured 0.742 — short series make noise look correlated. Slope alone and R² alone are both insufficient.yesReport slope AND linearity, plus a control run.
2026-08-11fe-02Harness isolationRan three listener variants on one page; variant 1's leak stayed alive through variants 2 and 3, so all three reported 300 detached nodes. JS-heap column stayed correct, making the table look partly plausible.yesFresh page per variant. Same class as fe-01's realm reuse.
2026-08-10fe-01Measurement trustTwo harness bugs produced clean, plausible, wrong data (realm reuse; per-event vs per-interaction INP).yesHarness now asserts liveness per trial; INP grouped by interactionId.

2. Open questions

Questions raised but not yet answered. A question that survives three modules is a signal — either it's genuinely hard, or the mental model underneath it is wrong.

#Raised inQuestionStatusAnswer / where it got resolved
1fe-03Why does PACKED_DOUBLE sum faster than PACKED_SMI (0.60x)? Accumulator-overflow hypothesis was tested and rejected (constraining values to i % 100 kept the sum in SMI range; ordering unchanged). Leading remaining hypothesis: per-add SMI overflow checks.open — deliberately not pursuedMagnitude is ~0.6ns/element. Settle with --print-opt-code if ever relevant.

3. Source-code readings

Per the spec's code-reading requirement and browser-framework-internals.md §44 (Source-Code Reading Ladder). Log what you read, what you were looking for, and what surprised you. The surprise column is the valuable one.

DateRepo / fileLooking forWhat I foundWhat surprised me
2026-08-10WHATWG HTML §8.1.7 (event loop)when a microtask checkpoint runs"when the JS execution context stack becomes empty" — not "at end of task"setTimeout(() => el.click()) is its own task yet still shows no checkpoint between listeners. The discriminator is stack emptiness, not task boundary.
2026-08-10Chrome 149, measuredrAF vs setTimeout(0) orderingnot a property of the primitives at allInput dispatch sits immediately before the rendering steps, so rAF is ~0ms away from a click handler and a whole task away from a plain script. Order flips 40/40 either way.

Verification log (browsers move; re-check anything older than ~6 months)

DateClaimVerdict
2026-08-11Flex/grid are slow; absolute positioning is fastFalse, measured — 1.4x spread across six layout modes over 20,000 items. Absolute was SLOWEST (75.5ms, 2.4x style cost from inline left/top); flex was fastest on relayout (7.3ms).
2026-08-11Container queries are expensiveFalse, measured — +1.4ms layout / +3.0ms style over 8,000 elements. The real cost is the containment constraint (container-type: inline-size implies size containment), not milliseconds.
2026-08-11Large DOM is inherently slow to renderFalse, measured — identical 261,011-node DOM took 401.9ms initial layout with no containment and 7.9ms with content-visibility: auto (−98%).
2026-08-11contain speeds up initial renderingFalse, measuredcontain: strict gave 398.4ms vs 401.9ms baseline. It bounds propagation (−80% on the update path), it does not skip rendering. Different tool, different problem.
2026-08-11Specificity decides the cascadeIncomplete, measured — layer order is evaluated first: 0,1,0 in a later layer beat 1,1,0 in an earlier one. Unlayered styles form an implicit final layer. Author !important inverts layer order.
2026-08-11minlength/maxlength apply to any valueFalse, measured — gated on the HTML spec's dirty value flag. el.value='short' reports valid; typing short reports tooShort. Tests that set .value pass while the real form rejects. Same class as fe-01's .click() finding.
2026-08-11Semantic HTML is more verbose than divsFalse, measured — div soup was 1.38x the markup of semantic HTML with 0 landmarks/headings/controls; div+ARIA 1.69x.
2026-08-11Property order is the main hidden-class hazardFalse, measured — alternating property order 1.14x; delete 11.93x. The folklore everyone repeats is minor; the practice everyone tolerates is the expensive one.
2026-08-11Megamorphic access is a performance crisisOverstated, measured — 1.9x ratio but only 2.9ns absolute. One forced layout = 1,448 megamorphic reads; one 200KB JSON.parse = 136,552.
2026-08-11V8 closures retain the whole Context, not just referenced variablesConfirmed Chrome 149 — an unused, never-called sibling closure caused 38.17MB retention vs 0.01MB. 5744x. V8 implementation detail, not a language guarantee.
2026-08-11performance.memory / Runtime.getHeapUsage can detect DOM leaksFalse, measured — freeing 507.8KB of detached DOM moved the JS heap by 0.10MB. DOM is in Blink's C++ heap.
2026-08-11Heap snapshots expose a per-node detachedness field (0/1/2)Confirmed Chrome 149 — exact detached counts without name-prefix matching.
2026-08-10await null costs 1 microtask tickConfirmed Chrome 149 — A1 P1 A2 P2 A3 P3. The widely-cited "3 ticks" is pre-2019 and stale.
2026-08-10Microtask chunking creates rendering opportunitiesFalse, measured — 0 rAF frames over 400 ms vs 48 for MessageChannel, identical structure.
2026-08-10scheduler.yield() available in ChromeConfirmed 149; same INP as MessageChannel but 3.4× wall clock in the 200k-row lab.
2026-08-10Workers reduce main-thread costConditional — naive worker was the worst strategy measured (1502 ms structured clone). True only when the data boundary is designed.

4. Architecture decisions & revisit queue

ADRs live in decisions/. This table is the index plus the revisit queue.

Decisions made

#DateDecisionReversibilityLink

Revisit queue

Topics marked revisit in PROGRESS.md, with the reason and the trigger for revisiting.

ModuleWhy flaggedRevisit trigger

5. Heuristics under test

The spec lists 10 Principal heuristics and requires counterexamples and limitations for each. Fill these in as you earn them — a heuristic with no counterexample is a slogan you haven't stress-tested.

HeuristicCounterexample foundLimitation
Prefer platform primitives when the browser already solves the problem.scheduler.yield() is the platform primitive and gave 3.4× the wall clock of a hand-rolled MessageChannel yield at identical INP (fe-01 lab)."Prefer" ≠ "always": the primitive optimises the common case (responsiveness), and you pay when your case differs (throughput).
Performance problems are often scheduling problems.Lab s5: pure serialisation cost (1502 ms structured clone), no scheduling error — the fix was a data-boundary decision, not a yield strategy.Says nothing about where the cost is. s2 and s5 had near-identical INP from completely different causes.
Move state to the lowest layer that actually owns it.
Optimize critical user journeys, not vanity benchmarks.Phase 1 produced three measured folklore corrections (fe-03 delete vs property order; fe-04 div soup 1.38x larger than semantic; fe-06 layout modes 1.4x with "fast" mode slowest). The reflex to build: when advice is repeated confidently and cheaply, measure before designing around it. Each had a real effect somewhere, misremembered as a general rule.
Every abstraction creates a future migration.
Test observable behavior, not implementation details.
E2E tests protect critical user journeys rather than duplicating unit tests.
Make invalid states difficult to represent.Applies to lifecycle, not just data: AbortController at registration beats removeEventListener because the teardown handle cannot be forgotten separately (fe-02). An anonymous listener is unrepairable after the fact.
Accessibility is architecture, not polish.
Performance problems are often scheduling problems.
Frontend architecture frequently reflects organizational architecture.

Frontend Track — Progress

Track: Frontend Principal Engineering (frontend-principal-engineering.md). The PROGRESS.md at the repo root tracks the other track (browser-framework-internals.md). They run independently; this file never claims progress on that one. Cross-track hooks are listed in curriculum-map.md §2.

Current phase: 1 — Platform Substrate Current module: fe-07 — Networking (next). fe-01–fe-06 built and measured. Status: in progress — concept + mechanics verified empirically; lab strategies measured (see measured-results.md). Outstanding: learner-run failure lab (F1–F3) + Principal Review questions.

See curriculum-map.md for the full map, dependency graph and phase plan. See learning-log.md for struggles, open questions, source readings and the revisit queue. See decisions/ for ADRs written during the program.

Status values: todo · in progress · lab done · complete · revisit


Phase 1 — Platform Substrate (Foundation → Senior)

#ModuleStatusLabFailure labNotes
fe-01Execution model: event loop, tasks/microtasks, rendering pipelinein progress☑ measured☑ seededmodule
fe-02Memory model: closures, GC, retention, leak taxonomyin progress☑ measured☑ seededmodule · results
fe-03Object shapes, hidden classes, ICs, JIT — and when it doesn't matterin progress☑ measuredn/amodule · results
fe-04HTML as a platform: semantics, forms, dialog/popover, web componentsin progress☑ measuredmodule · results
fe-05CSS: cascade, layers, custom properties, containmentin progress☑ measuredmodule · results
fe-06Layout: flex, grid, subgrid, container queries, intrinsic sizingin progress☑ measuredmodule · results
fe-07Networking: HTTP/1.1→3, caching, ETags, CDN, prioritiestodo
fe-08TypeScript as a contract systemtodo
Capstone 1 (Foundation)todo

Gate 1→2: predict execution order + rendering timing of unfamiliar async code unaided · find a leak from a heap-snapshot diff · read a waterfall and name the bottleneck class.


Phase 2 — Framework Mechanics (Senior)

#ModuleStatus
fe-09Component model, reconciliation, Fiber, render vs committodo
fe-10Hooks mechanics, stale closures, the useEffect pathologytodo
fe-11Memoization economicstodo
fe-12Concurrent rendering, transitions, Suspense as schedulingtodo
fe-13Reactivity models compared (Vue/Svelte/Solid/WC)todo
fe-14UI algorithms: diff, LRU, tries, intervals, virtualizationtodo

Phase 3 — Architecture & Data (Senior → Staff)

#ModuleStatus
fe-15State taxonomytodo
fe-16Server state ≠ client state; query caches & invalidationtodo
fe-17Races, cancellation, idempotency, optimistic update + rollbacktodo
fe-18API integration as a distributed-systems problemtodo
fe-19Application architecture — and its overengineering critiquetodo
fe-20Designing for the unhappy pathtodo
fe-21Rendering architectures: CSR/SSR/SSG/ISR/streaming/islands/RSCtodo
Capstone 2 (Intermediate)todo

Phase 4 — Quality Attributes (Staff)

#ModuleStatus
fe-22Performance engineering & budgetstodo
fe-23Loading architecturetodo
fe-24Accessibility as engineeringtodo
fe-25Frontend securitytodo
fe-26i18n / l10ntodo
fe-27Mobile web & PWAtodo
fe-28Workers & parallelismtodo
fe-29WebAssemblytodo
fe-30Large-scale UI performancetodo
Capstone 3 (Senior)todo

Phase 5 — Quality Engineering (Staff)

#ModuleStatus
fe-31Test strategy economicstodo
fe-32UI testingtodo
fe-33E2E & flaky-test preventiontodo
fe-34Integration & contract testingtodo
fe-35Visual regressiontodo
fe-36AI-assisted QAtodo
fe-37Principal quality engineeringtodo

Phase 6 — Platform & Delivery (Staff → Principal)

#ModuleStatus
fe-38Design systemstodo
fe-39Build toolingtodo
fe-40Monorepostodo
fe-41Microfrontends (critically)todo
fe-42Frontend platform engineeringtodo
fe-43Observabilitytodo
fe-44Reliabilitytodo
fe-45CI/CD & release engineeringtodo
Capstone 4 (Staff)todo

Phase 7 — Principal Judgment (Principal → Distinguished)

#ModuleStatus
fe-46ADRstodo
fe-47Technical decision-makingtodo
fe-48Technical debttodo
fe-49Migrationstodo
fe-50Principal skillstodo
fe-51Failure case-study gauntlet (all 12, cold)todo
Capstone 5 (Principal)todo
Capstone 6 (Distinguished/SME)todo

Failure case studies

IncidentPhaseStatus
Rendering loop freezes browser1todo
Memory leak after hours1todo
Global rerenders from context misuse2todo
Stale response overwrites newer data3todo
Hydration mismatch3todo
CSS bundle explosion4todo
Accessibility regression4todo
Cache poisoning4todo
Flaky E2E suite5todo
Design-system upgrade breaks many apps6todo
Dependency compromise6todo
Poor observability hides an outage6todo

Concepts — Execution Model, Scheduling & the Rendering Pipeline

Phase 1 · Platform substrate · Specification areas §1 (event loop, microtasks, macrotasks, promises, async/await), §2 (browser scheduling, rendering pipeline). Gates §11 (performance), §5 (concurrent rendering), §8/§9 (server state and races), §31 (rendering architectures).

1. What is it

The execution model is the set of rules determining when JavaScript runs, when the browser is permitted to draw, and in what order queued work is serviced. It has three components:

  • The event loop — a cooperative scheduler that services one task at a time, drains a microtask queue between tasks, and renders only at permitted points.
  • The rendering pipeline — the fixed sequence (requestAnimationFrame callbacks → observer delivery → style → layout → paint → composite) that converts DOM and CSSOM state into pixels.
  • The scheduling policy — how the browser prioritises competing work: input over compositor over default over idle, with dynamic policy changes under load.

These are not three subjects. They are one mechanism, and nearly every "performance problem", "race condition", and "why didn't it re-render" question in frontend engineering is a question about it.

2. Why it matters

Interaction to Next Paint is a scheduling metric. INP decomposes into input delay + processing + presentation. Two of the three terms are pure scheduling, and each has a different fix. A team optimising bundle size to fix an input-delay problem will work for a quarter and move nothing. Redirecting that work in one conversation requires this model.

Async correctness is an ordering claim. "Stale response overwrites newer data" — one of the twelve failure case studies in the specification — is the statement completion order is not issue order. Every fix (cancellation, sequence numbers, render-time guards) is a different answer to "which ordering guarantee do we actually need?" You cannot review that fix without knowing which guarantees the platform provides and which the framework invented.

Modern rendering architecture is applied scheduling. Concurrent rendering, transitions, Suspense, streaming SSR, partial hydration, islands, and Server Components are all strategies for breaking work into more, smaller tasks — or eliminating tasks entirely. Evaluating whether any of them is worth its complexity is impossible without the substrate. Teams routinely adopt concurrent features, observe no INP improvement, and conclude the features do not work; the actual cause is expensive work in an effect, which does not yield.

It is the arbitration a Principal owns. "Is this a network problem, a rendering problem, or a main-thread problem?" Senior engineers own their component. You own that answer across teams, and getting it wrong sends several engineers in the wrong direction for a sprint.

3. How it works

+==================================================================+
|  THE EVENT LOOP  (one main thread, cooperative, no preemption)   |
+==================================================================+

  while (browser is alive) {

    +----------------------------------------------------------+
    | 1. PICK ONE TASK                                          |
    |    from ONE of several prioritised task queues:           |
    |      input > compositor > default > best-effort/idle      |
    |    There is NO single global FIFO.                        |
    +----------------------------------------------------------+
                              |
    +----------------------------------------------------------+
    | 2. RUN IT TO COMPLETION                                   |
    |    No preemption. A 400ms task = 400ms frozen UI.         |
    |    No framework can change this from inside the task.     |
    +----------------------------------------------------------+
                              |
    +----------------------------------------------------------+
    | 3. DRAIN THE MICROTASK QUEUE — EXHAUSTIVELY               |
    |    including microtasks enqueued DURING the drain.        |
    |    Rule: runs whenever the JS execution context stack     |
    |          becomes empty (NOT merely "at end of task").     |
    |    An unbounded microtask chain never exits this step.    |
    +----------------------------------------------------------+
                              |
    +----------------------------------------------------------+
    | 4. IF THIS IS A RENDERING OPPORTUNITY:                    |
    |                                                           |
    |      requestAnimationFrame callbacks   <- BEFORE layout   |
    |               |                                           |
    |      ResizeObserver / IntersectionObserver delivery       |
    |               |            (may loop; deliberately drops  |
    |               |             rather than hanging)          |
    |      style  ->  layout  ->  paint  ->  composite          |
    |                                                           |
    |    ~16.7ms apart at 60Hz, ~8.3ms at 120Hz                 |
    +----------------------------------------------------------+
                              |
    +----------------------------------------------------------+
    | 5. IF TIME REMAINS BEFORE NEXT FRAME:                     |
    |    requestIdleCallback callbacks                          |
    +----------------------------------------------------------+
  }

The asymmetry that does all the work

Macrotask (setTimeout, events, MessageChannel)Microtask (.then, queueMicrotask, await)
Can the browser render after it?YesNo — not until the queue is empty
Unbounded chain starves rendering?No — browser renders between themYes — permanent freeze, no error, no crash
Consistency guaranteeWeak; state may change between themStrong; nothing interleaves
Correct usechunking work, yielding to the userbatching state, maintaining invariants

An infinite setTimeout loop produces a hot CPU and a responsive page. An infinite queueMicrotask loop produces a tab that must be killed. Microtasks are not a queue you yield to; they are a hole you can fall into.

Measured (src/run-starvation.mjs, 400ms of work in 5ms slices, identical structure):

Yield primitiverAF frames renderedwall clock
await Promise.resolve()0401 ms
MessageChannel postMessage48403 ms

Where INP comes from

INP = input delay        (a task was already running when the user acted)
    + processing time    (your listeners)
    + presentation delay (style/layout/paint/composite to next frame)
Dominant termReal causeFix class
Input delayLong task already running, often unrelated to the interactionChunk/yield elsewhere in the app
ProcessingThe handler does too muchAcknowledge visually now, defer the rest past a task boundary
PresentationHuge DOM, expensive style recalc, forced sync layout, large paintContainment, content-visibility, fewer invalidated elements, compositor-friendly properties

4. Core terminology

TermDefinition
Task (macrotask)A unit of work the event loop services from a task queue; runs to completion
Task source / task queueThe spec's grouping of tasks; the browser chooses which queue to service — not global FIFO
MicrotaskWork run at a microtask checkpoint; .then, queueMicrotask, await continuations
Microtask checkpointPoint where the queue is drained exhaustively — when the JS execution context stack becomes empty
Rendering opportunityA point between tasks where the browser may run "update the rendering"
Update the renderingThe fixed spec sequence: rAF → observers → style → layout → paint → composite
Long taskA task ≥ 50 ms; observable via PerformanceObserver type longtask, with poor attribution
LoAFLong Animation Frames — supersedes long tasks; reports blockingDuration, renderStart, per-script attribution
TBTTotal Blocking Time — sum of (taskDuration − 50ms) over long tasks
INPInteraction to Next Paint; per-interaction, the longest event duration in an interactionId group
interactionIdGroups the events of one interaction (keydown+keypress+keyup) in Event Timing
Forced synchronous layoutReading a geometric property after a style write, forcing style+layout mid-task
Compositor threadOff-main-thread scroll and transform/opacity animation; unblocked by main-thread jank
Passive listener{passive:true}; promises no preventDefault(), letting scroll stay off the main thread
scheduler.yield()Yields with continuation priority — resumes ahead of newly-arrived low-priority work
scheduler.postTask()Explicit user-blocking/user-visible/background priorities with AbortSignal
Structured cloneSerialisation across a worker boundary; synchronous main-thread cost, proportional to payload

5. Mental models

The one sentence. Anything between two task boundaries is invisible to the user and blocking their input. Every optimisation here is a variation on: make the gaps between task boundaries smaller.

Three clocks, never conflated.

  1. Microtask boundary — consistency, no rendering, ~free.
  2. Task boundary — the browser may render and process input; costs scheduling latency.
  3. Frame boundary — ~16.7 ms at 60 Hz; the only clock the user perceives. Much confused performance work optimises clock 1 while the user experiences clock 3.

rAF is frame-relative, not fast. requestAnimationFrame is not "quicker than setTimeout". Its latency is your distance to the next rendering opportunity, and that distance is determined by what scheduled you. Measured: from inside a click handler, rAF fires at +2–7 ms and setTimeout(0) at +2.1–7.0 ms (rAF wins); from a plain script, rAF fires at +3–11 ms and setTimeout(0) at +0.0 ms (the timer wins). Input dispatch is scheduled immediately before the rendering steps — that is the entire explanation.

async does not mean "in the background". It is main-thread, cooperative, microtask-scheduled. An async function awaiting 200 already-resolved promises while doing work between them is one long task, and nothing renders during it.

Workers move computation, not data. The boundary is the cost. Structured clone is synchronous main-thread work proportional to payload size. Measured: an identical worker filtering 200k rows cost 1502 ms of clone when the dataset crossed per keystroke, and 4 ms when the dataset lived in the worker and only the query crossed. Same worker, same algorithm, 376× difference from one architectural decision.

6. Common misconceptions

  1. "Microtasks yield to the event loop." They do not. The microtask queue must be empty before the browser may reach the rendering steps; a self-enqueueing chain never empties. Measured: microtask "chunking" produced 0 rendered frames, 8 long tasks, 865 ms TBT, 3× the INP of the naive baseline, and 5.5× the wall clock of task-based chunking. It reads like the careful version in code review and is a large regression.

  2. "A microtask checkpoint runs at the end of each task." Close enough to be dangerous. The rule is when the JS execution context stack becomes empty. Measured across four dispatch paths — real click interleaves microtasks between listeners (L1 m1 L2 m2 L3 m3); el.click(), dispatchEvent, and el.click() inside a setTimeout all do not (L1 L2 L3 m1 m2 m3). The last case is its own task, which rules out "task vs script" as the discriminator. This is why tests driving UI with .click()/fireEvent have different semantics from real users.

  3. "await costs three microtask ticks." Stale — that was pre-2019. Measured on Chrome 149: exactly one tick (sync A1 P1 A2 P2 A3 P3).

  4. "setTimeout(fn, 0) fixes layout timing." It works by accident and breaks under load. You did not choose a phase of the frame cycle; you chose a coin flip whose bias depends on scheduling pressure. If you need to run before layout, that is rAF; after layout, ResizeObserver or double-rAF.

  5. "Moving work to a worker makes it faster." Only if the data boundary is designed. A naive worker was the worst of seven strategies measured — worse than doing nothing on the main thread — because per-operation structured clone exceeded the computation being offloaded.

  6. "Platform primitives are always preferable." A live counterexample to the specification's own heuristic: scheduler.yield() and a hand-rolled MessageChannel yield produced identical INP (32 ms), but the platform primitive took 3.4× the wall clock (1562 ms vs 466 ms), because its continuations are scheduled behind rendering. Correct default, real cost.

  7. "Long tasks tell you what was slow." longtask attribution is famously poor (often just the containing frame). LoAF reports blockingDuration, renderStart, and a scripts[] array with per-script invoker and duration. Prefer LoAF where available.

7. Interview talking points

  • "INP decomposes into input delay, processing, and presentation, and those are three different bugs with three different fixes. When a team shows me one INP number I ask which term dominates before I let anyone start optimising — in our own benchmark the dominant term moved between proc, pres, and delay across strategies solving the same problem."
  • "Chunking work with await Promise.resolve() doesn't chunk anything. The microtask queue has to drain to empty before the browser can render, so a self-enqueueing chain never yields. We measured it: zero frames rendered over 400 ms, versus 48 for the same loop yielding via MessageChannel."
  • "Whether to move work to a worker is a data-ownership decision, not a compute decision. Structured clone is synchronous main-thread cost. We measured a 376× difference between posting the dataset per keystroke and keeping it resident in the worker — same worker, same algorithm."
  • "React's scheduler yields with MessageChannel rather than microtasks, deliberately: a microtask-based scheduler would have better total throughput and be useless for responsiveness. That design choice is the tasks-versus-microtasks asymmetry."
  • "Layout thrashing is invisible in code review because the bug fits on one line — e.style.width = e.offsetWidth + 1 + 'px' is 98× slower across 800 elements than batching the reads and writes, with an identical final DOM."
  • "requestAnimationFrame isn't faster than setTimeout; it's frame-relative. Its latency is your distance to the next rendering opportunity, which is set by whatever scheduled you. Input dispatch sits right before the rendering steps, which is why rAF beats a timer inside a click handler and loses to it in a plain script."

8. Connections to other modules

  • fe-02-memory-model (next) — the other Phase-1 module gated only by this one. Retention and leaks are about what survives across tasks; this module establishes what a task is.
  • fe-11-performance-engineering — hard prerequisite. Core Web Vitals, budgets, and quantitative claims are unreadable without INP decomposition.
  • fe-12-concurrent-rendering — Suspense and transitions are scheduling abstractions; this module is the substrate they abstract over.
  • fe-16 / fe-17 (server state, races) — "completion order is not issue order" starts here; cancellation, sequence numbers and render-time guards are the architectural answers.
  • fe-21-rendering-architectures — hydration is the canonical long task. Partial hydration, islands, and RSC are all task-decomposition strategies.
  • fe-28-workers-parallelism — the worker boundary economics measured here (s5 vs s6) generalise to the whole offload decision.
  • browser-framework-internals.md §16 (Browser Scheduling), §5 (Rendering Pipeline) — cross only when you need to know why a queue priority exists, not merely that it does.

References — Execution Model, Scheduling & Rendering

Primary sources first. Where a secondary source is listed it is because it is unusually good, not because it is convenient.

Specifications (normative)

Explanatory (high quality)

Implementation / source

  • Chromium: docs/ on the renderer main-thread scheduler; blink::scheduler::MainThreadSchedulerImpl — task queue priorities and policy transitions. Guided path in browser-framework-internals.md §16.
  • React: packages/scheduler/src/forks/Scheduler.jsschedulePerformWorkUntilDeadline; why MessageChannel over microtasks and setTimeout.
  • web-vitals librarysrc/onINP.ts is the reference implementation of interaction grouping. Read it if you intend to compute INP yourself; the grouping subtlety in docs/analysis.md is handled there.

Security-adjacent (why measurement is constrained)

Deliberately excluded

Generic "JavaScript Event Loop Explained" posts. They are overwhelmingly derived from one another and a substantial fraction still assert the pre-2019 three-tick await behaviour, which Checkpoint 3 in docs/verification.md disproves in about four seconds.

Analysis

Deeper treatment of the decisions this module surfaces. Numbers cited are from measured-results.md unless stated.

Yield primitives compared

PrimitiveCreates a rendering opportunity?Priority on resumeCostUse when
await Promise.resolve() / queueMicrotaskNon/a~freebatching state; never for chunking
setTimeout(fn, 0)Yesback of the queue; clamped (≥ 1 ms, 4 ms when nested ≥ 5 deep)high latencylegacy fallback only
MessageChannel.postMessageYesordinary task, no clampinglowthe reliable hand-rolled yield; what React's scheduler uses
scheduler.postTask({priority})Yesexplicit user-blocking / user-visible / background, AbortSignallowgenuinely competing work classes
scheduler.yield()Yescontinuation priority — ahead of new low-priority work, behind inputlow per call, but resumes behind renderingmid-task yielding where responsiveness dominates

The setTimeout clamping detail matters: nested timers beyond depth 5 are clamped to 4 ms, so a setTimeout-based chunker silently caps at ~250 slices per second regardless of slice size. That alone makes it unsuitable for time-sliced work, before considering queue position.

Why isInputPending() is a trap

isInputPending() yields only when input is actually waiting, which maximises throughput. But rendering is not input. A loop that yields only for pending input can run for seconds without letting a frame through — responsive to clicks, visually frozen. Prefer a time-slice budget (yield every ~5 ms of work) which is insensitive to whether anyone happens to be clicking.

The throughput cost of responsiveness

Measured, 200k rows at 6× throttle: s3 (MessageChannel) and s4 (scheduler.yield) produce identical INP (32 ms, zero long tasks). Their filter wall-clock differs by 1.5–3.5× across runs, s4 always slower — its continuations are scheduled behind rendering by design.

This produces a predictable and legitimate conflict. An engineer will show a benchmark where yielding is 30 % slower and propose reverting. They are not wrong about their number. The resolution is to establish which number represents the user:

  • A human is waiting for incremental feedback → responsiveness wins. Total time is invisible; unresponsiveness is not.
  • A human is waiting for the completed result and nothing else → total time is the experience. Yielding makes it worse. Show a determinate progress indicator and do not yield.
  • No human is waiting (background sync, prefetch, export) → total time wins outright, and you should be yielding only enough to not block interactions that might arrive.

The failure mode to avoid is deciding this by ideology in either direction. "Always yield" and "never yield" are both wrong; the question is who is waiting for what.

Worker offload economics

The naive worker (s5) was the worst of seven strategies — worse than doing the work synchronously on the main thread. It moved 200k rows across the boundary per keystroke: 1516 ms of structured clone versus 34 ms of actual compute. The resident worker (s6) does the same work with a 4 ms boundary cost and wins every column.

The decision rule that follows:

worker is worth it  ⟺  compute_cost  >  boundary_cost + scheduling_latency

where boundary_cost ≈ structured clone of everything crossing, EACH WAY, EACH CALL

Practical consequences:

  • Ownership, not offload. Ask "where does this data live?" before "should this be in a worker?" If the dataset crosses per operation, you have built a slower program with more moving parts and a second failure surface.
  • Transferables and SharedArrayBuffer change the arithmetic. ArrayBuffer transfer is O(1) rather than O(size), which is why binary-shaped workloads (image processing, parsing, columnar data) suit workers far better than object graphs do. SharedArrayBuffer requires cross-origin isolation (COOP + COEP) — a deployment constraint, not a code change.
  • Structured clone cannot carry functions, DOM nodes, class identity, or cycles-with-identity. Payloads usually need reshaping, and that reshaping is itself main-thread cost.
  • Latency floor. Even a trivial round-trip costs a task boundary each way. For sub-millisecond work, a worker is pure loss.

INP decomposition as a routing table

The benchmark produced at least three different dominant terms across seven implementations of one feature. This is the module's most transferable result, because organisations aggregate INP into a single dashboard number and then debate a single fix.

DominantThe real questionWhere the fix usually lives
input delaywhat was already running?somewhere else entirely — often a different team's code
processingwhy is the handler doing this much?split the handler; defer past a task boundary
presentationhow much are we invalidating?DOM volume, containment, content-visibility, compositor properties

A Principal-level intervention here is usually organisational rather than technical: require the decomposition alongside the number in any performance report, so three teams with the same INP value stop being treated as having the same problem.

Harness failure modes

Both bugs found while building this module produced clean, plausible, entirely fake data. This is the characteristic failure of performance work and is worth internalising more than any individual result.

1. Realm reuse across trials. page.setContent() reuses the JavaScript realm. A top-level const b in trial 2 collides with trial 1's binding, the script throws Identifier 'b' has already been declared, no listeners attach, and the log reads empty — indistinguishable from "the event did not fire." 39 of 40 trials were silently void while the summary table looked orderly. Fixes: IIFE-wrap every page script; assert harness liveness before each trial (loadTrial() throws if window.__log is missing).

2. Per-event instead of per-interaction INP. One keystroke emits keydown, keypress and keyup sharing an interactionId. Taking the longest single event reports processing: 0 even for a fully synchronous handler, because the longest event is keydown, which does no work. Taking the span from first start to last end reports ~250 ms for everything, because it swallows the inter-keystroke gap. Correct: group by interactionId; latency is the longest event's duration; processing is summed across the group.

Both bugs pointed in the right direction while being quantitatively meaningless — the most dangerous kind of wrong, because the conclusion survives review even though the number does not.

Rule: a measurement harness needs its own failing test before you trust a single number from it. Deliberately break the thing being measured and confirm the harness notices.

What breaks at scale

  • Third-party scripts you cannot modify. A vendor SDK responsible for the majority of long tasks is a scheduling problem with no code fix available to you. Options, in ascending blast radius: load it lazily after the critical interaction path; move it behind a facade that loads on intent; sandbox it in a worker or iframe; negotiate a contractual performance requirement; remove it. The technical work is the easy part.
  • Yielding interacts badly with transactional state. Every yield is a point where the DOM, application state, and the user's intent can all change. A chunked operation that mutates shared state across yields needs the same discipline as a concurrent program: either a consistent snapshot taken before the first yield, or explicit revalidation after each one. This is where naive chunking introduces correctness bugs while fixing performance ones.
  • Frameworks yield; your effects do not. React can time-slice rendering. It cannot time-slice 200 ms of parsing inside useEffect. Teams adopt concurrent features, observe no INP change, and conclude the features do not work. The measurement that settles it is the dominant column: framework-level yielding moves pres, and does nothing for proc in an effect.
  • Timer coarsening and cross-origin isolation. performance.now() is deliberately coarsened and SharedArrayBuffer requires COOP+COEP — both Spectre mitigations. Your measurement precision and your worker architecture are both constrained by a security decision the application cannot opt out of.
  • Background tabs. Timers are throttled aggressively in hidden tabs, rAF stops entirely. Any chunked job that assumes it keeps running when the user switches tabs will silently stall. Use visibilitychange explicitly rather than discovering this from a support ticket.

Execution Guide

Tool versions

ToolVersion usedNotes
Node.js23.11.0any ≥ 18 with global fetch/ESM works
playwright-core1.62.1ships no browsers; see below
Chrome for Testing149.0.7827.55 (arm64)reused from an existing Playwright cache
Python3.13only for python3 -m http.server in the manual labs

playwright-core deliberately ships no browser binaries. src/lib/browser.mjs resolves one in this order:

  1. $CHROME environment variable, if set
  2. newest chromium-NNNN in ~/Library/Caches/ms-playwright (or ~/.cache/ms-playwright)
  3. a system Chrome/Chromium in the usual install locations

If none is found it fails with instructions rather than silently using a different engine.

Version sensitivity. Several findings here are version-dependent — scheduler.yield() availability, LoAF support, and await tick cost have all changed within recent memory. Record the version with any result you keep. docs/measured-results.md states the version used for every number in this module.

Quick start

cd fe-01-execution-model-scheduling/src
npm install                 # playwright-core only, ~1 package
npm run ordering            # experiments A, B, C  (~40s)
npm run starvation          # experiment D          (~5s)
npm run layout              # experiment E          (~5s)
npm run frame-timing        # experiment F          (~10s)
npm run benchmark           # the 7-strategy lab    (~90s)

Or everything in sequence:

npm run all

Manual (browser) labs

Two labs are run by hand in a real browser, because the point is what you observe:

npm run serve               # http://localhost:8080
URLPurpose
/starvation.htmlStep 2 — feel the difference between microtask and task chunking
/lab.htmlStep 4 — implement the strategies yourself (TODO stubs)

web/bugs.js holds the failure lab (F1–F3) and is loaded from the console; see steps/05-failure-lab.md.

Tuning the benchmark

All parameters are environment variables:

ROWS=50000    npm run benchmark    # smaller dataset — find the worker crossover
THROTTLE=1    npm run benchmark    # no CPU throttling (see warning below)
THROTTLE=20   npm run benchmark    # low-end device simulation
REPS=5        npm run benchmark    # more repetitions, tighter medians
QUERY=zulu    npm run benchmark    # different selectivity
TRIALS=100    npm run ordering     # more ordering trials
WORK_MS=2000  npm run starvation   # longer starvation window
COUNT=3000    npm run layout       # more elements to thrash

Throttling is not optional for meaningful results. THROTTLE=1 on a modern laptop produces numbers that describe your laptop, not your users. The default of 6× approximates a mid-tier phone. Every table in this module was produced at 6×.

Reproducibility notes

  • The dataset uses a seeded PRNG, so row contents are identical across runs and machines.
  • Keystrokes are dispatched through CDP as trusted input events, not dispatchEvent — which matters here specifically, because synthetic dispatch changes microtask interleaving (experiment B).
  • The benchmark reports medians of REPS runs. Single runs vary by 30–50 % on the wall-clock columns; the ratios between strategies are the durable finding, not the absolute milliseconds.
  • Headless frame pacing is not real display pacing. Treat the jank column as directional.

Cleaning up

Nothing is installed globally and no state persists outside src/node_modules.

rm -rf src/node_modules

Observation Guide

How to read the evidence — in the scripts' output, and in DevTools on a real application.

Reading the benchmark output

 strat | strategy             | longTasks |  TBT | jank | INP p75 | INP max | dominant | filter | boundary |  wall
 s0    | sync handler         |         0 |    0 |    5 |      56 |      64 |     pres |    222 |        0 |  2098
 s2    | microtask-chunked    |         8 |  836 |    8 |     184 |     184 |     pres |   1192 |        0 |  3067
 s6    | worker, resident     |         0 |    0 |    0 |      24 |      24 |    delay |     79 |        4 |  1838
ColumnWhat it isWhat a bad value means
longTaskstasks ≥ 50 ms during the interaction window> 0 means the main thread was unresponsive for ≥ 50 ms at a stretch
TBTΣ(duration − 50 ms) over long tasksthe amount of unresponsiveness, not just its presence
jankrAF gaps > 32 msframes the user did not get. Directional only in headless
INP p75 / maxinteraction latency, per interaction> 200 ms fails the "good" threshold; > 500 ms is "poor"
dominantlargest of delay/proc/presthe diagnosis. Different values need different fixes
filterwall clock inside the filter, summedthe throughput cost of your yielding strategy
boundaryworker round-trip minus worker computestructured clone + postMessage. The worker tax
walltotal elapsed for the typing runthe number someone will use to argue against you

Read dominant before anything else. It routes you to a fix class:

  • proc → your handler does too much. Split it: acknowledge visually now, defer the rest.
  • pres → DOM volume, style recalc, forced layout, or paint. Look at containment and how many elements you invalidate.
  • delay → something else was running when the user acted. The fix is often nowhere near the interaction you are measuring.

Healthy vs unhealthy signals

Healthy:

  • longTasks: 0, TBT: 0 during interaction
  • INP max under 200 ms; under 100 ms if the interaction is a keystroke
  • boundary under ~20 ms for any worker strategy
  • filter within ~2× of the unchunked baseline — you are paying a little for responsiveness

Unhealthy, and the specific diagnosis:

  • longTasks > 0 with a chunked strategy → your yield is not a task boundary (this is s2)
  • boundary in the hundreds or thousands → you are cloning your dataset per operation (this is s5)
  • filter many times the baseline with no INP improvement → pure overhead; revert
  • INP fine but jank high → the interaction is responsive but an animation is not; look at compositor-vs-main-thread properties
  • Every strategy shows the same dominant term → your workload is not exercising what you think

DevTools: the six things to look at

Run npm run serve, open /lab.html, DevTools → Performance, CPU: 6× slowdown, record while typing.

  1. Main thread flame chart. Find the input event task. Split it into scripting vs rendering (style/layout) vs painting. Most engineers assume scripting dominates; in the 200k-row lab the render half is frequently larger. Check before optimising.

  2. Interactions track. Each interaction appears as a bar with its three phases. This is the dominant column, visually. The whisker before the bar is input delay — if it is long, the cause is outside the handler.

  3. Forced reflow warnings. DevTools flags forced synchronous layout with a red triangle and gives you the causing stack. Induce one deliberately (run-layout.mjs's thrash path) so you know what the marker looks like before you need to find one under pressure.

  4. User Timing track. performance.mark/measure around your filter and render show up as their own lane. Instrument both separately — the point is to find out whether your intuition about the split was right.

  5. LoAF vs longtask. In the console:

    new PerformanceObserver(l => l.getEntries().forEach(e =>
      console.log(e.duration, e.blockingDuration, e.scripts?.map(s => [s.invoker, s.duration]))
    )).observe({ type: 'long-animation-frame', buffered: true });
    

    longtask tells you that something blocked. LoAF tells you which script, plus renderStart and blockingDuration. Prefer LoAF whenever it is available.

  6. Compositor confirmation. With paint flashing / Layers on, confirm a transform animation keeps running during a long task — then change it to animate left and watch it stop. That contrast is the threading model made visible, and it is the fastest way to explain "animate transform, not position" to a team that thinks it is a style preference.

Reading starvation.html by hand

Open /starvation.html and click each button while watching frames drawn.

  • The red square keeps spinning during both runs. This is not a bug. It is a CSS animation on transform, running on the compositor thread, which the blocked main thread cannot stop. It is the single best demonstration in the module that "the page looks alive" and "the page is responsive" are different claims — and why a spinner is a terrible liveness indicator.
  • Try selecting text or scrolling during the microtask run. Scrolling may still work (compositor); text selection will not (main thread).
  • The frames drawn counter is rAF-driven, therefore main-thread-driven, therefore honest.

What these measurements do not tell you

State this explicitly whenever you present numbers from this module:

  • One machine, one browser version. The ratios transfer; the milliseconds do not.
  • Headless frame pacing is not display pacing. The jank column is directional.
  • Synthetic CPU throttling is not a real slow device. It scales CPU but not memory bandwidth, GPU, storage, or thermal behaviour. A real mid-tier phone will be worse in ways 6× does not model.
  • A single filter workload. Selectivity, row size, and render cap all change the balance between filter and render, and therefore which term dominates.
  • No network. Everything here is local compute. Real interactions usually have a request in them, which adds a whole failure surface this module does not touch (that is fe-17/fe-18).

M01 — Measured Results

All numbers produced on Chrome for Testing 149.0.7827.55 (arm64 macOS), driven by playwright-core over CDP. Scripts live in ../src/ (run-ordering.mjs, run-starvation.mjs, run-layout.mjs, run-frame-timing.mjs, run-benchmark.mjs) and are reproducible with npm run all. Reproduce before trusting: browsers move, and several of these numbers are version-sensitive.

Ordering results: 40 trials each, 100 % stable. Lab results: median of 3 runs, 6× CPU throttling via Emulation.setCPUThrottlingRate, 200 000-row dataset, 8 trusted keystrokes (november) at 150 ms intervals.


A. Prediction Challenge 1 — ordering

Dispatch pathObserved orderStability
Real trusted clickA F B C G H D E40/40
b.click() from a scriptA F G B C H E D40/40

Two things move: the microtasks (B, C, H) and D vs E. Different mechanisms.

B. Microtask checkpoint vs. 3 listeners on one element

Dispatch pathResult
Real trusted clickL1 m1 L2 m2 L3 m3 — checkpoint between listeners
el.click() from scriptL1 L2 L3 m1 m2 m3 — no checkpoint between listeners
dispatchEvent(new MouseEvent)L1 L2 L3 m1 m2 m3
el.click() inside setTimeoutL1 L2 L3 m1 m2 m3

The discriminator is not "task vs script" and not "trusted vs synthetic." The setTimeout case is its own task and still shows no interleaving. The rule is the spec's: a microtask checkpoint runs when the JS execution context stack becomes empty. Browser- initiated dispatch has an empty stack between listeners; script-initiated dispatch has your frame still on the stack.

C. await tick cost

sync A1 P1 A2 P2 A3 P3 P4 P5 P6

await null costs exactly one microtask tick — A1 interleaves with P1, A2 with P2. (Pre-2019 this was three ticks; blog posts asserting that are stale.)

D. Microtask "chunking" does not chunk — 400 ms of work, 5 ms slices

Yield primitiverAF frames during the workwall clock
await Promise.resolve()0401 ms
MessageChannel postMessage48403 ms

Identical structure, identical slice budget, same total time. One renders 48 frames; the other renders nothing. This is the tasks-vs-microtasks asymmetry in one table.

E. Forced synchronous layout — 800 elements

PatternTime
Interleaved offsetWidth read → style write, per element98 ms
Batched: read all, then write all1 ms

98× . Same work, same elements, same final DOM. Only the ordering changed.

F. Why D and E swap order

Dispatch pathrAF firedsetTimeout(0) fired
Real click+2.1 / +6.0 / +6.4 / +6.9 ms+2.1 / +6.2 / +6.6 / +7.0 ms
.click() from script+7.0 / +11.4 / +4.6 / +3.3 ms+0.1 / +0.0 / +0.0 / +0.0 ms

Neither primitive changed speed. Input dispatch is scheduled immediately before the rendering steps, so a rAF registered inside a click handler is microseconds from running, while the timer task waits for the frame to finish. A script running at an arbitrary point in the cycle is typically far from the next rendering opportunity, so the timer — already ready — wins easily.

The spec guarantees nothing about D vs E. The observed order is a function of where in the frame cycle the initiating task sits.


Lab — five strategies (+2 controls), 200k rows, 6× CPU throttle

Strategylong tasksTBTjank frames >32msINP p75INP maxdominant termfilterclonewall
s0 sync handler0075656proc22102098
s1 async, unchunked0076464pres22802132
s2 microtask-chunked88658184192pres121903099
s3 MessageChannel, 5 ms0093232delay46601941
s4 scheduler.yield()00173232pres156201925
s5 worker, naive88818184200pres3315023111
s6 worker, resident data0002424proc8041838

Worst-interaction decomposition (ms):

input delayprocessingpresentation
s00340
s112863
s21145183
s3770
s44721
s50156184
s6120

The four results that matter

  1. s2 is worse than doing nothing. Microtask "chunking" produced 8 long tasks, 865 ms of blocking, 3× the INP of the naive baseline, and 5.5× the filter time of s3. It added the full cost of chunking (clock polling, loop restructuring) and bought none of the benefit. A code reviewer who sees "chunked with await" and approves it has shipped a regression that looks like an optimisation.

  2. s5 vs s6: the worker is not the win — the boundary is. Same worker, same algorithm, same compute (33 ms vs 80 ms off-thread). s5 posts 200 000 objects per keystroke: 1502 ms of structured clone, on the main thread, which is exactly what a worker was supposed to avoid. s6 keeps the data resident and posts an 8-byte string: 4 ms. 376× difference in boundary cost, from one architectural decision.

  3. s3 vs s4: same INP, 3.4× the wall clock. Both hit 32 ms INP with zero long tasks. scheduler.yield() took 1562 ms of elapsed filter time against s3's 466 ms, because its continuations are scheduled behind rendering rather than ahead of it. Whether that is a win depends entirely on whether anything is waiting for the result. This is the trade-off from the module made concrete — and note that s4's jank-frame count (17) is the worst of the non-pathological strategies.

  4. The dominant INP term moves between strategies. s0 is proc-bound, s1/s2/s5 are pres-bound, s3 is delay-bound. Three different diagnoses, three different fixes. A team with a single "INP is bad" dashboard cannot tell these apart, and will apply one fix to all of them.


Harness bugs found while building this — keep them

Both produced clean, plausible, entirely fake data. This is the failure mode of performance work.

  1. page.setContent reuses the JS realm. A top-level const b in trial 2 collides with trial 1's, the script throws Identifier 'b' has already been declared, no listeners attach, and the harness reports an empty log — indistinguishable from "the event didn't fire." 39/40 trials were silently void. Fix: IIFE-wrap page scripts, and assert the harness is alive before every trial.
  2. INP is per interaction, not per event. One keystroke emits keydown+keypress+keyup sharing an interactionId. Taking the longest single event reports processing: 0 for a fully synchronous handler, because the longest event is keydown, whose own processing is empty. Taking the span from first start to last end instead reports ~250 ms for everything, because it swallows the inter-keystroke gap. Correct: group by interactionId, latency = longest event's duration, processing = sum across the group.

Rule this establishes: a measurement harness needs its own failing test before you trust a single number it produces. Both bugs above pointed the right direction while being quantitatively meaningless — the most dangerous kind of wrong.

Verification Checkpoints

A checkpoint passes on observed output, not on having read the explanation. Where a range is given, the range is the pass condition — exact milliseconds are machine-specific.

Reference environment: Chrome for Testing 149.0.7827.55, arm64 macOS, 6× CPU throttle.


Checkpoint 1 — Event ordering is stable and differs by dispatch path

npm run ordering
=== A. EVENT ORDERING (40 trials each) ===
  real click    40/40   A F B C G H D E
  .click()      40/40   A F G B C H E D

Pass: both rows are 40/40 (or ≥ 38/40), and the two orders differ. Fail — both identical: your page script is not IIFE-wrapped, or you are dispatching both cases the same way. Fail — any (empty) row: the harness is broken. loadTrial() should have thrown; if it did not, window.__log is being clobbered. Do not interpret any other number until this is green.


Checkpoint 2 — The microtask checkpoint follows stack emptiness

=== B. MICROTASK CHECKPOINT vs 3 LISTENERS ON ONE ELEMENT ===
  real trusted click       : L1 m1 L2 m2 L3 m3
  el.click() from script   : L1 L2 L3 m1 m2 m3
  dispatchEvent from script: L1 L2 L3 m1 m2 m3
  el.click() in setTimeout : L1 L2 L3 m1 m2 m3

Pass: row 1 interleaves; rows 2–4 do not. The row that matters is the fourth. It is a genuine separate task and still does not interleave — which is what rules out "task boundary" as the explanation and leaves stack emptiness as the only one standing.


Checkpoint 3 — await costs one microtask tick

=== C. `await` TICK COST ===
   sync A1 P1 A2 P2 A3 P3 P4 P5 P6

Pass: A1 appears immediately before P1, A2 before P2 — strict interleaving. Fail — A1 appears after P3: three ticks per await. You are on a pre-2019 engine, or reading a transpiled build where async/await was downleveled to generators. Check what your bundler targets before concluding anything about the platform.


Checkpoint 4 — Microtask chunking renders nothing

npm run starvation
  yield primitive              | frames rendered | slices | wall clock
  -----------------------------|-----------------|--------|-----------
  await Promise.resolve()      |               0 |     80 | 400ms
  MessageChannel postMessage   |              49 |     80 | 404ms

Pass: the microtask row is exactly 0 frames; the task row is > 20. Slice counts and wall clock are within a few percent of each other — that equality is the point, because it proves the two runs did the same amount of work. Fail — microtask row > 0: your yield is not actually a microtask (an await on a real promise that resolves from a timer is a task in disguise).


Checkpoint 5 — Forced synchronous layout is catastrophic and invisible

npm run layout
  interleaved read -> write, per element    | 86.2ms
  batched: read all, then write all         |  0.3ms
  287x difference.

Pass: ratio ≥ 50×. Absolute values vary widely by machine; the ratio does not collapse. Fail — ratio near 1: the compiler eliminated your reads, or the elements are not in the layout tree (display:none ancestors make offsetWidth free). Confirm the elements are rendered.


Checkpoint 6 — rAF latency is frame-relative

npm run frame-timing

Pass: in the el.click() in script rows, setTimeout(0) fires at ~+0.0 ms while rAF fires at +3 to +12 ms — a decisive, repeatable gap. In the real trusted click rows, both fire within a fraction of a millisecond of each other and the winner column flips between runs.

The near-tie is the finding, not noise. It shows that inside a click handler the rAF callback and the frame boundary are effectively the same moment, which is why nothing is guaranteed. If you see a stable winner in the real-click rows, check your margins before claiming a rule.


Checkpoint 7 — The strategy benchmark reproduces its shape

npm run benchmark

Pass — all seven rows present, page errors: none, and these relationships hold (absolute values will differ):

RelationshipWhy it must hold
s2.longTasks > 0 and s2.tbt > 500microtask chunking does not chunk
s2.filter ≥ 3× s0.filterchunking overhead paid, no benefit received
s3.longTasks == 0 and s4.longTasks == 0task-based yielding works
s3.inpMax ≈ s4.inpMax (within ~10 ms)both achieve responsiveness
s4.filter > s3.filter, typically 1.5–3.5×scheduler.yield() costs throughput
s5.boundary > 1000naive worker pays structured clone per keystroke
s6.boundary < 20resident worker posts only a query string
s6.inpMax is the lowest of all sevencorrect worker design wins outright
s5.inpMax > s0.inpMaxthe naive worker is worse than no worker at all

Fail — s4 identical to s3 in every column: scheduler.yield() is unavailable and silently fell back. The header line reports this; check it. Fail — s5.boundary near 0: the dataset is too small for clone cost to register. Raise ROWS. Fail — all INP values 0: no interaction was recorded. Event Timing clamps durationThreshold to a 16 ms floor; on a fast machine with a small dataset every interaction falls below it. Raise ROWS or THROTTLE.


Checkpoint 8 — The decomposition table shows different dominant terms

  strat | input delay | processing | presentation
  s0    |           1 |         27 |           55
  s2    |           0 |        157 |          184
  s6    |           1 |          1 |            0

Pass: the dominant term is not the same for every strategy.

This is the checkpoint with the most transfer to real work. Seven implementations of one feature produce at least three different dominant INP terms. A team looking at a single aggregate INP number cannot distinguish them and will apply one fix to three different problems.


Module completion

All eight checkpoints pass, and:

  • steps/05-failure-lab.md F1–F3 diagnosed from evidence before reading the notes
  • F2 reproduced deterministically (forced response ordering), not just observed
  • Three fixes implemented for F2 and ranked, with the server-side-effect case answered
  • Written answers to the Principal Review questions in steps/06-principal-review.md
  • ../fe-00-roadmap/docs/learning-log.md updated: any prediction you got wrong, recorded as the shape of the confusion rather than the topic

Broader Ideas

Where this module's mechanism reappears later in the programme, and what it explains.

Hydration is the canonical long task

Server-rendered HTML paints quickly (good LCP), then hydration attaches handlers and rebuilds client state — classically as one uninterruptible task. The result is a page that looks interactive and is not, which is the single most common cause of a good-LCP/bad-INP profile.

Every mitigation is a task-decomposition strategy, and this module lets you evaluate them from first principles rather than by reputation:

ApproachWhat it does to the task graph
Progressive / selective hydrationsplits one long task into many, prioritised by interaction likelihood
Islandseliminates hydration for subtrees that never needed it
Server Componentsmoves component work off the client entirely; nothing to hydrate
Streaming SSRoverlaps server work with client parsing; does not by itself shorten hydration

The last row is the one teams get wrong: streaming improves time-to-first-byte-of-content and does nothing for hydration cost. If your dominant INP term is proc, streaming will not move it. Picked up properly in fe-21-rendering-architectures.

React's scheduler as a worked example

packages/scheduler/src/forks/Scheduler.js — read schedulePerformWorkUntilDeadline. It prefers MessageChannel over both microtasks and setTimeout, for exactly the reasons measured here: a microtask-based scheduler would have better total throughput and be useless for responsiveness, and setTimeout is clamped and queued behind everything. It is the tasks-versus-microtasks asymmetry, chosen deliberately by people who had to live with the consequences.

Worth reading in source rather than in a blog post, and worth logging in ../fe-00-roadmap/docs/learning-log.md §3. Followed up in fe-09 (Fiber, render vs commit) and fe-12 (concurrent rendering as scheduling).

Debounce is not a race fix

A debounced input reduces the number of in-flight requests. It changes nothing about ordering. If request N returns after request N+1, the user sees results for a query they already replaced — the "stale response overwrites newer data" incident from the specification's case-study list.

The three fixes are not equivalent, and ranking them is the seam into fe-17:

FixGuaranteesFails when
AbortController cancellationrequest is actually cancelled client-sideserver already committed a side effect
Sequence number / last-write-winscorrect renderingwasted work; server still processed both
Render-time guard (compare response to current state)correct rendering, simplestwasted work; no cancellation signal upstream

For a read, any of the three works and the ranking is about efficiency. For a request with a server-side effect, only cancellation is safe, and even then only if the server honours it — which is why idempotency keys exist and why this becomes a distributed-systems conversation in fe-18.

Scheduling as an accessibility concern

Long tasks are not neutral across users. Focus movement queues behind the blocked main thread, so a keyboard user pressing Tab gets no response and — unlike a mouse user — has no hover feedback confirming the app is alive. Screen-reader users additionally suffer from aria-live regions updated per chunk, which floods the announcement queue. The correct pattern is announcing start and completion, not progress.

prefers-reduced-motion interacts here too: a rAF-driven busy animation must be suppressible, which means the jank canary from this module's labs is a debugging tool, not a production spinner. Developed in fe-24-accessibility.

The compositor as an architectural boundary

The red square in starvation.html keeps spinning through a completely blocked main thread, because it animates transform on the compositor. This is worth generalising: "the page looks alive" and "the page is responsive" are independent claims, and most loading indicators assert the first while users care about the second.

The corollary is the threading rule behind "animate transform, not left" — it is not a style preference, it is which thread owns the animation. And it is why a single non-passive wheel or touchstart listener hands your scroll performance to your worst long task: the compositor must ask the main thread whether preventDefault() will be called. Picked up in fe-11 and fe-27-mobile-web.

Scheduling shows up in test infrastructure

Experiment B has a direct consequence for testing: .click() and fireEvent do not interleave microtasks between listeners, while real user input does. A test suite driving UI synthetically has different semantics from production. If any listener's correctness depends on another's promise having settled, the suite is structurally incapable of catching it.

The mitigation is knowing which of your tests use real input (Playwright's page.click() goes through CDP and is trusted) versus synthetic dispatch (Testing Library's fireEvent; userEvent is closer but still synthetic). Developed in fe-32 and fe-33.

Scheduling as an SLO

Once you can decompose INP, you can argue about which metric an organisation should commit to. INP is user-centric but lagging and sparse; Total Blocking Time is lab-measurable and leading but does not reflect real interaction patterns. The mature position is to pair them: a field metric for truth and a lab metric for CI gating, with the explicit acknowledgement that the lab metric is a proxy. Developed in fe-43-observability and fe-44-reliability.

Step 1 — Event Ordering and the Microtask Checkpoint

Goal

Predict the execution order of async code without running it, then discover the rule that makes the prediction correct. Establish that the microtask checkpoint follows stack emptiness, not task boundaries.

Prerequisites

  • Node 18+; npm install in src/
  • Read CONCEPTS.md §3 (How it works) and §5 (Mental models)

Predict first — do not run anything yet

Write your answers down. The value of this step is the diff between prediction and result; if you run it first, there is no diff and no learning.

<button id="b">Go</button>
<script>
const b = document.getElementById('b');

b.addEventListener('click', () => {
  console.log('A');
  Promise.resolve().then(() => console.log('B'));
  queueMicrotask(() => console.log('C'));
  requestAnimationFrame(() => console.log('D'));
  setTimeout(() => console.log('E'), 0);
  b.style.background = 'red';
  console.log('F', b.offsetHeight);
});

b.addEventListener('click', () => {
  console.log('G');
  Promise.resolve().then(() => console.log('H'));
});
</script>
  1. Log order when a human clicks the button.
  2. Log order when the page runs b.click() from a top-level script. If your answer differs from (1), state the mechanism. If it does not differ, state why not.
  3. When does the button actually turn red — relative to which log line?
  4. D vs E: which fires first? Separate what the specification guarantees from what Chrome will typically do, and name the condition that flips it.
  5. One line in that handler is more expensive than it looks. Which, and what does it cost?

Run

cd src
npm run ordering

Expected output:

=== A. EVENT ORDERING (40 trials each) ===
  real click    40/40   A F B C G H D E
  .click()      40/40   A F G B C H E D

=== B. MICROTASK CHECKPOINT vs 3 LISTENERS ON ONE ELEMENT ===
  real trusted click       : L1 m1 L2 m2 L3 m3
  el.click() from script   : L1 L2 L3 m1 m2 m3
  dispatchEvent from script: L1 L2 L3 m1 m2 m3
  el.click() in setTimeout : L1 L2 L3 m1 m2 m3

=== C. `await` TICK COST ===
   sync A1 P1 A2 P2 A3 P3 P4 P5 P6

What just happened

Two things moved between the two orderings, for unrelated reasons. Most explanations conflate them; keeping them separate is the whole point of this step.

The microtasks (B, C, H) moved because the checkpoint rule is:

A microtask checkpoint runs when the JavaScript execution context stack becomes empty.

"At the end of a task" is a consequence of that rule in the common case, not the rule. Experiment B isolates the real variable across four dispatch paths. The fourth row is decisive: el.click() inside a setTimeout is a genuine separate task and still does not interleave — so "task vs script" cannot be the explanation. When the browser dispatches, nothing of yours is on the stack between listeners, so the stack empties and the checkpoint fires. When you dispatch, your calling frame stays on the stack and every microtask queues behind it.

Consequence for testing (previews fe-32/fe-33): a suite driving UI with .click() or fireEvent runs listeners without microtask interleaving. Real users get interleaving. If any listener's correctness depends on another's promise having settled, the suite is structurally incapable of catching it. That is not flakiness — it is a harness with different semantics from the runtime.

The red background appears after E — after every log line. style.background mutates the CSSOM; it does not paint. Painting happens in the rendering steps, which cannot run until the task ends and the microtask queue drains.

D vs E is not a property of the primitives — see Step 1b below.

b.offsetHeight is the expensive line. You wrote to style.background on the previous line, invalidating style; reading a geometric property demands a correct answer immediately, forcing style recalculation and layout synchronously, inside your handler. One read is cheap; the pattern is not. Quantified in Step 3.

await costs one microtask tick, not three. The three-tick figure is pre-2019 and still widely repeated. This is why the module keeps a verification log — platform facts rot.


Step 1b — why D and E swap

npm run frame-timing

Expected output (abridged):

  dispatch path        | rAF fired at | setTimeout(0) fired at | winner
  real trusted click   |       +5.4ms |                 +5.6ms | tie (<0.5ms)
  real trusted click   |       +6.1ms |                 +6.1ms | tie (<0.5ms)
  el.click() in script |      +10.4ms |                 +0.1ms | timeout
  el.click() in script |       +6.4ms |                 +0.0ms | timeout

Read the margins, not the winner column. Neither primitive changed speed.

  • Real click: both fire within a fraction of a millisecond, because input dispatch is scheduled immediately before the rendering steps — the rAF callback and the frame boundary are effectively the same moment. Which one "wins" is decided at sub-millisecond margins. It is reproducible on one machine and guaranteed by nothing.
  • Script click: the timer wins decisively (~0.0 ms vs 5–12 ms). A plain script runs at an arbitrary point in the cycle, usually far from the next rendering opportunity, so the already-ready timer task runs immediately.

The generalisation: requestAnimationFrame is not fast or slow. Its latency is your distance to the next frame, and that distance is set by whatever scheduled you. This is why setTimeout(fn, 0) "fixes" a layout-timing bug on your machine and breaks under load — you did not choose a phase of the frame cycle, you chose a coin flip whose bias depends on scheduling pressure.

Checkpoint

docs/verification.md Checkpoints 1, 2, 3, 6.

Record

Any prediction you got wrong goes in ../fe-00-roadmap/docs/learning-log.md §1 — as the shape of the confusion, not the topic. "Didn't get microtasks" is useless in three months. "Believed the checkpoint runs at task boundaries, which predicts interleaving for setTimeout(() => el.click()) — it does not" is a diagnosis.

Step 2 — Does "Chunking" Actually Chunk?

Goal

Establish, by measurement, that microtask-based chunking creates zero rendering opportunities — and feel the difference in a real browser before seeing the number.

Prerequisites

  • Step 1 complete
  • npm install in src/

Predict first

Two loops. Same 400 ms of work, same 5 ms slices, pausing between every slice. The only difference is how they pause:

const yieldMicro = () => Promise.resolve();                       // A
const yieldTask  = () => new Promise(r => { resume = r;           // B
                          channel.port2.postMessage(0); });

Both pause constantly. Write down: how many frames does each render in 400 ms?

Run it by hand first

cd src
npm run serve       # http://localhost:8080/starvation.html

Click the left button (await Promise.resolve()), watch frames drawn. Then the right button (postMessage).

While the left run is going:

  • Try selecting this text. Try scrolling.
  • Watch the red square. It keeps spinning. That is not a bug — it is the most useful thing on the page.

Then measure

npm run starvation

Expected output:

=== D. MICROTASK vs TASK CHUNKING (400ms of work, 5ms slices) ===
  yield primitive              | frames rendered | slices | wall clock
  -----------------------------|-----------------|--------|-----------
  await Promise.resolve()      |               0 |     80 | 400ms
  MessageChannel postMessage   |              49 |     80 | 404ms

What just happened

Zero frames. Not "fewer frames" — zero. The specification requires the microtask queue to be empty before the browser may proceed to the rendering steps. A loop that re-enqueues itself is never empty, so the rendering steps are never reached. You did not yield; you built a queue that refills faster than it drains.

The slice counts and wall clocks are nearly identical (80 vs 80, 400 ms vs 404 ms). That equality is what makes this a controlled experiment: both runs did the same work, in the same number of pieces, in the same time. Only the rendering differs.

The red square is the lesson most worth keeping. It animates transform, which runs on the compositor thread, which a blocked main thread cannot stop. So during a total main-thread freeze the page still looks alive.

Two consequences:

  1. A spinner is a terrible liveness indicator. "The page looks alive" and "the page is responsive" are independent claims, and most loading UI asserts the first while users care about the second.
  2. This is the threading rule behind "animate transform, not left". It is not a style preference — it decides which thread owns the animation. The same mechanism explains why one non-passive wheel listener hands your scroll performance to your worst long task: the compositor must ask the main thread whether preventDefault() will be called.

Scrolling may have worked during the freeze; text selection did not. Scroll is compositor; selection is main thread. You just observed the thread boundary directly.

Checkpoint

docs/verification.md Checkpoint 4.

Going deeper

WORK_MS=3000 npm run starvation     # longer freeze — watch the tab become unkillable-feeling

Then ask: during the microtask run, can you open DevTools? Can you set a breakpoint? What does that tell you about where DevTools' own UI runs?

Step 3 — Forced Synchronous Layout

Goal

Quantify the cost of interleaving layout reads and writes, and learn to recognise the pattern in code that looks entirely ordinary.

Prerequisites

  • Step 1 complete (you met b.offsetHeight there)

Predict first

Two loops over 800 elements. Identical work, identical final DOM:

// A
for (const e of els) e.style.width = (e.offsetWidth + 1) + 'px';

// B
const widths = els.map(e => e.offsetWidth);
els.forEach((e, i) => { e.style.width = (widths[i] + 1) + 'px'; });

Write down: how much slower is A than B? Commit to a number, not "somewhat".

Run

cd src
npm run layout

Expected output:

=== E. FORCED SYNCHRONOUS LAYOUT (800 elements, median of 5) ===
  pattern                                  | time
  -----------------------------------------|--------
  interleaved read -> write, per element    | 86.2ms
  batched: read all, then write all         |  0.3ms

  287x difference. Identical work, identical final DOM.

The ratio varies by machine and element count; on the reference machine it ranged from 98× to 287× across runs. Anything above ~50× is the same phenomenon.

What just happened

Each style.width write invalidates layout. Each offsetWidth read demands a geometrically correct answer right now, so the browser must run style recalculation and layout synchronously before returning — inside your loop, outside the rendering steps.

Interleaved, you pay N layouts. Batched, you pay one. You wrote code that looks O(n) and executes O(n) layouts, each of which is itself proportional to the document.

Why this matters more than the number suggests: the bug fits on one line, and the line reads as completely normal code.

e.style.width = e.offsetWidth + 1 + 'px';

There is no await, no loop-in-a-loop, no obviously expensive call. Code review does not catch this by inspection — it is caught by knowing which properties force layout, or by DevTools' forced-reflow warning.

Layout-forcing properties (partial): offsetWidth/Height/Top/Left, clientWidth/Height, scrollWidth/Height/Top, getBoundingClientRect(), getComputedStyle() (for most properties), focus(), scrollIntoView(), innerText. Paul Irish's list in references.md is the complete one and is worth bookmarking rather than memorising.

The general rule: batch reads, then batch writes. If a library forces you to interleave (many measurement-driven layout libraries do), that is a real architectural cost of the library and belongs in the evaluation.

Debugging exercise

npm run serve      # http://localhost:8080/lab.html

DevTools → Performance, CPU 6× slowdown, record while the thrash path runs.

  1. Find the red triangle forced-reflow warning. Read the stack it blames.
  2. Confirm the total time is dominated by Layout, not Scripting — this is the case where the flame chart contradicts the intuition that "JS is the slow part."
  3. Now record the batched version. The Layout blocks collapse to one.

Induce this deliberately now, while you have a working example. You want to recognise the marker before you need to find one under production pressure.

Checkpoint

docs/verification.md Checkpoint 5.

Going deeper

COUNT=3000 npm run layout

Is the ratio stable as N grows, or does it worsen? Explain the answer in terms of what each forced layout costs relative to document size — and what that predicts for a 100k-row table (fe-30).

Step 4 — The Strategy Benchmark

Goal

Compare seven scheduling strategies for one feature under identical conditions, and produce a written recommendation that states what the numbers do not prove.

Prerequisites

  • Steps 1–3 complete
  • npm install in src/

Predict first

Seven implementations of a filter over 200,000 rows, driven by 8 real keystrokes at 6× CPU throttle:

strategy
s0fully synchronous handler
s1async handler, unchunked
s2chunked with await Promise.resolve()
s3chunked with MessageChannel, 5 ms slices
s4chunked with scheduler.yield()
s5Web Worker, dataset posted per keystroke
s6Web Worker, dataset resident, only the query posted

Rank them for INP before running. Separately, rank them for total wall clock. If your two rankings are identical, you have probably not understood the trade-off yet.

Run

cd src
npm run benchmark          # ~90 seconds

Expected output:

 strat | strategy             | longTasks |  TBT | jank | INP p75 | INP max | dominant | filter | boundary |  wall
 s0    | sync handler         |         0 |    0 |    5 |      56 |      64 |     pres |    222 |        0 |  2098
 s1    | async, unchunked     |         0 |    0 |    4 |      56 |      64 |     pres |    221 |        0 |  2096
 s2    | microtask-chunked    |         8 |  836 |    8 |     184 |     184 |     pres |   1192 |        0 |  3067
 s3    | MessageChannel 5ms   |         0 |    0 |    0 |      32 |      32 |     pres |    957 |        0 |  1920
 s4    | scheduler.yield()    |         0 |    0 |   16 |      32 |      32 |     proc |   1358 |        0 |  1913
 s5    | worker, naive        |         8 |  913 |    8 |     200 |     200 |     pres |     34 |     1516 |  3148
 s6    | worker, resident     |         0 |    0 |    0 |      24 |      24 |    delay |     79 |        4 |  1838

Absolute numbers vary. The relationships in docs/verification.md Checkpoint 7 are the pass condition.

What just happened

s2 is worse than doing nothing. 8 long tasks, 836 ms of blocking, 3× the INP of the naive baseline, and ~5× the filter time. It paid the entire cost of chunking — clock polling, loop restructuring, a lost tight loop — and received none of the benefit, for the reason established in Step 2.

This is the most important row, because s2 passes code review. It reads as await-ing between chunks with a time budget: the careful version. A reviewer without this model approves a large regression labelled as an optimisation.

s5 vs s6: the worker is not the win — the boundary is. Same worker, same algorithm, comparable off-thread compute (34 ms vs 79 ms). s5 posts 200,000 objects per keystroke: 1516 ms of structured clone, synchronously, on the main thread — precisely what the worker was supposed to avoid. s6 keeps the data resident and posts a string: 4 ms.

A ~380× difference in boundary cost from one architectural decision. "Move it to a worker" is not an optimisation; it is a data-ownership decision. And note s5 is worse than s0 — a naive worker is worse than no worker.

s3 vs s4: identical INP, very different throughput. Both reach 32 ms INP with zero long tasks. scheduler.yield() takes 1.5–3.5× the filter wall clock, because its continuations are scheduled behind rendering by design. This is a genuine counterexample to the specification's own heuristic "prefer platform primitives" — correct default, real cost. Record it in the heuristics table in ../fe-00-roadmap/docs/learning-log.md §5.

The dominant column moves. Across seven implementations of one feature you should see at least three different dominant terms. This is the most transferable result in the module: an organisation with a single aggregate INP dashboard cannot distinguish these, and will apply one fix to three different problems.

Answer in writing

Create RESULTS.md in this module directory:

  1. Which strategy has the best INP? Which has the best total time? Are they the same? Explain the gap in terms of the event loop — not "it's faster."
  2. Find the dataset size at which s5 stops losing to s0. Use ROWS= to search for it. What dominates below the crossover?
  3. Change the s3 slice budget from 5 ms to 50 ms, then to 0.5 ms. Both are worse, for different reasons. Name both.
  4. What do your numbers not prove? Be specific. This is the section a Principal Engineer is graded on, and docs/observation.md §"What these measurements do not tell you" is the starting point, not the answer.

Optional: implement them yourself

src/web/lab.html has the same seven strategies as TODO stubs with the harness pre-wired (instrument.js). Implementing s2 yourself — after predicting its behaviour — is the single most effective exercise in this module. Serve with npm run serve.

Checkpoint

docs/verification.md Checkpoints 7 and 8.

Step 5 — Failure Lab

Goal

Diagnose three seeded failures from evidence, before reading any explanation. Two of them are realistic production incidents; one is on the specification's failure case-study list.

Prerequisites

  • Steps 1–4 complete

Setup

cd src
npm run serve      # http://localhost:8080/lab.html

Open the console and load the bugs:

await import('./bugs.js');

Do not read the notes at the bottom of web/bugs.js until your written diagnosis is done.


F1 — The frozen tab

Bugs.f1()

A "progress-friendly" chunked implementation using recursive queueMicrotask. The tab freezes. No error, no crash dialog, CPU at 100 %.

Predict before running:

  • Does the rAF-driven counter stop?
  • Can you get a useful Performance recording?
  • Can you break in with the debugger?
  • Does the tab recover on its own?

Then run it and explain precisely why "chunking" changed nothing. You already have the mechanism from Step 2; this step is about recognising the symptom without knowing the cause in advance.

Corresponds to: specification failure case study "rendering loop freezes browser."


F2 — The stale overwrite

Bugs.f2()

A debounced search against an endpoint with randomised 50–800 ms latency. Typing a query then correcting it intermittently leaves the results showing the old query's results — roughly 1 run in 6.

This is the specification's "stale response overwrites newer data" incident.

Your tasks

  1. Reproduce it deterministically. Bugs._f2.fakeFetch.forceOrder([300, 60]) forces the first request slow and the second fast, guaranteeing out-of-order completion.

    If your reproduction is "type fast a few times," you have observed it, not reproduced it. The difference is the difference between a fix and a hope — and it is the difference between a regression test that holds and one that flakes.

  2. Name the violated invariant in one sentence.

  3. Implement three fixes:

    • AbortController cancellation
    • a request sequence number (last-write-wins on issue order)
    • a render-time guard comparing the response's query against current state
  4. Rank them and defend the ranking. All three "work" for a read.

  5. Which is correct when the request has a server-side effect? This question is the seam into fe-17 (races, cancellation, idempotency) and fe-18 (API integration as a distributed system). A partial answer is fine here; a stated partial answer is the point.

Note on the debounce

The debounce reduced the number of in-flight requests. Establish for yourself what it did to the ordering guarantee. This is the trap the incident depends on.


F3 — The invisible progress bar

Bugs.f3()

A loop updating progressBar.style.width on every iteration of a 5,000-item job. The bar jumps 0 % → 100 % with nothing in between, and the whole job is slower than the version with no progress bar at all.

Two symptoms. Two different causes. Explain both, then fix it so the bar animates smoothly and the job finishes faster than the original.

You have both mechanisms already — one from Step 2, one from Step 3. This step tests whether you can select the right one from a symptom rather than recognise it from a heading.


Debugging exercise

For each failure, produce written answers to the specification's incident protocol:

  1. What do you investigate first?
  2. What evidence do you need?
  3. What hypotheses exist?
  4. What experiments distinguish them?
  5. What mitigation is appropriate?
  6. What permanent fix is appropriate?
  7. What systemic change prevents recurrence?

Question 7 is the Principal-level one, and it is where most incident reviews stop short. For F2, "we added a guard" is a fix; "our data-fetching layer makes unordered responses unrepresentable, and the lint rule catches raw fetch in components" is a systemic change.

Testing considerations

Turn F2 into a regression test that cannot flake:

await page.route('**/search*', async (route) => {
  const q = new URL(route.request().url()).searchParams.get('q');
  await new Promise(r => setTimeout(r, q === 'nov' ? 300 : 60));   // force N after N+1
  await route.fulfill({ json: { query: q, results: [`results for "${q}"`] } });
});

Controlling response ordering explicitly converts a probabilistic bug into a deterministic test. That conversion is the whole game, and it recurs throughout fe-33.

Note also, from Step 1: assert on counted invariants (requests issued per N keystrokes; final rendered state matches the last query), never on wall-clock durations. Shared CI runners have order-of-magnitude variance, and a duration assertion will be deleted within a month — correctly.

Checkpoint

Module completion in docs/verification.md requires F1–F3 diagnosed from evidence, F2 reproduced deterministically, and the three F2 fixes ranked.

Step 6 — Principal Engineer Review

Answer in writing. These are judgment questions: several have defensible answers on more than one side, and "it depends" is only acceptable if you say on what.

Questions 4, 5 and 10 are no longer hypothetical — you generated the data for them in Step 4.


1. A team reports INP regressed from 180 ms to 450 ms after a release that "only changed CSS." Give three mechanisms by which a CSS-only change can regress INP. Which single piece of evidence distinguishes them fastest?

2. An engineer proposes a codebase-wide lint rule banning setTimeout(fn, 0). Make the strongest case for the rule, then the strongest case against. What do you actually do, and what does your answer depend on?

3. Your platform team wants to ship a shared chunkWork() utility to 40 teams. Specify its API. What must it not let callers do? What happens when a caller passes a callback that itself awaits a network request — and does your API make that mistake representable?

4. A senior engineer benchmarks your chunked data transform, finds it 28 % slower in total wall clock, and wants to revert. You measured exactly this in Step 4 (s3 vs s4, s0 vs s3). Walk through how you handle it — the technical argument and the fact that they are right about their number. Under what circumstances should you agree with them?

5. Two proposals for a 100k-row table: (a) virtualize on the main thread with time-sliced rendering; (b) move filtering and sorting to a Web Worker and keep rendering naive. Your s5/s6 result is directly relevant. What does each optimise? What workload makes each the wrong choice? What would you measure before choosing — and what would you do if you could not get that measurement in time?

6. Your organization wants a single frontend performance SLO. Argue for INP over Total Blocking Time as the primary SLI, then argue the reverse. Which do you adopt, and what second metric do you pair it with to cover the first one's blind spot?

7. React's scheduler yields with MessageChannel rather than microtasks or setTimeout. Reconstruct that decision from first principles: what does each of the three options cost, and what breaks if you swap it for the other two?

8. When is the correct engineering decision to have a long task — to deliberately refuse to yield? Give a concrete scenario and state the invariant that yielding would violate.

9. A third-party SDK you cannot modify, and that Legal requires, causes 60 % of your long tasks. Enumerate your options in order of blast radius. Which do you propose, and how do you get agreement?

10. Someone claims: "Concurrent React fixes our INP problem." Under exactly which conditions is that true, and under which is it false? What single question would you ask to find out which situation you are in?

11. Your test suite drives all UI interactions with fireEvent.click(). Given Step 1's finding about microtask interleaving, describe a bug class this suite cannot catch. What would you change, and what would that change cost in suite runtime and maintenance?

12. You have established that the dominant INP term differs across implementations of the same feature. Design the performance report your organization should require. What must it contain beyond a number, and what behaviour are you trying to make impossible?


Record

File your answers as RESULTS.md in the module directory, or as an ADR in ../fe-00-roadmap/docs/decisions/ where the question is genuinely a decision (3, 6, 9, 12 are the natural candidates).

Anything you could not answer goes in ../fe-00-roadmap/docs/learning-log.md §2 as an open question. A question that survives three modules is a signal — either it is genuinely hard, or the model underneath it is still wrong.

Concepts — Memory Model, Retention & Leaks

Phase 1 · Platform substrate · Specification areas §1 (closures, garbage collection, WeakRef, memory leaks, object shapes). Gates §11 (performance), §30 (large-scale UI), §41, and the "memory leak after hours" failure case study.

1. What is it

A JavaScript memory leak is not a failure to free memory. It is unintended reachability: an object you are done with is still reachable from a GC root, so the collector — correctly — keeps it. Every leak in this module is a correctness bug in a reference graph, not a resource-management bug.

Three things make this hard in a browser, and they are why this is a module rather than a footnote:

  • You do not control collection. You control reachability. The only lever you have is severing references; when the collector acts is not your decision.
  • The heap is not one heap. JavaScript objects live in V8's heap. DOM nodes live in Blink's C++ heap. The instrument most teams reach for reports only the first, so a large DOM leak can look like no leak at all.
  • Retention is often created by code you did not write and never called. A sibling closure in the same scope, a framework's subscription registry, a module-level cache.

2. Why it matters

"The app gets slow after a few hours" is a leak until proven otherwise, and it is one of the twelve failure case studies in the specification. It is also the hardest class of bug to reproduce, because it requires time, and time is what test suites do not spend.

Leaks degrade before they crash. Long before an out-of-memory kill, a growing heap causes longer and more frequent GC pauses — which appear as scheduling problems: long tasks, jank, INP regressions with no obvious cause in the interaction path. This is the concrete link back to fe-01: a team can spend a quarter optimising handlers when the actual cause is that major GC now runs every few seconds.

The instrument selects the conclusion. Measured in this module: releasing 5,000 detached DOM nodes freed 508 KB of DOM memory while moving the JS heap by 0.10 MB. A team checking performance.memory, seeing it flat, and concluding "no leak" has been misled by their tool, not by the data.

Single-page applications made this everyone's problem. A page that used to be discarded on every navigation now lives for a working day. Every route change, modal open, subscription and cache entry is an opportunity to retain something forever.

3. How it works

+==================================================================+
|  REACHABILITY — the only thing that decides collection            |
+==================================================================+

  GC ROOTS
   ├── the global object (window)
   ├── the execution stack (locals of running functions)
   ├── the DOM tree reachable from document
   ├── active timers and their callbacks
   ├── registered event listeners (via their targets)
   └── pending promises / microtask queue entries
                    |
                    v
        anything reachable by ANY path is retained
                    |
        +-----------+-----------+
        |                       |
   reachable                unreachable
   -> retained              -> collectable (eventually)


+==================================================================+
|  TWO HEAPS, TWO INSTRUMENTS                                       |
+==================================================================+

  V8 JS heap                        Blink C++ heap (Oilpan)
  ─────────────────                 ────────────────────────
  objects, arrays, closures,        DOM nodes, style data,
  strings, Maps, wrappers           layout objects
        ▲                                   ▲
        │                                   │
  Runtime.getHeapUsage              NOT visible to it
  performance.memory                        │
        │                                   │
        └─── heap snapshot ─────────────────┘
             (includes native nodes +
              a `detachedness` flag per node)

  performance.measureUserAgentSpecificMemory() spans both,
  but requires cross-origin isolation (COOP + COEP).


+==================================================================+
|  THE FIVE RETAINER SHAPES                                         |
+==================================================================+

  1. DETACHED DOM        node removed from document, JS ref survives
  2. LISTENER            long-lived target holds a closure over a dead component
  3. TIMER               setInterval callback closes over a dead scope
  4. SIDE TABLE          Map/Set/registry keyed by objects, never deleted
  5. SHARED CONTEXT      a sibling closure forces context allocation

Closures retain contexts, not variables

V8 allocates one Context per scope. If any closure created in that scope references a variable, the variable is context-allocated — and every sibling closure keeps the whole context alive, including closures that never mention it.

Measured (200 retained closures, each from a scope holding a 50,000-element array):

VariantReturned closureRetained heap
A — nothing else references big() => 420.01 MB
B — an unused, never-called sibling references big() => 4238.17 MB
C — same as B, plus big = null before returning() => 420.01 MB

The returned closure is byte-for-byte identical in all three. What differs is only what else was declared in the same scope. This is why "my callback doesn't reference that variable" is not a defence, and why these leaks survive code review.

4. Core terminology

TermDefinition
ReachabilityExistence of any reference path from a GC root; the sole criterion for retention
GC rootGlobal object, stack, document tree, timers, listeners, pending microtasks
Retained sizeMemory freed if this object became unreachable — the number that matters
Shallow sizeMemory of the object itself, excluding what it references
DominatorNode through which all paths to an object pass; removing it frees the subtree
Detached DOMNode removed from the document but still referenced from JS
detachednessPer-node field in modern V8 heap snapshots: 0 unknown, 1 attached, 2 detached
OilpanBlink's C++ garbage collector, managing DOM and rendering objects
Minor GC (scavenger)Frequent, cheap, collects the young generation
Major GC (mark-compact)Infrequent, expensive, walks the whole heap; the source of long GC pauses
ContextV8's per-scope allocation holding captured variables shared by sibling closures
WeakMap / WeakSetCollections holding keys weakly; not enumerable, object keys only
WeakRef / FinalizationRegistryExplicit weak reference and post-collection callback; non-deterministic
Leak vs. cacheA cache has an eviction policy. Without one, it is a leak with better branding

5. Mental models

You do not free memory; you sever references. Reframing every leak question as "what still points at this?" replaces an unanswerable question ("why isn't this freed?") with a mechanical one you can answer from a retainer path.

The retainer path is the bug report. In a heap snapshot, the object is not the finding — the path from a root to the object is. Fixing a leak means breaking exactly one edge on that path, and the edge you break determines whether the fix is local or architectural.

Ownership must be explicit, and it usually is not. Every subscription, timer, observer and cache entry has an owner responsible for teardown. Leaks happen when ownership is implicit, so the structural fix is to make teardown impossible to forget — an AbortController signal created at registration time rather than a removeEventListener call someone must remember to write.

Growth shape, not growth. A single measurement proves nothing; heaps grow for many innocent reasons. Repeated identical cycles with a forced GC between them produce a slope and a linearity. Measured here: a clean workload had slope 0.001 MB/cycle with R² 0.742 — noise that looks correlated — while the leak had slope 1.836 MB/cycle with R² 1.000. Slope alone would have been ambiguous; slope plus linearity is not.

A cache without an eviction policy is a leak. The most common accidental cache is a module-level Map keyed by objects. Measured: 38.34 MB retained and 2,000 immortal detached nodes versus 0.09 MB and zero for the identical code using a WeakMap.

6. Common misconceptions

  1. "performance.memory shows a flat line, so there's no leak." It reports the JS heap only. Measured: 5,000 detached nodes holding 508 KB of DOM memory moved the JS heap by 0.10 MB. Use a heap snapshot's detachedness flag, or performance.measureUserAgentSpecificMemory().

  2. "My closure doesn't reference it, so it can't retain it." False, and it is the subtlest result here — a 5,744× difference driven entirely by an unused sibling function.

  3. "Setting the variable to null is cargo cult." Usually yes, and occasionally load-bearing — variant C above. The distinguishing question is whether a surviving closure shares the scope.

  4. "WeakMap makes memory concerns go away." It weakens the key, not the value. A value that references its own key creates a cycle a WeakMap cannot break. And WeakMap is not enumerable, so the leak you do have becomes harder to see in a dump.

  5. "The GC will get to it eventually, so a small leak is fine." Unreachable objects are collected; reachable ones never are, no matter how long you wait. A leak does not shrink.

  6. "Removing the element from the DOM frees it." Only if nothing else references it. Detached subtrees retain their entire subtree — one held reference to a leaf can retain thousands of ancestors and their event listeners.

  7. "Leaks cause crashes." Eventually. Long before that they cause longer, more frequent GC pauses, which present as fe-01-shaped problems: long tasks, jank, INP regressions with no cause in the interaction path.

7. Interview talking points

  • "A leak is unintended reachability, not a failure to free. So the only question worth asking in front of a heap snapshot is 'what is the retainer path?' — the object is never the finding."
  • "The first thing I check is whether the team measured the right heap. DOM nodes are in Blink's C++ heap; performance.memory doesn't see them. We measured a case where releasing half a megabyte of detached DOM moved the JS heap by a tenth of that — flat graph, real leak."
  • "For leak detection I require repeated identical cycles with forced GC between them, and I report slope and R². We had a clean workload show R² of 0.74 — noise can look correlated, and reporting a slope alone costs someone a week."
  • "The listener leak is worth fixing structurally rather than case by case. removeEventListener requires remembering a second call and keeping a reference to the exact function; an AbortController signal created at registration ties teardown to the same line. That's making the invalid state hard to represent."
  • "Closures retain contexts, not variables. A sibling function that's never called can pin megabytes, which means the retaining code is often nowhere near the code that looks suspicious."
  • "Leaks show up as scheduling problems first. If INP is regressing with no change in the interaction path, I want a heap trace before I let anyone optimise a handler."

8. Connections to other modules

  • fe-01-execution-model-scheduling — prerequisite, and the link is causal: GC pauses are long tasks. A leak diagnosed as a scheduling problem is the classic misdirection.
  • fe-10 / fe-11 (hooks, memoization) — stale closures and effect cleanup are this module's retention rules wearing framework clothing; useEffect teardown is ownership.
  • fe-16 / fe-17 (server state, races) — query caches are side tables keyed by objects. The eviction policy is what separates a cache from a leak.
  • fe-30-large-scale-ui — virtualization creates and destroys thousands of nodes per second; a per-row listener or side-table entry becomes catastrophic rather than merely wasteful.
  • fe-33-e2e — leaks need time to appear, which is why they escape unit tests. Long-running soak tests with heap assertions are the only automation that catches them.
  • browser-framework-internals.md §8 (DOM Internals), §17 (V8 Integration) — cross when you need to know how DOM wrappers are kept alive across the two heaps.

References — Memory, Retention & Leaks

Specifications

Engine internals

Tooling

Analysis and practice

Deliberately excluded

Listicles of "10 causes of memory leaks". They enumerate symptoms without the reachability model, which leaves you pattern-matching instead of reading a retainer path — and the retainer path is the only thing that generalises to a leak nobody has blogged about.

Analysis

The five retainer shapes, and where the fix belongs

ShapeRootTypical fixFix altitude
Detached DOMa JS reference to a removed nodedrop the reference on teardownlocal
Listenerthe long-lived event targetAbortController signal at registrationstructural
Timerthe timer registryclear on teardown; prefer AbortSignal-aware wrappersstructural
Side tablemodule-level Map/Set/registryWeakMap, or an explicit eviction policyarchitectural
Shared contextV8 Context shared by sibling closuresnarrow the scope; null the bindinglocal, but invisible

The altitude column is the Principal-relevant one. Detached-DOM leaks are usually one bad line and one good line. Side-table leaks are a design question — who owns this cache and when does an entry die? — and fixing one instance without answering that question guarantees the next one.

Why AbortController beats removeEventListener

Measured, both fixes work: 0.08 MB vs 0.09 MB retained across 300 cycles. They are not equivalent in the ways that matter at scale.

// requires: a stable reference, a second call, and remembering to make it
this._h = () => this.onResize();
window.addEventListener('resize', this._h);
// ... elsewhere, later, in a different function
window.removeEventListener('resize', this._h);

// teardown handle exists at registration; one abort() covers every registration
this._ac = new AbortController();
window.addEventListener('resize', () => this.onResize(), { signal: this._ac.signal });
// ... teardown
this._ac.abort();

The removeEventListener form has three separate failure modes: forgetting the call, losing the reference (an anonymous listener is unremovable — no amount of later code can fix it), and passing different options than at registration. The signal form has one teardown call regardless of how many listeners were registered, and it composes with fetch, addEventListener, and any API that accepts a signal.

This is the specification's heuristic "make invalid states difficult to represent" applied to lifecycle rather than to data — which is where it usually pays most.

When WeakMap is the wrong answer

WeakMap fixed a 38 MB leak to 0.09 MB in the measurement. It is still not a default:

  • It weakens the key, not the value. wm.set(node, { node }) retains everything, because the value's reference to the key is strong. This is a common and hard-to-see mistake.
  • Not enumerable. No .size, no iteration, no way to dump it in an incident. You have traded a visible leak for an invisible cache.
  • Object keys only. Anything keyed by an id string cannot use it.
  • Non-deterministic collection. You cannot write a test that asserts an entry was freed — only that heap growth is bounded, which is a different and weaker claim.
  • It hides the real question. If entries should expire on logout, on navigation, or after 30 seconds, WeakMap implements none of that. It only handles "when the key dies", which is frequently not the intended policy.

Decision rule: use WeakMap when the key's lifetime genuinely is the entry's lifetime. When the policy is time, count, or an explicit event, you want a real cache with an eviction policy — LRU, TTL, or clear-on-event — and that is a design decision, not a data-structure swap.

Leak detection as a statistical claim

The clean run in experiment 5 produced R² = 0.742. Correlated-looking noise over a 12-point series is unremarkable, and it is exactly why slope alone is not evidence.

slopeInterpretation
highhighreal leak driven by the repeated operation
highlowwarming cache, lazy compilation, unsettled heap — not yet a finding
lowhighsteady tiny growth; may be real but is not urgent — quantify against session length
lowlownoise

Two further requirements to make the claim honest:

  • Magnitude against session length. 0.05 MB/cycle is meaningless until multiplied by how many cycles a real session performs. A leak that costs 4 MB over a working day does not justify a sprint; one that costs 400 MB does.
  • A control. Run the same harness against a code path you believe is clean. Without a control you cannot distinguish your application leaking from your harness leaking — and harnesses leak.

What breaks at scale

  • Frameworks own the teardown, and their contract is easy to violate. useEffect cleanup, ngOnDestroy, onUnmounted are all ownership declarations. A subscription created outside the lifecycle hook — in a module body, an event handler, a promise chain — is outside the contract and will not be torn down. Most framework-era leaks are ownership placed in the wrong scope.
  • Virtualized lists multiply everything. A per-row listener or side-table entry is a rounding error at 20 rows and an incident at 100,000 (fe-30). Anything per-row must be delegated or weak, and the review question for virtualization code is "what is allocated per row and who frees it?"
  • Long sessions are the test environment you do not have. Leaks need time. Unit and component tests never spend it, and E2E suites rarely exceed a minute. The only automation that catches these is a soak test asserting bounded growth over N cycles — which belongs in a nightly job, not in PR CI, because it is slow and mildly flaky by nature.
  • Third-party scripts leak and you cannot fix them. Analytics and chat widgets that retain DOM across route changes are common. Options in ascending blast radius: lazy-load them, move them to an iframe with its own heap, or drop them. Recognising it is third-party is most of the work — which requires the retainer path, not the growth curve.
  • Cross-process invisibility. Workers, iframes and extensions each have their own heap. A renderer that looks healthy may be one of several, and performance.measureUserAgentSpecificMemory() is the only in-page API that even attempts a whole-renderer figure.
  • GC pauses present as scheduling problems. A rising heap makes major GC more frequent and longer, which shows up as long tasks and INP regressions with no cause in the interaction path. Teams then optimise handlers for a quarter. The tell is that the regression correlates with session age, not with a code change — and nobody looks at session age unless someone tells them to.

Execution Guide

Tool versions

ToolVersion usedNotes
Node.js23.11.0any ≥ 18
playwright-core1.62.1ships no browsers; resolved via lib/browser.mjs
Chrome for Testing149.0.7827.55 (arm64)detachedness snapshot field requires a modern V8
Python3.13only for python3 -m http.server in the manual lab

Browser resolution is identical to fe-01: $CHROME, then the newest Playwright cache build, then a system Chrome. node_modules is symlinked to fe-01's to avoid a second install; if that link is missing, npm install here.

Why CDP rather than in-page APIs. Two capabilities are unavailable to page script and essential here:

  • HeapProfiler.collectGarbage — deterministic GC. Without it you measure collection scheduling rather than retention. (--js-flags="--expose-gc" is the alternative and is clumsier.)
  • HeapProfiler.takeHeapSnapshot — the only way to see DOM nodes and the detachedness flag.

Quick start

cd fe-02-memory-model-and-leaks/src
npm run closures      # 1. closure context retention      (~10s)
npm run detached      # 2. detached DOM vs the JS heap    (~10s)
npm run listeners     # 3. listener leak + two fixes      (~15s)
npm run weakmap       # 4. Map vs WeakMap                 (~10s)
npm run growth        # 5. leak-detection method          (~30s)
npm run all

Manual lab

npm run serve         # http://localhost:8081

web/leaky-app.html is a small SPA-shaped application with four seeded leaks, for DevTools Memory-panel practice. See steps/05-devtools-hunt.md.

Tuning

N=500 SIZE=100000 npm run closures    # bigger contexts
ROWS=50000        npm run detached    # more detached nodes
CYCLES=1000       npm run listeners   # longer soak
N=10000           npm run weakmap
CYCLES=30 PER_CYCLE=200 npm run growth  # tighter regression, slower run

Reproducibility notes

  • Forced GC is two rounds. One round can leave objects reachable only from dying weak references. If your numbers are noisy, raise it before suspecting a real leak.
  • Every variant gets a fresh page. Sharing a page lets an earlier variant's leak contaminate later ones — this produced wrong data while building the module; see measured-results.md.
  • JS-heap numbers are stable to roughly ±0.1 MB. Anything smaller is noise. Snapshot detached-node counts are exact and are the stronger signal.
  • Snapshots are expensive (hundreds of ms to seconds). Do not put one inside a measurement loop.

Observation Guide

Choosing the instrument first

Most failed leak investigations are instrument-selection failures. Decide what you are looking at before you look.

InstrumentSeesBlind toUse when
performance.memory (Chrome, deprecated)JS heap sizeall DOM, workers, other framesquick triage only; never as evidence
Runtime.getHeapUsage (CDP)JS heap used/totalsamescripted regression checks
Heap snapshotJS heap + native/DOM nodes, detachedness, retainer pathscross-processthe real tool; anything you will act on
performance.measureUserAgentSpecificMemory()whole renderer incl. DOMneeds COOP+COEPproduction RUM, when you can isolate
DevTools Performance → Memory checkboxheap over time, node/listener countsno retainer pathsseeing the shape before you snapshot

Rule: if a claim about a leak rests on performance.memory, it rests on nothing. Measured in this module: 508 KB of detached DOM freed moved the JS heap by 0.10 MB.

The DevTools Memory panel workflow

The workflow that finds leaks, in the order that works:

  1. Establish the repeated action. Open modal → close modal. Navigate → back. It must be idempotent: after one cycle the app should be in the same logical state.
  2. Warm up. Run the action 3–5 times first. Caches, lazy compilation and first-run allocation are not leaks, and skipping this step is how false positives are born.
  3. Snapshot 1. DevTools forces a GC before each snapshot, so you do not need to.
  4. Run the action N times (10–20).
  5. Snapshot 2.
  6. Comparison view, sort by Delta. You are looking for a class whose count grew by exactly N or a multiple of it. That proportionality is the signal — noise does not come in multiples of your loop count.
  7. Select an instance → Retainers panel. This is the finding. Walk the path to a root.
  8. Filter by Detached in the class filter to isolate detached DOM directly.

Reading the columns

  • Shallow size — the object itself. Usually uninteresting.
  • Retained size — what would be freed if it became unreachable. This is the number. A small object with huge retained size is a dominator, and dominators are where fixes go.
  • Distance — hops from a GC root. Very short distances on objects that should be transient are suspicious.
  • Delta (comparison view) — count and size change between snapshots. Sort here first.

Retainer paths worth recognising instantly

window → myModuleCache (Map) → {…} → HTMLDivElement        side-table leak
window → (event listeners) → onResize() → Widget → data    listener leak
window → (timer) → interval callback → context → Widget    timer leak
Window → context → sibling closure → big array             shared-context leak
document → … (nothing)  +  Detached HTMLDivElement         detached DOM

The last one is diagnostic: an element flagged Detached whose retainer path starts at a JS object rather than the document is the definition of the leak.

Healthy vs unhealthy signals

Healthy:

  • Heap returns to roughly its baseline after each cycle, once warmed
  • Detached node count returns to 0 after teardown
  • Listener count stable across mount/unmount cycles
  • Growth slope near zero regardless of R²

Unhealthy, with the diagnosis:

  • Class count growing by exactly N per N cycles → deterministic per-iteration retention
  • Detached nodes accumulating → something holds a reference to removed DOM
  • Listener count rising monotonically → missing teardown, and the target is long-lived
  • Heap sawtooths but the troughs rise → real growth hidden under normal GC activity; look at post-GC minima, not peaks
  • Major GC frequency rising over a session → the fe-01 link: this presents as jank, not as a memory alert

What these measurements do not tell you

  • One browser, one version. Retention behaviour is engine-specific; the closure/context result in particular is a V8 implementation detail, not a language guarantee.
  • Synthetic cycles are not user sessions. Real leaks often need a specific navigation order or an error path that a loop never exercises.
  • Forced GC is not real GC timing. It removes scheduling noise deliberately — which is what makes retention measurable, and what makes these numbers not a model of production pause behaviour.
  • Snapshots perturb what they measure. Taking one forces a GC and pauses the renderer; do not put snapshots inside the loop you are timing.
  • Nothing here covers workers, iframes, or other processes. Each has its own heap, and cross-process retention is invisible to all of the above.

Measured Results

Chrome for Testing 149.0.7827.55 (arm64 macOS), driven by playwright-core over CDP. Reproduce with cd ../src && npm run all. GC is forced via HeapProfiler.collectGarbage (two rounds) before every measurement.


1. Closure context retention

200 retained closures, each created in a scope holding a 50,000-element array. Arrays alone would cost ~76 MB if all were retained.

VariantReturned closureRetained heap
A — no sibling references big() => 420.01 MB
B — unused, never-called sibling references big() => 4238.17 MB
C — sibling present, plus big = null() => 420.01 MB

B retains 5,744× what A retains. The returned closure is identical in all three variants. Only the other declarations in the scope differ.

V8 allocates one Context per scope. A variable referenced by any closure in that scope becomes context-allocated, and every sibling closure retains the whole context.


2. Detached DOM, and the wrong instrument

5,000 <div> nodes built, attached, detached, then released.

Stagedetached nodesdetached bytesJS heap
built + attached00.0 KB0.84 MB
removed from DOM, JS refs held5000507.8 KB0.84 MB
JS refs released00.0 KB0.74 MB

Releasing 507.8 KB of DOM moved the JS heap by 0.10 MB.

DOM nodes live in Blink's C++ heap. Runtime.getHeapUsage and performance.memory report the JS heap only. A team checking performance.memory, seeing it flat, and concluding "no leak" has been misled by the instrument. Use the snapshot detachedness field or performance.measureUserAgentSpecificMemory().


3. Event listener leak — 300 mount/destroy cycles

Every variant constructs identical widgets and calls destroy(). Only listener lifecycle differs. Each variant runs on a fresh page (see harness note below).

Variantretained JS heapdetached nodes
anonymous listener, never removed23.00 MB300
removeEventListener in destroy()0.08 MB0
AbortController signal + abort()0.09 MB0

The anonymous-listener variant cannot be repaired later: there is no reference to the function that was registered. The AbortController form creates the teardown handle at registration time, which is why it is the better default — it is not faster, it is harder to get wrong.


4. Map vs WeakMap — 2,000 detached nodes as keys

Side tableretained JS heapdetached nodes still alive
Map38.34 MB2000
WeakMap0.09 MB0

Identical code except the constructor. A Map holds keys strongly, so every node it has ever seen is immortal, along with its metadata — unreachable from the document and from application code, and still not collectable.


5. Leak-detection method — 12 cycles × 60 views, GC between each

clean  ▁▁▁▁▁▁▁█████  0.68MB -> 0.69MB     slope 0.001 MB/cycle   R² 0.742
leaky  ▁▁▂▃▃▄▅▆▆▇██  2.51MB -> 22.71MB    slope 1.836 MB/cycle   R² 1.000

The clean run has R² = 0.742. Noise can look strongly correlated over a short series — which is exactly why slope alone is not evidence. The discriminator is the pair:

slopeverdict
clean0.001 MB/cycle0.742correlated noise at zero magnitude
leaky1.836 MB/cycle1.000linear growth driven by a repeated operation

Rules this encodes:

  1. Force GC between cycles, or you are measuring GC scheduling, not retention.
  2. Use identical repeated cycles, or slope is meaningless.
  3. Report slope and linearity. Neither alone supports a conclusion.

A high slope with low R² is usually a cache warming, a lazily-compiled path, or a heap that has not settled. Reporting that as a leak costs someone else a week.


Harness failure mode found while building this

Shared page across variants silently voids the experiment. The first listener-leak run measured all three variants on one page. The leaked widgets from variant 1 remained alive during variants 2 and 3, so the detached column read 300 / 300 / 300 — identical for the fixed variants and the broken one. The JS-heap column was still correct, which made the table look partially plausible and therefore harder to distrust.

Fix: a fresh page (fresh realm, fresh heap) per variant.

This is the same class of bug as the realm-reuse issue in fe-01: the harness produced clean, plausible, wrong data. Deliberately break the thing being measured and confirm the harness notices, before trusting any number it reports.

Verification Checkpoints

Reference environment: Chrome for Testing 149.0.7827.55, arm64 macOS. Ranges are the pass condition; exact bytes are machine-specific.


Checkpoint 1 — Closures retain contexts, not variables

npm run closures
  variant                            | retained heap
  A  no sibling references it        |    0.01MB
  B  unused sibling references it    |   38.17MB
  C  sibling + explicit big = null   |    0.01MB

Pass: B is at least 100× A. A and C are within noise of each other (< 0.5 MB). Fail — all three near zero: SIZE too small, or the engine optimised the arrays away. Raise SIZE/N. Fail — A is also large: you are retaining something else; check that __held is reset between variants.


Checkpoint 2 — The JS heap does not see DOM

npm run detached
  removed from DOM, JS refs held   |           5000 |        507.8KB |  0.84MB
  JS refs released                 |              0 |          0.0KB |  0.74MB

Pass: detached count goes 0 → ROWS → 0, while the JS-heap column changes by a much smaller amount than the detached-bytes column. Fail — detached always 0: the snapshot has no detachedness field (older V8). lib/heap.mjs reports hasDetachednessField; check it. Fall back to name-prefix matching only if false.

This checkpoint is the one to internalise: the instrument selects the conclusion.


Checkpoint 3 — Listener lifecycle, three variants

npm run listeners
  anonymous listener, never removed    |          23.00MB |            300
  removeEventListener in destroy()     |           0.08MB |              0
  AbortController signal + abort()     |           0.09MB |              0

Pass: the anonymous variant retains ≥ 20× the other two and shows CYCLES detached nodes while the other two show 0. Fail — all three show the same detached count: page isolation is broken; each variant must run on a fresh page. This exact bug occurred while building the module.


Checkpoint 4 — WeakMap weakens keys

npm run weakmap
  Map        |          38.34MB |                      2000
  WeakMap    |           0.09MB |                         0

Pass: Map retains ≥ 100× the WeakMap variant, and detached counts differ by ~N.


Checkpoint 5 — Slope and linearity together

npm run growth
clean  ▁▁▁▁▁▁▁█████  slope 0.001 MB/cycle   R² 0.742
leaky  ▁▁▂▃▃▄▅▆▆▇██  slope 1.836 MB/cycle   R² 1.000

Pass: leaky slope ≥ 0.5 MB/cycle with R² ≥ 0.95; clean slope < 0.05 MB/cycle. Note the clean R² is high — around 0.74 here — and that is the point. If your clean R² comes out low, run it again; over a short series it will sometimes be high. The checkpoint is that you can state why a high R² on the clean run is not alarming (the slope is at zero magnitude).


Checkpoint 6 — You can name the retainer path

Not a script. Open web/leaky-app.html, take two snapshots around a repeated action, use the Comparison view, and for each of the four seeded leaks write down:

  1. the object class that grew,
  2. the retainer path from a GC root,
  3. the single edge you would break to fix it,
  4. whether breaking that edge is a local fix or an architectural one.

Pass: four retainer paths written down, each naming a specific root. "It's a closure" is not a retainer path. window → appState.cache → Map → {…} → HTMLDivElement is.


Module completion

All six checkpoints, and:

  • steps/06-principal-review.md answered in writing
  • One leak found in a real codebase you work on, or a documented statement of why none was found and what you checked — the method transfers or it was not learned
  • ../fe-00-roadmap/docs/learning-log.md updated with any prediction you got wrong
  • A soak-test sketch: what you would assert, at what cadence, in CI (fe-33 picks this up)

Broader Ideas

Leaks are scheduling problems in disguise

The most valuable connection in this module runs back to fe-01. A growing heap makes major GC more frequent and longer. Major GC is a long task. So a leak presents as:

  • INP regressions with no change in the interaction path
  • jank that worsens the longer a session runs
  • "the app gets slow after a few hours", which is the specification's own case study

The diagnostic that separates them: a scheduling problem is reproducible on a fresh page load; a leak is not. If a performance complaint correlates with session age rather than with a release, take a heap trace before anyone touches a handler. Almost nobody checks session age unless it is written into the triage runbook — which is a Principal-level artifact, not a technical fix.

useEffect cleanup is an ownership declaration

Every framework lifecycle teardown hook is this module's ownership rule in framework clothing:

useEffect(() => {
  const ac = new AbortController();
  window.addEventListener('resize', onResize, { signal: ac.signal });
  return () => ac.abort();          // <- the ownership declaration
}, []);

The leaks happen where ownership is placed outside the hook: a subscription in a module body, a listener registered inside a promise chain that resolves after unmount, a cache written from an event handler. The framework cannot tear down what it does not know about.

This is also why the stale-closure bug and the leak bug are the same bug seen from two angles: a closure that outlives its component both reads stale state and retains that state. Developed in fe-10.

Query caches are side tables with a policy

A server-state cache (fe-16) is exactly the side-table shape from experiment 4, and the entire difference between a cache and a leak is the eviction policy:

PolicyMechanismWhen it is right
key lifetimeWeakMapentry is meaningful only while the key object lives
timeTTL / staleTime + gcTimeserver data with a known freshness window
countLRUbounded memory is the hard constraint
eventclear on logout / navigation / tenant switchcorrectness or privacy requires it

Libraries default to time-based policies, which is why an unconfigured cache is usually bounded but frequently wrong for the privacy case — cached data surviving a logout is a security finding, not a memory one. That crossover is picked up in fe-25.

Soak testing is the only automation that catches this

Leaks need time, and no normal test spends it. The shape that works:

// nightly, not PR CI: slow, and mildly flaky by nature
const samples = [];
for (let i = 0; i < 30; i++) {
  await doTheRepeatedThing(page);
  await cdp.send('HeapProfiler.collectGarbage');
  samples.push((await cdp.send('Runtime.getHeapUsage')).usedSize);
}
const { slope, r2 } = regression(samples.slice(5));   // discard warm-up
expect(slope).toBeLessThan(BUDGET_BYTES_PER_CYCLE);

Three design decisions carry the value: discard the warm-up samples, assert on slope rather than absolute heap, and pair the slope assertion with an R² sanity check so a noisy run fails loudly rather than silently passing. Assert detached-node counts too where the leak is DOM-shaped — counts are exact where byte counts are not. Picked up properly in fe-33.

Memory as a budget, not an alarm

Performance budgets (fe-11) usually cover bytes over the wire and milliseconds on the main thread, and stop there. A mature budget includes memory, expressed the way this module measures it:

  • bytes per repeated operation (slope), not total heap
  • detached node count after teardown — should be zero, and zero is testable
  • listener count stability across mount/unmount cycles
  • a session-length assumption stated explicitly, since slope only becomes a number that matters when multiplied by it

The reason to express it this way is that total heap is not actionable and varies with content, while slope attributes growth to a specific operation someone owns.

WeakRef and FinalizationRegistry

Deliberately not used in this module's experiments, because they are almost always the wrong tool and appear in interviews far more often than in good code. FinalizationRegistry callbacks are not guaranteed to run at all, may run arbitrarily late, and must never be used for correctness — only for opportunistic cleanup of external resources. If a design needs to know when something was collected, the design is wrong; make the lifetime explicit instead.

The legitimate uses are narrow: caches of expensive derived values where recomputation is acceptable, and releasing non-memory resources tied to objects the GC owns.

Step 1 — Reachability, and What Closures Actually Retain

Goal

Replace "the GC frees what you stop using" with "the GC frees what is unreachable", and discover that closures retain contexts, not variables.

Prerequisites

  • fe-01 complete
  • cd src && npm install (or the symlinked node_modules from fe-01)

Predict first

Three factories. All three return the identical closure () => 42. You keep 200 of the returned closures alive.

// A
const makeClean = () => {
  const big = new Array(50000).fill('x');
  return () => 42;
};

// B
const makeLeaky = () => {
  const big = new Array(50000).fill('x');
  const unusedSibling = () => big.length;   // never called, never returned
  return () => 42;
};

// C
const makeFixed = () => {
  let big = new Array(50000).fill('x');
  const unusedSibling = () => big.length;
  big = null;
  return () => 42;
};

Write down the retained heap for each. Commit to numbers.

Run

npm run closures

Expected output:

  variant                            | retained heap
  -----------------------------------|---------------
  A  no sibling references it        |    0.01MB
  B  unused sibling references it    |   38.17MB
  C  sibling + explicit big = null   |    0.01MB

  B retains 5744x what A retains.

What just happened

V8 allocates one Context per scope. If any closure created in that scope references a variable, that variable is context-allocated — moved from the stack into a heap object shared by every closure from that scope. The returned () => 42 holds the Context, and the Context holds big.

unusedSibling is never called and never escapes. It does not need to run, or even be reachable by name, to have caused big to be context-allocated at compile time.

Three consequences worth carrying:

  1. "My callback doesn't reference that variable" is not a defence. The retaining code is a sibling function, often written by someone else, often the innocuous-looking one.
  2. x = null is sometimes load-bearing, not cargo cult — variant C. The distinguishing question is whether a surviving closure shares the scope. If none does, nulling is noise.
  3. This is a V8 implementation detail, not a language guarantee. Other engines may allocate contexts differently. Record it as engine behaviour, with the version, in the verification log.

The general rule this establishes and the rest of the module builds on:

Nothing is freed because you stopped using it. Things are freed because nothing can reach them.

Every remaining experiment is an instance of that sentence.

Checkpoint

docs/verification.md Checkpoint 1.

Going deeper

N=1000 SIZE=100000 npm run closures

Does B scale linearly with N? With SIZE? What does that tell you about whether the Context is shared per call or per scope?

Step 2 — Detached DOM, and Why Your Instrument Lied

Goal

Find a detached-DOM leak, and learn that the tool most teams reach for cannot see it.

Prerequisites

  • Step 1 complete

Predict first

5,000 <div> elements are created, appended to the document, then removed with host.replaceChildren() — while an array holds a JS reference to every one.

  1. After removal, are the nodes collectable?
  2. How much will the JS heap change when the array is finally cleared?
  3. Would performance.memory reveal this leak?

Run

npm run detached

Expected output:

  stage                            | detached nodes | detached bytes | JS heap
  ---------------------------------|----------------|----------------|--------
  built + attached                 |              0 |          0.0KB |  0.84MB
  removed from DOM, JS refs held   |           5000 |        507.8KB |  0.84MB
  JS refs released                 |              0 |          0.0KB |  0.74MB

What just happened

Removing a node from the document does not free it. It only removes one reference path — the one from document. The array still reaches every node, so every node is retained, along with its attributes, text nodes, and any listeners attached to it.

And the JS heap barely moved. Freeing 507.8 KB of DOM changed the JS heap by 0.10 MB, because DOM nodes live in Blink's C++ heap (Oilpan), not V8's. Runtime.getHeapUsage, performance.memory, and every "check the heap size" reflex report the JS heap only.

This is the module's central methodological point:

A team checks performance.memory, sees a flat line, concludes "no leak", and stops looking. The instrument selected the conclusion.

What actually sees it:

InstrumentSees detached DOM?
performance.memory / Runtime.getHeapUsageNo
Heap snapshot (detachedness field)Yes — exact counts
performance.measureUserAgentSpecificMemory()Yes, but needs COOP+COEP
DevTools Memory panel, filter DetachedYes — with retainer paths

Detached subtrees retain their whole subtree. One held reference to a leaf can retain thousands of ancestors. This is why virtualized lists (fe-30) turn a small mistake into an incident.

Checkpoint

docs/verification.md Checkpoint 2.

Going deeper

ROWS=50000 npm run detached

Then ask: how large would this leak need to be before performance.memory moved enough to notice — and would the page still be usable at that point?

Step 3 — Listeners, Timers, and Who Owns Teardown

Goal

Measure the classic component leak and establish why one of the two working fixes is structurally better than the other.

Prerequisites

  • Steps 1–2 complete

Predict first

A Widget appends an element, holds a 20,000-element payload, registers a resize listener on window, and is then destroyed 300 times. Three listener strategies:

// leak   — anonymous listener on a long-lived target
window.addEventListener('resize', () => this.onResize());

// manual — keep the reference, remove it in destroy()
this._h = () => this.onResize();
window.addEventListener('resize', this._h);
// destroy: window.removeEventListener('resize', this._h)

// abort  — signal created at registration
this._ac = new AbortController();
window.addEventListener('resize', () => this.onResize(), { signal: this._ac.signal });
// destroy: this._ac.abort()

All three call destroy(), and destroy() always calls this.el.remove(). Predict retained heap and detached-node count for each.

Run

npm run listeners

Expected output:

  variant                              | retained JS heap | detached nodes
  -------------------------------------|------------------|---------------
  anonymous listener, never removed    |          23.00MB |            300
  removeEventListener in destroy()     |           0.08MB |              0
  AbortController signal + abort()     |           0.09MB |              0

What just happened

The retainer path is:

window → (event listeners) → the arrow function → its context → the Widget → this.data + this.el

window is a GC root and lives forever. The listener closes over this. So the Widget, its 20,000-element payload, and its detached DOM element are all immortal — even though destroy() ran and removed the element from the document.

Both fixes work. They are not equivalent.

The anonymous form is unrepairable after the fact: there is no reference to the registered function, so no later code can remove it. That is not a bug you fix in a follow-up commit; it is a bug you fix by rewriting the registration.

removeEventListener has three independent failure modes: forgetting the call, losing the reference, and passing different options than at registration. AbortController has one teardown call regardless of how many listeners were registered, the handle exists at registration time, and it composes with fetch() and every other signal-aware API.

This is "make invalid states difficult to represent" applied to lifecycle rather than to data — which is where the heuristic usually pays most.

Timers are the same shape. setInterval(() => this.refresh(), 1000) is a registration on a long-lived registry that closes over this. Same retainer path, same fix altitude.

Framework connection (previews fe-10): useEffect cleanup, ngOnDestroy and onUnmounted are ownership declarations. Leaks happen when the subscription is created outside the lifecycle hook — in a module body, inside a promise chain that resolves after unmount, in an event handler. The framework cannot tear down what it was never told about.

Checkpoint

docs/verification.md Checkpoint 3.

Note on the harness

Each variant runs on a fresh page. The first version of this experiment shared one page, so leaked widgets from variant 1 stayed alive through variants 2 and 3 and every row reported 300 detached nodes. The JS-heap column was still right, which made the table look partly plausible. See docs/measured-results.md.

Step 4 — Side Tables, Caches, and the Limits of WeakMap

Goal

Measure the most common accidental cache leak, and establish when WeakMap is not the answer.

Prerequisites

  • Steps 1–3 complete

Predict first

2,000 DOM nodes are created, used as keys in a module-level side table with per-node metadata, then detached and dereferenced. Two variants, identical except:

const store = new Map();       // vs
const store = new WeakMap();

Predict retained heap and surviving detached-node count for each.

Run

npm run weakmap

Expected output:

  side table | retained JS heap | detached nodes still alive
  -----------|------------------|---------------------------
  Map        |          38.34MB |                      2000
  WeakMap    |           0.09MB |                         0

What just happened

A Map holds its keys strongly. Every node the map has ever seen is immortal, along with the metadata hanging off it. The nodes are unreachable from the document and from application code, and still not collectable — the map is the only thing keeping 38 MB alive.

This is the shape of most accidental caches: a module-level Map, Set, registry, or id → object index that nothing ever deletes from.

A cache without an eviction policy is a leak with better branding.

But WeakMap is not a default. Five limits worth knowing before reaching for it:

  1. It weakens the key, not the value. wm.set(node, { node }) retains everything, because the value's reference to the key is strong. Easy to write, hard to see.
  2. Not enumerable. No .size, no iteration, no dump during an incident. You have traded a visible leak for an invisible cache.
  3. Object keys only. Anything keyed by an id string cannot use it.
  4. Non-deterministic collection. You cannot write a test asserting an entry was freed — only that growth is bounded, which is a weaker claim.
  5. It implements one policy: "when the key dies". If entries should expire on logout, on navigation, or after 30 seconds, WeakMap implements none of that.

The decision rule:

The entry should die when…Use
its key object diesWeakMap
a fixed time passesTTL cache
memory is the binding constraintLRU
a specific event occurs (logout, tenant switch)explicit clear-on-event

Point 5 crosses into security: cached user data surviving a logout is a privacy finding, not a memory one, and WeakMap would not have fixed it. Picked up in fe-25.

Checkpoint

docs/verification.md Checkpoint 4.

Step 5 — The DevTools Hunt

Goal

Find four seeded leaks in an unfamiliar application using only the Memory panel, and write a retainer path for each. This is the step that transfers to real work.

Prerequisites

  • Steps 1–4 complete

Setup

cd src
npm run serve      # http://localhost:8081/leaky-app.html

An SPA-shaped app with three routes. web/leaky-app.html contains four distinct leaks. Do not read the source until you have found them from evidence — reading it spends the exercise, and the exercise is the deliverable.


The workflow, in the order that works

  1. Establish the repeated action. Navigating between routes. It is idempotent: after one navigation the app is in the same logical state.
  2. Warm up. Click Navigate ×10 once and discard it. Caches and lazy compilation are not leaks, and skipping this is how false positives are born.
  3. Snapshot 1. DevTools → Memory → Heap snapshot. It forces a GC first, so you do not need to.
  4. Click Navigate ×50.
  5. Snapshot 2.
  6. Comparison view, sorted by Delta. Look for classes whose count grew by exactly 50 or a multiple of it. That proportionality is the signal — noise does not arrive in multiples of your loop count.
  7. Select an instance → Retainers panel. Walk the path to a root. This is the finding.
  8. Filter the class list by Detached to isolate detached DOM directly.

Deliverable

For each of the four leaks, write down:

#Class that grewRetainer path from a GC rootEdge to breakLocal or architectural?
1
2
3
4

"It's a closure" is not a retainer path. A retainer path names the root and every hop: window → appState.cache (Map) → {…} → HTMLDivElement.

Hints, in increasing order of spoiler

Hint 1 — how many kinds am I looking for?

Four leaks, four different shapes from CONCEPTS.md §3. If you have found two of the same shape, you are missing two others.

Hint 2 — one is invisible in the JS heap

Filter by Detached. Step 2 told you why the JS-heap number will under-report it.

Hint 3 — two involve registrations on long-lived targets

window is a GC root. So is the timer registry. Both hold closures. destroy() addresses neither — and it looks complete, which is the point.

Hint 4 — two are collections that only grow

Look at the #stats line in the app. It is telling you the size of both of them on every navigation, in plain text. Most people do not read it.

Then fix them

Fix each leak, re-run the comparison, and confirm the deltas are gone. For each fix, state whether it is local (one line moved) or architectural (someone now owns a policy). Two of these four are architectural — being able to say which is the Principal-level part of the exercise.

Debugging exercise

Run the app under the specification's incident protocol as though it were a production report of "the dashboard gets slow after a few hours":

  1. What do you investigate first?
  2. What evidence do you need?
  3. What hypotheses exist? — include at least one that is not a leak
  4. What experiments distinguish them?
  5. What mitigation is appropriate? — before the fix ships
  6. What permanent fix is appropriate?
  7. What systemic change prevents recurrence?

Question 3 is the one that separates levels. "GC pauses from a leak" and "a genuine long task" present identically to a user, and the discriminator is cheap: a leak correlates with session age, a scheduling problem reproduces on a fresh page load. If that check is not in the triage runbook, nobody performs it — which makes question 7's answer a document, not a code change.

Checkpoint

docs/verification.md Checkpoint 6.

Step 6 — Growth Detection: Telling a Leak From Noise

Goal

Turn "the heap went up" into a defensible claim, and design the only automation that catches leaks.

Prerequisites

  • Steps 1–5 complete

Predict first

Two workloads, 12 cycles each, 60 components created and destroyed per cycle, GC forced between every cycle. One registers each component in a module-level array; one does not.

  1. What slope (MB/cycle) do you expect for each?
  2. What do you expect for the clean run?

Most people answer the second question "near zero". Commit to a number before running.

Run

npm run growth

Expected output:

  clean  ▁▁▁▁▁▁▁█████  0.68MB -> 0.69MB
         slope 0.001 MB/cycle   R² 0.742

  leaky  ▁▁▂▃▃▄▅▆▆▇██  2.51MB -> 22.71MB
         slope 1.836 MB/cycle   R² 1.000

What just happened

The clean run has R² = 0.742. Over a short series, noise routinely looks strongly correlated. If R² alone were your test, you would have just reported a leak that does not exist.

Slope alone is equally insufficient — caches warming, lazy compilation, and an unsettled heap all produce real positive slopes for a while.

The claim requires both, read together:

slopeInterpretation
highhighreal leak driven by the repeated operation
highlowwarming / lazy compilation / unsettled heap — not yet a finding
lowhighsteady tiny growth; quantify against session length before acting
lowlownoise

Two further requirements before the claim is honest:

  • Magnitude against session length. 0.05 MB/cycle means nothing until multiplied by how many cycles a real session performs. 4 MB over a working day does not justify a sprint; 400 MB does.
  • A control. Run the same harness against a path you believe is clean. Without one you cannot distinguish your application leaking from your harness leaking. Harnesses leak.

Three rules the experiment encodes:

  1. Force GC between cycles, or you are measuring GC scheduling, not retention.
  2. Use identical repeated cycles, or slope is meaningless.
  3. Report slope and linearity. Neither alone supports a conclusion.

Design the soak test

Leaks need time, and no normal test spends it. Unit tests do not. Component tests do not. E2E suites rarely exceed a minute. Sketch the automation:

// nightly, NOT PR CI: slow by nature, and mildly flaky by nature
const samples = [];
for (let i = 0; i < 30; i++) {
  await doTheRepeatedThing(page);
  await cdp.send('HeapProfiler.collectGarbage');
  samples.push((await cdp.send('Runtime.getHeapUsage')).usedSize);
}
const { slope, r2 } = regression(samples.slice(5));      // discard warm-up
expect(slope).toBeLessThan(BUDGET_BYTES_PER_CYCLE);
expect(detachedNodeCount).toBe(0);                        // exact where bytes are not

Write down, for an application you actually work on:

  1. Which repeated action would you soak? Why that one?
  2. What is your budget in bytes per cycle, and how did you derive it from an assumed session length rather than from taste?
  3. Where does it run — and why not in PR CI?
  4. What does it assert besides slope? (Detached counts are exact; byte counts are not.)
  5. How do you stop it becoming a flaky test everyone disables within a month?

Question 5 is the one that decides whether this exists in twelve months. A soak test that fails noisily gets muted; one that reports a trend and only fails on a sustained regression survives. Picked up properly in fe-33.

Checkpoint

docs/verification.md Checkpoint 5.

Step 7 — Principal Engineer Review

Answer in writing. Several have defensible answers on more than one side; "it depends" is acceptable only if you say on what.


1. Users report the dashboard "gets slow after a few hours". Nothing reproduces on a fresh load. Give three hypotheses — at least one that is not a memory leak — and the cheapest experiment that distinguishes them. What single field would you add to your triage runbook so the next person does not have to be clever?

2. An engineer reports a leak: "heap grew 40 MB over ten minutes of use." What do you ask before accepting it? Your measurement in Step 6 produced R² = 0.742 on a clean workload — use it.

3. A teammate proposes a lint rule requiring every addEventListener to have a matching removeEventListener. Make the strongest case for, then against. What would you actually ship, and what would it fail to catch?

4. Your design system ships a useResizeObserver hook used by 40 teams. Specify its teardown contract. What must it not let callers do? What happens if a caller stores the observed element in a module-level Map — is that your problem, and how would you know it happened?

5. A team fixes a detached-DOM leak by adding element = null in 30 places. It works. What is your review comment? Under what circumstances is the diff actually correct?

6. You must choose between WeakMap and an LRU cache for a query cache keyed by request objects. What information decides it? Give a scenario where WeakMap is dangerous rather than merely insufficient.

7. Memory is not in your organisation's performance budget. Propose an addition. What is measured, what is the threshold, where is it enforced, and what makes it resistant to being disabled after the third false positive?

8. A third-party analytics script retains DOM across route changes: ~2 MB per navigation. Legal requires the script. Enumerate your options in ascending blast radius, say which you propose, and say how you would get agreement from the team that owns the relationship.

9. Your soak test has failed intermittently for three weeks. Nobody has investigated. What went wrong — technically and organisationally — and what do you change first?

10. Explain to a senior engineer why a memory leak can present as an INP regression, and why optimising the interaction handler will not help. What one measurement settles it?

11. WeakRef and FinalizationRegistry are proposed to fix a cache. Explain why this is almost certainly wrong, and name the narrow cases where it is right.

12. You have one week to reduce leak risk across a 200-engineer frontend organisation. You cannot review every codebase. What do you do, and what do you deliberately not do?


Record

File answers as RESULTS.md in the module directory. Questions 4, 7 and 12 are natural ADRs — write at least one in ../fe-00-roadmap/docs/decisions/ using the seven-field template, and make the reversibility and migration sections real rather than perfunctory.

Anything unanswerable goes in ../fe-00-roadmap/docs/learning-log.md §2 as an open question.

Concepts — Object Shapes, Hidden Classes, Inline Caches & JIT

Phase 1 · Platform substrate · Specification area §1 (object shapes, hidden classes, inline caches, JIT compilation). Parallel-safe with fe-04–fe-07.

This module's thesis is unusual: its main deliverable is knowing when to ignore it. The effects are real and reproducible. In frontend code they are almost always the wrong thing to work on, and being able to say so with numbers is the Principal-level skill here.

1. What is it

V8 does not store JavaScript objects as hash maps. It infers structure and specialises:

  • Hidden classes (Maps/Shapes) describe an object's layout — which properties exist, in what order, at which offsets. Objects built identically share one hidden class.
  • Inline caches (ICs) memoise, at each property-access site, which shapes have been seen there and where the property lives. One shape means a direct offset load.
  • Element kinds specialise array storage by content: small integers, doubles, or general elements; packed or holey. Transitions are one-way.
  • Tiered JIT — an interpreter (Ignition) plus optimising compilers (Sparkplug/Maglev/TurboFan) that speculate on observed types and deoptimise when the speculation breaks.

All of it is speculation on consistency. Consistent code runs fast; inconsistent code falls back.

2. Why it matters

Three legitimate reasons, and one illegitimate one worth naming.

Legitimate — you own a hot data path. Virtualized tables, canvas/WebGL render loops, parsers, diffing algorithms, worker-side transforms over 10⁵–10⁷ items. Here per-element costs multiply by enough to matter (fe-30).

Legitimate — you are choosing a data representation. Array-of-objects vs object-of-arrays, Map vs plain object as a dictionary, whether to delete. These are decisions made once and hard to reverse, so making them from knowledge rather than folklore is cheap.

Legitimate — you must arbitrate. An engineer proposes a refactor for shape stability. You need to price it against alternatives in the same units, and say no with evidence rather than taste.

Illegitimate — it feels like real performance work. It is measurable, satisfying, and tractable, which makes it a magnet for effort that belongs elsewhere. Measured here: removing one forced layout from a render path is worth more than de-megamorphising 1,448 property accesses. That number is the module.

3. How it works

+==================================================================+
|  HIDDEN CLASSES — structure inferred from construction order      |
+==================================================================+

   {}          --add x-->   {x}        --add y-->   {x,y}
   Shape0                   Shape1                  Shape2

   const a = { x: 1, y: 2 };     ->  Shape0 -> Shape1 -> Shape2
   const b = { y: 2, x: 1 };     ->  Shape0 -> Shape1'-> Shape2'
                                        DIFFERENT final shape

   delete o.tmp                  ->  DICTIONARY MODE (no shape at all)


+==================================================================+
|  INLINE CACHES — per-access-site memory of shapes seen            |
+==================================================================+

   function read(o) { return o.v; }
                        ^ this site has its own IC

   1 shape seen    MONOMORPHIC   direct offset load          fastest
   2-4 shapes      POLYMORPHIC   short linear shape check    ~equal
   5+ shapes       MEGAMORPHIC   global stub-cache hash      ~2x slower


+==================================================================+
|  TIERED EXECUTION                                                 |
+==================================================================+

   Ignition (interpreter)
        | hot
   Sparkplug (baseline)  ->  Maglev  ->  TurboFan (optimising)
        ^                                     |
        +------------- DEOPTIMISE ------------+
              speculation violated: a shape, type, or
              element kind the compiler assumed away

The measured effects

EffectMeasuredNotes
Megamorphic vs monomorphic read1.9× (3.8 ns vs 0.9 ns)ratio looks alarming, absolute is 2.9 ns
Alternating property order1.14×far smaller than folklore suggests
delete o.tmp → dictionary mode11.9×the one that genuinely bites
o.tmp = undefined instead0.98×keeps the shape; free
Array element kinds1.70× spread~1 ns/element; ordering counterintuitive

4. Core terminology

TermDefinition
Hidden class / Map / ShapeV8's internal descriptor of an object's layout
Transition chainThe path of shapes produced by adding properties in a given order
Inline cache (IC)Per-site cache of shape → property offset
Monomorphic / polymorphic / megamorphic1 / 2–4 / 5+ shapes observed at one access site
Stub cacheThe global hash table a megamorphic site falls back to
Dictionary modeHash-map storage after delete or too many properties; no shape, no IC
Element kindArray storage specialisation: PACKED_SMI, PACKED_DOUBLE, PACKED_ELEMENTS, and holey variants
SMISmall integer; a tagged 31-bit value stored without heap allocation
DeoptimisationBailing from optimised code back to the interpreter when speculation fails
Ignition / Sparkplug / Maglev / TurboFanV8's interpreter and three compiler tiers
OSROn-stack replacement — swapping a running function to optimised code mid-loop

5. Mental models

The engine bets on consistency. Every optimisation is a wager that the future resembles the past: same shapes, same types, same element kinds. You do not make code fast by adding cleverness; you avoid making it slow by not surprising the engine.

Ratios seduce; absolutes decide. "2× slower" is the most misleading phrase in performance work. The megamorphic penalty is 1.9× — and 2.9 nanoseconds. Always convert to absolute cost per operation, then multiply by the real operation count, then compare against what else is on the critical path. Doing this once will kill most micro-optimisation proposals, including your own.

Micro-benchmarks lie by default. Three of the four experiments here produced confidently wrong results before they produced right ones — a shared function accumulating IC state across cases, a DOM case leaving 5,000 children for the next case, and an accumulator overflowing SMI range. Each looked plausible. Assume your first benchmark is wrong and try to break it.

Optimise the tree, then the leaf. Frontend cost lives in the network, the DOM, layout, and scheduling — in that order, by orders of magnitude. Shape optimisation is a leaf-level concern and belongs after the tree is right, which it rarely is.

6. Common misconceptions

  1. "Property order matters a lot." It measured 1.14×. It is real, cheap to get right, and almost never the reason anything is slow. Treat it as hygiene, not optimisation.

  2. "delete is fine, it's just a keyword." The one folklore item that holds: 11.9×, from dictionary mode. o.tmp = undefined measured 0.98×. If you must remove keys from hot objects, rebuild the object or use a Map.

  3. "Megamorphic call sites are a crisis." 2.9 ns per read. You need ~1,448 of them to equal one forced layout. Fix it when it is free; never schedule work for it without a profile.

  4. "Map is always slower than a plain object." Map is designed for dynamic keys, never enters dictionary mode, and supports deletion without shape damage. For a genuine dictionary it is usually the better choice — the plain-object habit is a shape hazard.

  5. "The JIT will optimise my code." It will specialise consistent code. It cannot fix an algorithm, a request waterfall, or layout thrashing, and those dominate frontend cost.

  6. "Benchmark ratios transfer between engines and versions." These are V8 implementation details on one version. JavaScriptCore and SpiderMonkey differ. Record the version with any number you keep — this module's verification log does.

7. Interview talking points

  • "I convert every micro-optimisation claim to absolute nanoseconds and multiply by the real operation count before discussing it. Megamorphic access is 1.9× slower, which sounds urgent, and 2.9 ns, which usually isn't — one forced layout costs about 1,448 of them."
  • "The one piece of shape folklore that survives measurement is delete: 11.9× from dictionary mode, versus 0.98× for assigning undefined. Property order was 1.14×, which is hygiene, not a refactor."
  • "Shape stability matters where per-element cost multiplies — virtualized lists, canvas loops, worker transforms over millions of rows. In component code it's noise, and I'd rather the team spend that attention on request waterfalls."
  • "I assume my first micro-benchmark is wrong. Building this module, three of four produced confident, plausible, backwards results from shared IC state, DOM contamination, and an accumulator type transition. Isolation per case is not optional."
  • "Map versus plain object is a shape question, not a style question. If keys are dynamic and get deleted, a plain object drops into dictionary mode and a Map doesn't."

8. Connections to other modules

  • fe-01 — the perspective table is priced in the same units as scheduling work; forced layout appears in both modules as the dominant avoidable cost.
  • fe-02 — dictionary mode and shape transitions change retained size as well as speed; both are consequences of object representation.
  • fe-14 (UI algorithms) and fe-30 (large-scale UI) — where per-element cost genuinely multiplies, and therefore the only places this module's findings should change a design.
  • fe-28 (workers) — structured clone cost depends on object representation; shape-stable, transferable-friendly data is the real optimisation at a worker boundary.
  • browser-framework-internals.md §17 (V8 Integration) — cross if you want to read the IC implementation rather than measure its behaviour.

References — Object Shapes, ICs & JIT

Primary / engine

Measurement method

Counterweight — read these alongside

  • Donald Knuth, Structured Programming with go to Statements (1974) — the actual "premature optimisation" passage, including the 3% it is usually stripped from.
  • web.dev — Optimize long tasks — where frontend time really goes.
  • Paul Irish, What forces layout / reflow — the 4,200 ns line item in the perspective table.

Deliberately excluded

"JavaScript performance tips" listicles. They propagate property-order folklore (measured: 1.14×) while omitting delete (measured: 11.9×), and none of them price their advice against a forced layout. The perspective experiment exists specifically to inoculate against this genre.

Analysis

When this actually matters

The honest list. If your situation is not on it, the perspective table applies and you should be working on something else.

SituationWhy the arithmetic changesExample
Per-element cost × 10⁵–10⁷3 ns becomes 30 msvirtualized table over 10M cells (fe-30)
Per-frame budget of 16.7 mssmall constants recur 60×/scanvas/WebGL render loop, physics
Hot library internalsyour users' users pay itreconciler, parser, diff, state store
Worker transformsdominated by data representationcolumnar processing, structured clone (fe-28)
Data representation decisionsone-way and expensive to reversearray-of-objects vs object-of-arrays; Map vs object

Everywhere else — component code, event handlers, data fetching, form logic, business rules — the effects are real and swamped by DOM, layout, network and scheduling.

The test to apply before starting: what is the operation count, what is the absolute per-op saving, and what else is on the same critical path? If you cannot answer all three, you are not ready to spend anyone's time.

Ratio vs absolute: the arithmetic that ends most debates

A proposal to "fix megamorphic access in the render path" sounds serious. Price it:

penalty            2.9 ns per read
reads per render   50,000            (generous for a component tree)
saving             0.145 ms per render

0.145 ms against a 16.7 ms frame budget: 0.9%. Now price the alternative:

one forced layout removed   4,200 ns = 0.0042 ms

...which sounds worse, until you count how many forced layouts a thrashing render path performs. At 800 elements interleaving reads and writes, fe-01 measured 86 ms. The shape refactor buys 0.145 ms; removing the thrash buys 86 ms. Same engineer, same week, 590× the return.

This arithmetic is reusable. Convert to absolutes, multiply by real counts, compare against alternatives on the same path. Most micro-optimisation proposals do not survive it — including ones you will be tempted by.

delete is the exception worth acting on

11.9×, and the fix is free. It is worth a lint rule where objects are hot, because it is the rare case where the folklore, the measurement, and the cost of compliance all align.

// dictionary mode: 11.93x
const o = { x, y, z, tmp: 0 }; delete o.tmp;

// shape preserved: 0.98x
const o = { x, y, z, tmp: 0 }; o.tmp = undefined;

// no phantom key at all — usually the right answer
const { tmp, ...rest } = o;

// dynamic keys that get deleted: this is what Map is for
const m = new Map(); m.set(k, v); m.delete(k);

The third form allocates a new object, which is the correct trade in almost all frontend code and the wrong one in a hot loop. The fourth is the structural answer: if keys are dynamic and deleted, a plain object is being used as a dictionary and Map is the type that was designed for it.

The polymorphic→megamorphic cliff

The IC curve is not linear, and the shape of it changes what you do:

1 shape   1.00x
2 shapes  1.00x   <- free
4 shapes  1.23x
8 shapes  1.96x   <- cliff crossed
16 shapes 1.94x   <- flat; already in the stub cache

Two consequences:

  • Going from 1 to 4 shapes is nearly free. Refactoring to make a site strictly monomorphic is usually not worth it.
  • Going from 8 to 80 shapes costs nothing further. Once a site is megamorphic, "reducing" shape variety buys nothing unless you get back under the threshold — which is rarely achievable in code that legitimately handles heterogeneous data.

So the only actionable version is: avoid crossing the cliff in code that is genuinely hot. Once crossed, stop optimising and go read the perspective table.

Why Map deserves rehabilitation

The folklore ("objects are faster than Maps") comes from benchmarks with static string keys, where an object's shape is stable and its IC monomorphic. That is the case Map was never for.

UseBetter choiceWhy
Fixed, known keysplain objectstable shape, monomorphic ICs
Dynamic keys from dataMapno shape churn, no dictionary-mode cliff
Keys added and deletedMapdelete on an object costs 11.9×
Non-string keysMapobjects coerce keys to strings
Needs size / iteration orderMap.size is O(1); insertion order guaranteed
Keyed by object lifetimeWeakMapsee fe-02

A plain object accumulating user IDs as keys is a shape hazard and a dictionary-mode candidate and a leak candidate. Three separate modules point at the same refactor.

What breaks at scale

  • Deoptimisation loops. A function repeatedly optimised then deoptimised by an unexpected type is far worse than never optimising. These are invisible without --trace-deopt, which means a frontend engineer will essentially never find one — which is itself the argument for not speculating about JIT behaviour in code review.
  • Benchmarks that stop resembling the application. A micro-benchmark runs one shape through one site. Production runs your component with props from twelve call sites. Shape-stability results from a benchmark routinely fail to reproduce in the app, and the benchmark is the thing that is wrong.
  • Framework internals move the ground. Which objects a framework allocates per render, and whether they are shape-stable, is a framework implementation detail that changes between minor versions. Building application-level optimisations on assumptions about it creates work that silently expires.
  • Engine divergence. These are V8 behaviours. Safari (JavaScriptCore) and Firefox (SpiderMonkey) differ in IC design, element-kind handling, and tiering. Any optimisation justified by numbers from one engine needs a statement about the other two before it becomes a standard.
  • The opportunity cost is the real risk. The failure mode of this module is not writing slow code; it is a senior engineer spending a sprint on shapes while a request waterfall, an unindexed list render, and a 200 KB JSON parse sit untouched on the same critical path.

Execution Guide

Tool versions

ToolVersion used
Node.js23.11.0
playwright-core1.62.1
Chrome for Testing149.0.7827.55 (arm64)

Browser resolution as in fe-01: $CHROME, newest Playwright cache build, then system Chrome.

Quick start

cd fe-03-object-shapes-and-jit/src
npm install
npm run ic            # 1. inline caches: mono/poly/megamorphic    (~15s)
npm run shapes        # 2. hidden classes, delete, dictionary mode (~20s)
npm run arrays        # 3. array element kinds                     (~25s)
npm run perspective   # 4. what any of it is worth                 (~40s)
npm run all

Run perspective even if you skip the others. It is the module.

Tuning

ITER=10000000 npm run ic
N=1000000     npm run shapes
N=20000000    npm run arrays

Why every case gets a fresh page

Micro-benchmarks contaminate each other in ways that are silent and directional:

  • Inline caches are per-site and persist. A shared helper function accumulates feedback across cases, so case 1 pays warm-up and case 4 inherits a warm-but-polymorphic function. This produced a backwards result while building the module.
  • JIT tier is per-function and persists. By case 3, a function may be in TurboFan; case 1 ran in Sparkplug.
  • The DOM persists. A case that appends 5,000 elements makes every later layout measurement enormous.

Fresh page per case costs ~150 ms of setup and removes all three. It is not optional.

Reading the harness

lib/bench.mjs addresses three standard micro-benchmark failures:

FailureMitigation
Dead-code elimination removes the workevery benchmark returns a value, accumulated into window.__sink
Cold JIT dominates the first sample3 warm-up runs before measurement
Single-sample noisemedian of 7 reps, with min/max retained

It does not address: timer coarsening (performance.now() is quantised to ~0.1 ms in non-isolated contexts, so keep per-rep work above ~5 ms), thermal throttling on long runs, or other processes on the machine.

A warning about editing the page templates

Page scripts are embedded in JS template literals. A backtick anywhere inside them — including inside a comment — terminates the template and produces a syntax error reported at a confusing line. This happened twice while building this module. Write the (a[i] || 0) form, never `a[i] || 0`, inside a page template.

Observation Guide

Seeing shapes and ICs directly

The measurements in this module are black-box. To see the mechanism, use V8 flags via node (same engine, no browser needed):

node --allow-natives-syntax -e '
  const a = { x: 1, y: 2 };
  const b = { y: 2, x: 1 };
  console.log(%HaveSameMap(a, b));        // false — different transition chains
  const c = { x: 1, y: 2, tmp: 0 };
  delete c.tmp;
  console.log(%HasFastProperties(c));     // false — dictionary mode
'
FlagShows
--allow-natives-syntaxenables %HaveSameMap, %HasFastProperties, %DebugPrint
--trace-icevery IC state transition (very verbose)
--trace-deoptdeoptimisations and their reasons
--trace-optwhich functions get optimised, and when
--print-opt-codegenerated machine code (rarely useful in practice)

%DebugPrint(obj) prints the hidden class and property layout. This is the fastest way to confirm a shape hypothesis, and far more reliable than inferring it from timings.

These are debugging tools, not decision tools. Nothing here tells you whether an optimisation is worth doing — only what the engine is doing. The perspective table answers "worth it".

Chrome DevTools

The Performance panel's bottom-up view attributes time to functions, not to shapes. There is no "megamorphic access" marker, and this is deliberate: at 2.9 ns, it would be noise in any real profile.

What you can see, and what actually matters:

SignalWhereMeaning
Large "Scripting" blocksflame chartworth investigating — but usually algorithmic, not shapes
Forced reflow warningsred triangle4,200 ns each. Fix these first
Long JSON.parse / Recalculate Styleflame chart~400,000 ns and up. Fix these before anything
GC blocksMemory tracksee fe-02; a leak presents here

The ordering of that table is the observation skill. Engineers reach for JIT explanations when the flame chart shows a big scripting block, and the cause is nearly always an algorithm, a waterfall, or layout thrashing.

Healthy vs unhealthy

Healthy:

  • Objects for one logical entity constructed with the same properties in the same order
  • No delete on objects in hot paths
  • Numeric arrays that stay numeric
  • Map used where keys are dynamic
  • Nobody on the team can tell you the IC state of anything, because nobody needed to look

Unhealthy — but check the magnitude before acting:

  • Object literals built conditionally so property sets vary per branch
  • delete in a per-item loop
  • Arrays initialised as new Array(n) then filled sparsely
  • A "fast path" object mutated into a different shape by an error handler

Unhealthy — act immediately, unrelated to this module:

  • Forced reflow warnings in a render path
  • Parsing large JSON on the main thread
  • A request waterfall

What these measurements do not tell you

  • One engine, one version. V8 149. JavaScriptCore and SpiderMonkey differ in IC design and element-kind handling; no result here transfers without re-measurement.
  • Micro-benchmarks are not applications. One shape through one site, versus your component receiving props from twelve call sites. Shape results routinely fail to reproduce in the app.
  • Timer resolution is ~0.1 ms. Anything below ~5 ms per rep is quantisation, not measurement.
  • No allocation or GC pressure is measured here. Shape churn also affects memory (fe-02), and this module measures only speed.
  • The PACKED_DOUBLE result is unexplained. It reproduced, one hypothesis was tested and rejected, and it was not pursued because it is ~0.6 ns/element. That is a documented limit, not a finding.

Measured Results

Chrome for Testing 149.0.7827.55 (arm64 macOS) via playwright-core/CDP. Reproduce: cd ../src && npm run all. Medians of 7 reps after 3 warm-up runs.

These are V8 implementation details on one version, not language guarantees.


1. Inline caches — 3,000,000 property reads per rep

distinct shapesIC statemedian msns/readvs monomorphic
1monomorphic5.301.7671.00×
2polymorphic5.301.7671.00×
4polymorphic6.502.1671.23×
8megamorphic10.403.4671.96×
16megamorphic10.303.4331.94×

The access site is character-identical in every row; only shape variety differs.

Note the shape of the curve. Nothing happens from 1→2. The cliff is between 4 and 8 — the polymorphic→megamorphic boundary — and past 8 it flattens: 16 shapes is no worse than 8, because the site has already fallen back to the stub cache.

Absolute penalty: 1.7 ns per read here, 2.9 ns in the perspective run (different iteration count and JIT tier).


2. Hidden classes — 400,000 objects created + read per rep

variantmedian msvs A
A — same property order5.601.00×
B — alternating property order6.401.14×
C — delete o.tmp (dictionary mode)66.8011.93×
D — o.tmp = undefined instead5.500.98×

The folklore is mostly wrong and one part is right. Property order (the part everyone repeats) is 1.14×. delete (the part people wave away) is 11.9×, and the fix is free.


3. Array element kinds — 5,000,000 elements summed per rep, fresh page per case

element kindmedian msns/elemvs SMI
PACKED_SMI — all small integers7.801.5601.00×
PACKED_DOUBLE — all doubles4.700.9400.60×
PACKED_ELEMENTS — 1% nulls7.701.5400.99×
HOLEY_SMI — 1% holes8.001.6001.03×

Spread: 1.70×.

Doubles measured faster than small integers, and I did not fully isolate why. The first hypothesis — the accumulator overflowing SMI range and transitioning to double mid-loop — was tested and rejected: constraining values to i % 100 keeps the sum near 2.5e8, well inside SMI range, and the ordering did not change. The most plausible remaining explanation is that SMI addition carries an overflow check per accumulate which double addition does not.

Recorded as unexplained. It is ~0.6 ns/element either way, which is why it did not justify further investigation — a judgement this module explicitly endorses, and an open question in the learning log rather than a claim.


4. Perspective — what these optimisations are worth

operationns each
monomorphic property read0.9
megamorphic property read3.8
createElement + textContent + appendChild360
addEventListener + removeEventListener130
style write + getBoundingClientRect (forced layout)4,200
JSON.parse of a ~200 KB API response396,000

Megamorphic penalty: 2.9 ns per read.

Property reads you must de-megamorphise to save the cost of one:

operationequivalent reads
addEventListener + removeEventListener45
create + append one element124
one forced layout1,448
one JSON.parse of a 200 KB response136,552

Removing one forced layout from a render path is worth more than de-megamorphising 1,448 property accesses. One avoided 200 KB JSON parse is worth more than 136,000.

This table is the module's deliverable. Bring it to the next discussion about object shapes.


Harness failure modes — three, all producing plausible wrong answers

  1. Shared IC state across cases. One page for all four element kinds meant the shared sum accumulated feedback across kinds: the first case measured paid JIT warm-up, later cases inherited a warm-but-polymorphic function. Reported doubles as 4× faster than SMIs. Fix: fresh page per case.

  2. DOM contamination between cases. createAppend left 5,000–20,000 children in the host element, so every subsequent getBoundingClientRect measured layout over a huge subtree. The script did not produce a wrong number — it timed out, which was luckier than the alternative. Fix: fresh page per case, and reset the DOM inside the case.

  3. Accumulator type transition. Summing raw i over 5M elements reaches ~1.25e13, overflowing SMI range and transitioning the accumulator to a double partway through the loop — attributing a cost to the array that belonged to the sum variable. Fix: constrain values so the accumulator stays in range. (This fix did not change the ordering, which is how it was ruled out as the cause of finding 3.)

Plus one mechanical trap worth recording: backticks inside comments in a page template literal terminate the template, twice producing syntax errors far from the real line.

Rule: every micro-benchmark needs a case whose answer you already know. If the harness cannot reproduce a result you are certain of, it cannot be trusted on one you are not.

Verification Checkpoints

Reference: Chrome for Testing 149.0.7827.55, arm64 macOS. Ratios are the pass condition; absolute nanoseconds are machine-specific.


Checkpoint 1 — The IC curve has a cliff, not a slope

npm run ic

Pass: 1 and 2 shapes are indistinguishable; 8 and 16 shapes are ~1.5–2.5× slower than 1; 8 and 16 are within noise of each other.

That last clause is the real check. If 16 is markedly worse than 8, you are measuring something other than IC state — most likely cache pressure from the larger objects.


Checkpoint 2 — delete dominates; property order does not

npm run shapes

Pass: delete is ≥ 5× variant A. Alternating property order is < 1.5×. o.tmp = undefined is within noise of A.

Fail — delete under 2×: V8 kept the object in fast mode (small objects with few properties sometimes avoid dictionary mode). Raise N, or add more properties before deleting.


Checkpoint 3 — Element-kind differences are ~1 ns/element

npm run arrays

Pass: spread across the four kinds is under ~3×, and every value is in the 0.5–3 ns/element range.

Expected oddity: PACKED_DOUBLE may measure faster than PACKED_SMI. This reproduced here (0.60×) and is documented as unexplained in measured-results.md §3. If you see it, you have reproduced the module's result, not made a mistake.

Fail — all four identical to two decimals: the summing operation is forcing generic element access in every case (a typeof guard will do this), or per-rep work is below timer resolution.


Checkpoint 4 — The perspective table

npm run perspective

Pass: the ordering holds, spanning ~5 orders of magnitude:

property read  <  listener pair  <  create+append  <  forced layout  <  JSON.parse
   ~1 ns            ~130 ns          ~360 ns           ~4,200 ns        ~400,000 ns

The checkpoint is not the numbers — it is that you can state the consequence: one forced layout removed is worth ~1,400 de-megamorphised property reads; one avoided 200 KB JSON parse is worth ~136,000.

Fail — forced layout under 500 ns: the element has no layout-affecting context, so getBoundingClientRect is nearly free. Ensure it is attached and in flow.


Checkpoint 5 — You can refuse the work

Not a script. Write a short review response to this proposal:

"I profiled our table component and found megamorphic property access in the row renderer. I'd like a sprint to normalise the row object shapes. I measured a 2× improvement on the property access microbenchmark."

Pass: your response prices the claim in absolute terms against the real operation count, identifies at least one alternative on the same critical path with a larger return, and says whether the answer would change if this were a 10M-cell virtualized grid. Agreeing is a valid answer if the arithmetic supports it — the checkpoint is the arithmetic, not the verdict.


Module completion

  • Checkpoints 1–5
  • steps/05-principal-review.md answered in writing
  • One real code path in a codebase you own, priced with the perspective method — including a stated decision not to optimise it if that is what the numbers say
  • Learning log updated, including the unexplained PACKED_DOUBLE result as an open question

Broader Ideas

The perspective method generalises beyond this module

The technique that makes this module useful is not about shapes at all:

Convert the ratio to an absolute per-operation cost. Multiply by the real operation count. Compare against the other items on the same critical path, in the same units.

It applies to every optimisation debate you will arbitrate:

ClaimRatio offeredWhat to ask
"This library is 3× faster"at what absolute cost, for how many calls, versus what else on the path?
"Server components cut bundle size 40%"40%how many ms of parse/execute, on which device, at what percentile?
"This cache gives 90% hit rate"90%what is the absolute latency saved, and what is the invalidation risk?
"Migrating to X reduces re-renders 60%"60%how many ms per interaction, against a 200 ms INP budget?

A Principal Engineer's leverage here is mostly refusal — killing work that cannot pay for itself, in a way the proposer can verify rather than merely accept. The arithmetic is the artefact that makes the refusal collaborative instead of political.

Where shape stability becomes a real design input

Three places in this curriculum where the findings should actually change a decision:

Virtualized lists (fe-30). A row renderer running over 10M cells multiplies per-element cost by enough to matter. This is also where element kinds stop being trivia: a numeric column that acquires one null transitions storage for the whole array, one-way.

Worker boundaries (fe-28). Structured clone cost depends on representation. Shape-stable, homogeneous, transferable-friendly data is the actual optimisation at a worker boundary — fe-01 measured a 376× difference between a naive and a resident worker, which dwarfs anything here, but representation is what makes transferables possible at all.

State-store internals (fe-16). A store allocating a new object per update, read at thousands of sites, is exactly the hot-library-internals case. This is why store implementations care about shapes and application code does not.

Data representation as a two-way-door decision

Array-of-objects vs object-of-arrays is usually framed as ergonomics. It is also a shape decision, and unusually it is a reversible one — which by fe-47's framing means it should be decided fast and cheaply, not deliberated:

// AoS — natural to write, one shape per row, fine at any realistic UI scale
const rows = [{ id, name, value }, ...];

// SoA — homogeneous typed arrays, no per-row objects; only pays at extreme scale
const ids = new Int32Array(n), values = new Float64Array(n);
const names = new Array(n);

Adopt AoS by default. Move to SoA when profiling says the per-row objects are the cost, which for frontend workloads means canvas, WebGL, or 10⁶-scale data. Doing it preemptively costs ergonomics permanently to buy nanoseconds you have not shown you need.

Why this module is short and mostly negative

The specification asks for depth on 46 areas. It does not ask for equal depth. Part of Principal judgment is allocating attention proportionally to leverage, and doing that visibly so others can calibrate too.

This module is the curriculum's worked example of deliberately bounded depth: enough to make correct decisions, enough to refuse incorrect ones, and an explicit statement of where the boundary is and why. A curriculum that treated JIT internals with the same weight as accessibility or rendering architecture would be teaching a distorted model of the job.

The one unexplained result here (PACKED_DOUBLE faster than PACKED_SMI) is left unexplained on purpose, with the reasoning recorded. Knowing when to stop investigating is the same skill as knowing when to stop optimising, and it is worth practising somewhere the stakes are 0.6 ns.

Step 1 — Inline Caches and the Megamorphic Cliff

Goal

Measure the IC penalty, and discover that its shape (a cliff, then flat) matters more than its size.

Predict first

One access site, objs[i % n].v, reading from n objects with n distinct hidden classes. Predict the slowdown versus n = 1 for n = 2, 4, 8, 16.

Most people predict a smooth curve. Commit to four numbers.

Run

cd src && npm install && npm run ic

Expected output:

  distinct shapes | IC state      | median ms | ns/read | vs monomorphic
                1 | monomorphic   |      5.30 |   1.767 |          1.00x
                2 | polymorphic   |      5.30 |   1.767 |          1.00x
                4 | polymorphic   |      6.50 |   2.167 |          1.23x
                8 | megamorphic   |     10.40 |   3.467 |          1.96x
               16 | megamorphic   |     10.30 |   3.433 |          1.94x

What just happened

An inline cache records, per access site, which shapes have been seen and where the property lives.

  • 1 shape — direct offset load.
  • 2–4 shapes — a short linear check. Measured: free at 2, 1.23× at 4.
  • 5+ shapes — the site gives up and falls back to the global stub cache: a hash lookup.

Two consequences that change what you would do:

  1. 1 → 4 shapes is nearly free. Refactoring to make a site strictly monomorphic is usually not worth it. A lot of advice implicitly assumes the curve is linear from 1.
  2. 8 → 16 costs nothing further. Once megamorphic, reducing shape variety buys nothing unless you get back under the threshold — which heterogeneous data rarely allows. "We reduced our shapes from 40 to 12" is a change with no effect.

So the only actionable form is: avoid crossing the cliff in genuinely hot code. Everywhere else, note the absolute number — 1.7 ns — and go to Step 4.

Checkpoint

docs/verification.md Checkpoint 1.

Step 2 — Hidden Classes, delete, and Which Folklore Survives

Goal

Test the two most-repeated claims about object shapes against measurement. One survives; one does not.

Predict first

400,000 objects created and read, four ways:

A: { x, y, z }                                    // consistent order
B: alternating { x, y, z } and { y, x, z }        // inconsistent order
C: { x, y, z, tmp }, then delete o.tmp
D: { x, y, z, tmp }, then o.tmp = undefined

Rank them, and predict B and C as multiples of A. Most engineers rank B as the serious problem.

Run

npm run shapes

Expected output:

  variant                              | median ms | vs A
  A  same property order               |      5.60 | 1.00x
  B  alternating property order        |      6.40 | 1.14x
  C  delete o.tmp  (dictionary mode)   |     66.80 | 11.93x
  D  o.tmp = undefined instead         |      5.50 | 0.98x

What just happened

Property order: 1.14×. Real, but a fraction of what the folklore implies. It is worth doing right because it is free, not because it is urgent. Treat as hygiene.

delete: 11.93×. The claim people wave away is the one that bites. Deleting a property drops the object out of the shape system entirely into dictionary mode — a hash map, no hidden class, no IC. Every subsequent read on every such object is slow, permanently.

o.tmp = undefined: 0.98×. Free. The shape is preserved; only the value changes.

What to do instead of delete

o.tmp = undefined;              // shape preserved; leaves the key present
const { tmp, ...rest } = o;     // new object, clean shape — usually right
const m = new Map(); m.delete(k);  // dynamic keys that get removed: use the right type

The third is the structural answer. If keys are dynamic and deleted, a plain object is being used as a dictionary, and Map is the type designed for that — it never enters dictionary mode, and (fe-02) WeakMap additionally solves the retention question.

Why this ordering is worth remembering: it is a clean case of measurement inverting received wisdom. The advice everyone repeats is worth 1.14×; the practice everyone tolerates is worth 11.9×.

Checkpoint

docs/verification.md Checkpoint 2.

Step 3 — Array Element Kinds (and an Unexplained Result)

Goal

Measure array storage specialisation, and practise reporting a result you could not fully explain.

Predict first

5,000,000-element arrays summed with an identical operation. Rank:

PACKED_SMI       all small integers
PACKED_DOUBLE    all doubles
PACKED_ELEMENTS  1% nulls mixed in
HOLEY_SMI        1% holes

Run

npm run arrays

Expected output:

  element kind                          | median ms | ns/elem | vs SMI
  PACKED_SMI      all small integers    |      7.80 |   1.560 |  1.00x
  PACKED_DOUBLE   all doubles           |      4.70 |   0.940 |  0.60x
  PACKED_ELEMENTS 1% nulls mixed in     |      7.70 |   1.540 |  0.99x
  HOLEY_SMI       1% holes, no strings  |      8.00 |   1.600 |  1.03x

  Spread across all four kinds: 1.70x

What just happened

V8 specialises array storage by content. PACKED_SMI stores tagged 31-bit integers; PACKED_DOUBLE stores unboxed doubles; PACKED_ELEMENTS stores general tagged values. Transitions are one-way — adding one null to a numeric array changes storage for the whole array, and removing it does not restore the fast kind.

And PACKED_DOUBLE measured faster than PACKED_SMI, which is backwards.

This is the point of the step. The first hypothesis was that the accumulator overflowed SMI range (summing raw i over 5M elements reaches ~1.25e13) and transitioned to a double mid-loop — attributing to the array a cost that belonged to the sum variable. That was testable: constraining values with i % 100 keeps the sum near 2.5e8, well inside SMI range.

The fix was applied. The ordering did not change. Hypothesis rejected.

The most plausible remaining explanation is that SMI addition carries a per-add overflow check that double addition does not. It was not investigated further, because the entire effect is ~0.6 ns/element, and Step 4 shows what that is worth.

The skill being practised

Recording "reproduced, one hypothesis tested and rejected, not pursued because the magnitude does not justify it" is a better professional output than either a confident wrong explanation or silence. It is logged as an open question, not as a finding.

Knowing when to stop investigating is the same judgment as knowing when to stop optimising. This is a safe place to practise it: the stakes are 0.6 nanoseconds.

Checkpoint

docs/verification.md Checkpoint 3 — including that reproducing the oddity is a pass.

Going deeper (optional)

If you want to settle it, node --allow-natives-syntax --print-opt-code and compare the generated loops. If you do settle it, update docs/measured-results.md §3 and the learning log — that is a genuine contribution to this repository.

Step 4 — Perspective: What Any of This Is Worth

Goal

Price the previous three steps against the operations frontend code actually performs. This is the module. If you do one step, do this one.

Predict first

Rank by cost, and estimate each in nanoseconds:

  • one megamorphic property read
  • createElement + textContent + appendChild
  • addEventListener + removeEventListener
  • one style write + getBoundingClientRect (forced layout)
  • JSON.parse of a ~200 KB API response

Then answer: how many megamorphic property reads equal one forced layout? Write a number.

Run

npm run perspective

Expected output:

  operation                                          |        ns each
  monomorphic property read                          |            0.9
  megamorphic property read                          |            3.8
  createElement + textContent + appendChild          |          360.0
  addEventListener + removeEventListener             |          130.0
  style write + getBoundingClientRect (forced layout)|         4200.0
  JSON.parse of a ~200KB API response                |       396000.0

  The megamorphic penalty is 2.90 ns per property read.

  How many property reads must you de-megamorphise to save the cost of ONE:
    addEventListener + removeEventListener                     45 reads
    createElement + textContent + appendChild                 124 reads
    style write + getBoundingClientRect (forced layout)      1,448 reads
    JSON.parse of a ~200KB API response                   136,552 reads

What just happened

Five orders of magnitude separate the top and bottom of that table.

Removing one forced layout is worth de-megamorphising 1,448 property accesses. Avoiding one 200 KB JSON parse is worth 136,552. And fe-01 measured a layout-thrashing loop over 800 elements at 86 ms — that single fix is worth roughly 30 million de-megamorphised reads.

The arithmetic to reuse

Price the plausible-sounding proposal "fix megamorphic access in our render path":

penalty              2.9 ns per read
reads per render     50,000              (generous)
saving               0.145 ms per render
frame budget         16.7 ms
improvement          0.9%

Now price the alternative on the same path: removing the layout thrash, 86 ms. Same engineer, same week, ~590× the return.

The general form

Convert the ratio to an absolute per-operation cost. Multiply by the real operation count. Compare against the other items on the same critical path, in the same units.

This is not a fact about shapes. It is the method for every optimisation debate you will arbitrate — library choices, bundle-size claims, cache hit rates, re-render counts. Most proposals do not survive it, including your own, which is the point.

The honest counterweight

These effects are real, and there are situations where they dominate — see docs/analysis.md "When this actually matters". The failure mode is not writing shape-unstable code; it is a senior engineer spending a sprint on shapes while a request waterfall and a 200 KB main-thread JSON parse sit untouched on the same path.

Checkpoint

docs/verification.md Checkpoints 4 and 5.

Deliverable

Take one real code path you own. Price it with this method:

  1. What is the operation count?
  2. What is the absolute per-operation saving?
  3. What else is on the same critical path, in the same units?

A decision not to optimise, backed by that arithmetic, is a passing deliverable — and is the outcome most of the time.

Step 5 — Principal Engineer Review

Answer in writing. Several questions are about refusal, which is most of this module's practical value.


1. An engineer proposes a sprint normalising object shapes in a table row renderer, citing a 2× microbenchmark improvement. Price the proposal. What do you need to know to answer, what is your answer, and what would change it?

2. The same engineer is right — it is a 10M-cell virtualized grid, and the row renderer is the hot path. What changes? What do you ask them to measure before and after, and what would make you reject the work even so?

3. A style guide bans delete. Make the case for and against. Where would you actually enforce it, and how (lint rule, review convention, nothing)? What does your answer depend on?

4. A teammate says "we should use plain objects instead of Map, objects are faster." What is the steelman of their position, where is it true, and where is it exactly backwards?

5. Your benchmark shows a 3× regression after a dependency upgrade, isolated to property access. List four explanations other than shape changes, and the cheapest experiment that distinguishes them.

6. You measured a result you cannot explain (Step 3). Write the two-sentence version you would put in a PR description, and the version you would put in a team-wide performance guide. Should they differ?

7. A junior engineer asks whether they should worry about hidden classes. Give the answer that is useful rather than the one that is complete. What single heuristic do you leave them with?

8. Your organisation wants a "JavaScript performance" section in its engineering standards. Draft the outline. What is in it, what is deliberately not, and how do you stop it becoming a folklore repository within a year?

9. These are V8 numbers. Your product supports Safari and Firefox. What is your policy for engine-specific optimisation, and what evidence would justify an exception?

10. Reflect on the module's structure: it is short, largely negative, and explicitly bounded. Was that the right call for a Principal curriculum? Argue the other side — what does an engineer lose by not going deeper here, and when would that loss actually bite?


Record

Answers in RESULTS.md. Question 8 is a natural ADR — file it in ../fe-00-roadmap/docs/decisions/ with the reversibility and migration sections filled in seriously.

Concepts — HTML as an Application Platform

Phase 1 · Platform substrate · Specification area §3 (semantic HTML, forms, native validation, dialog, popover, details/summary, tables, media, responsive images, progressive enhancement, custom elements, Shadow DOM). Feeds §12 (accessibility) and §26 (design systems).

1. What is it

HTML is not a template format. It is a declarative application platform with built-in semantics, accessibility, focus management, validation, and state — most of which frontend teams reimplement in JavaScript, worse, at greater cost, and then maintain forever.

This module treats HTML as an engineering choice with measurable consequences, and asks the question the specification puts at the centre of the curriculum:

What problem existed? What does the platform provide? What abstraction was introduced? What trade-off did it make? When does the abstraction leak?

2. Why it matters

The platform version is usually smaller and more capable. Measured here: a <dialog> with showModal() passes 7/7 behavioural tests in 222 bytes of JS. A naive custom modal passes 1/7 in more bytes. A conscientious hand-rolled implementation reaches 6/7 in 5.4× the code — and owes maintenance forever.

Div soup costs more markup than semantic HTML and delivers nothing. Measured: div soup was 1.38× the bytes of the semantic version while exposing zero landmarks, zero headings and zero interactive elements to the accessibility tree. It is not a shortcut; it is more work for less function.

ARIA is a repair kit, not a substitute. Reconstructing the same accessibility tree from divs took 1.69× the markup, required explicit tabindex to restore keyboard access that semantic elements had for free, and only covers what you remembered to declare.

This is where accessibility becomes architecture rather than remediation. A team that reaches for <div role="button" tabindex="0"> has taken on focus, keyboard activation, and state announcement as permanent maintenance. A team that writes <button> has not. That decision is made in Phase 1, and everything in fe-24 either inherits it or fights it.

3. How it works

+==================================================================+
|  MARKUP -> ACCESSIBILITY TREE                                     |
+==================================================================+

  <button>Export</button>
        |
        v
  role=button, name="Export", focusable, Enter/Space activate,
  announced as a button, exposed in the controls list
        |
        v
  screen reader / voice control / switch access / browser UI

  <div class="btn" onclick>Export</div>
        |
        v
  role=generic, no name, NOT focusable, no keyboard activation,
  invisible to every assistive navigation mode

Measured: three implementations of one UI

AX nodesnamedlandmarksheadingsinteractivetable rolesfocusablemarkup
semantic HTML5638423731.00×
div soup4624000001.38×
div + ARIA4833423731.69×

Measured: dialog behaviours

behaviour<dialog>custom, naivecustom, careful
opensyesyesyes
focus moves inyesNOyes
focus trappedyesNOyes
background inertyesNOyes
Escape closesyesNOyes
focus restoredyesNOyes
::backdropyesNONO
JS bytes2222411200 (5.4×)

4. Core terminology

TermDefinition
Accessibility treeThe browser-computed tree exposed to assistive technology; derived from DOM + CSS + ARIA
Accessible nameThe computed label for an element (from content, aria-label, <label>, etc.)
LandmarkA navigable region: banner, navigation, main, contentinfo, complementary
Implicit roleThe ARIA role an element has by virtue of being that element
Progressive enhancementWorking baseline in HTML, improved by CSS and JS rather than depending on them
<dialog> / showModal()Native modal with focus trap, inertness, ::backdrop, Escape handling
inertAttribute removing a subtree from focus, hit-testing and the accessibility tree
Popover APIpopover + popovertarget: top-layer, light-dismiss, no JS positioning state
Constraint validationNative required/type/pattern/min/max/minlength + ValidityState
Dirty value flagHTML-spec flag set when the user edits a field; gates minlength/maxlength
:user-invalidMatches only after user interaction — the selector you usually want, not :invalid
Top layerRendering layer above all content, used by modal dialogs and popovers; escapes clipping and z-index
Shadow DOMEncapsulated subtree with scoped styles; affects the accessibility tree and focus traversal

5. Mental models

Native elements are behaviour bundles, not tags. <button> is not "a styled box". It is: focusable, Enter/Space activation, role=button, accessible name from content, participates in forms, respects disabled, exposed to voice control, works with switch access. Choosing a div opts out of all of it simultaneously, and each piece must be rebuilt and maintained separately.

ARIA's first rule is "don't use ARIA". Not because ARIA is bad, but because it only changes the accessibility tree. It does not add focusability, keyboard handling, or state management — measured above: the ARIA variant needed explicit tabindex to get 3 focusable elements, and still has no keyboard activation behaviour. ARIA describes; elements behave.

The platform's abstraction cost is the styling boundary. Native elements trade customisation for behaviour, and the honest version of "we can't use <dialog>" is usually "we couldn't style the backdrop the way the design asked." That is a real cost — and it should be traded explicitly against 5.4× the code and permanent ownership, not assumed away.

Progressive enhancement is a failure-mode question, not an ideology. The question is never "should this work without JS" in the abstract, but "what does the user see when the bundle fails, the CDN is blocked, or the script throws during hydration?" A form built on native constraints degrades to a working form. A form built on a JS validation library degrades to nothing.

6. Common misconceptions

  1. "Semantic HTML is verbose." Measured backwards: div soup was 1.38× the bytes of the semantic version, and ARIA-patched divs 1.69× — for the same visual result and (in the div soup case) none of the function.

  2. "ARIA makes divs accessible." It makes them announced. Measured: the ARIA variant matched semantic HTML on roles and landmarks but required manual tabindex for focus, and still lacks Enter/Space activation, disabled semantics, and form participation.

  3. "We need a custom modal for design reasons." Sometimes true. But the naive custom modal measured 1/7 on behaviour while costing more bytes than native, and the careful one costs 5.4× and still lacks ::backdrop. Make it an explicit trade, not a default.

  4. "Native form validation isn't good enough." It correctly enforced type=email, type=url, pattern, and min/max, blocked submission, and exposed precise ValidityState flags — with zero lines of validation JS. What it does not do is presentation, localisation, and cross-field rules. The correct architecture is native constraints as the source of truth with your own presentation layer; reimplementing the constraints is the mistake.

  5. ":invalid is the styling hook." :invalid matches before the user has typed anything, so an empty required field is styled as an error on first paint. :user-invalid is the one you want, and it is supported.

  6. "Setting .value in a test is equivalent to typing." Measured false, and it matters: minlength reported valid=true, tooShort=false for a programmatically-set short value and valid=false, tooShort=true for the same string typed. minlength/maxlength are gated on the spec's dirty value flag. A test that sets .value passes while the real form rejects the input — the same class of bug as fe-01's .click() finding.

7. Interview talking points

  • "Div soup isn't a shortcut — we measured it at 1.38× the markup of semantic HTML with zero landmarks, zero headings and zero exposed controls. You pay more to get less."
  • "I treat <div role="button" tabindex="0"> as taking on permanent maintenance: focus, keyboard activation, disabled semantics, and state announcement all become yours. <button> is the same visual result with none of that liability."
  • "We tested a native <dialog> against a hand-rolled one behaviourally — focus trap, inertness, Escape, focus restore. Native passed 7/7 in 222 bytes; a careful custom implementation reached 6/7 in 5.4× the code. If design requires a custom one, fine, but that's the price."
  • "Native constraint validation should be the source of truth, read through the ValidityState API, with your own presentation layer. Teams reimplement the constraints, which is the one part they get for free, and then still have to build the part that's actually missing."
  • "A subtle one: minlength only applies to user-edited values because of the dirty value flag. So a test that sets .value directly never sees tooShort and passes while the real form rejects the same input."

8. Connections to other modules

  • fe-05 / fe-06 (CSS, layout) — the styling boundary is the real cost of native elements; ::backdrop, ::part, and form-control styling are where that trade is negotiated.
  • fe-24 (accessibility as engineering) — this module is its foundation. The specification's dependency is strict: ARIA-first accessibility built without semantic HTML produces compliant- looking, unusable interfaces.
  • fe-20 (unhappy path) — progressive enhancement is a failure-mode strategy; what the user gets when the bundle fails is decided by the choices in this module.
  • fe-32 / fe-33 (testing) — the dirty-value-flag finding is a concrete case where synthetic interaction and real interaction have different semantics.
  • fe-38 (design systems) — whether a design system's primitives wrap native elements or replace them is the single most consequential decision it makes.
  • browser-framework-internals.md §8 (DOM Internals) — cross when you need to know how the accessibility tree is computed rather than what it contains.

References — HTML as an Application Platform

Specifications

Practice

Tooling

Deliberately excluded

Component libraries presented as accessibility solutions. Some are excellent, but adopting one does not transfer understanding, and this module's point is that the element choice is the decision. Evaluate libraries with the behaviour table from experiment 2 — several popular ones fail rows a native <dialog> passes.

Analysis

When a custom control is the right call

Native elements are the default, not a rule. The honest cases for replacing one:

SituationWhy native losesWhat you are accepting
Design requires styling the browser refuses::backdrop, <select> internals, validation bubblespermanent behaviour maintenance
Behaviour genuinely differsmulti-select combobox, virtualised listbox, rich textyou now own a WAI-ARIA pattern end to end
Cross-browser inconsistency is unacceptabledate/time inputs vary widely between enginesyou own date semantics, i18n and keyboard
The element does not existtree grid, split button, complex data gridthe full APG pattern, tested with real AT

The decision rule: you are not choosing markup, you are choosing an ownership boundary. The question is not "can we build this?" but "will this still be correct in three years, after four team changes, a framework upgrade, and a new browser focus behaviour?"

Measured cost of that ownership for the simplest possible case — a modal — is 5.4× the code and still one behaviour short. A combobox is an order of magnitude beyond that.

The styling boundary is the real trade

Almost every "we can't use the native element" reduces to styling. That is a legitimate constraint, and the platform has been closing the gap:

WantModern option
styled modal backdropdialog::backdrop
light-dismiss overlays without JS statePopover API (popover, popovertarget)
styled <select>appearance: base-select (emerging; check support before relying)
styled checkbox/radioaccent-color, or appearance:none + custom visuals on the real input
positioned anchored UICSS Anchor Positioning
exposed shadow internals::part()

The pattern worth internalising: keep the native element and restyle it, rather than replacing it with a div. appearance: none on a real <input type="checkbox"> gives complete visual control while retaining focus, keyboard, form participation and accessibility — which is the entire reason the element existed.

Native validation as source of truth

The architecture that survives contact with designers and localisation:

<input id="email" name="email" type="email" required
       aria-describedby="email-error">
<p id="email-error" role="alert" hidden></p>
// Constraints live in HTML. JS only presents them.
const show = (input, msgEl, messages) => {
  const v = input.validity;
  if (v.valid) { msgEl.hidden = true; return true; }
  const key = Object.keys(messages).find(k => v[k]) ?? 'default';
  msgEl.textContent = messages[key];       // YOUR locale, not the browser's
  msgEl.hidden = false;
  return false;
};
form.addEventListener('submit', (e) => { if (!form.checkValidity()) e.preventDefault(); /* … */ });
form.noValidate = true;   // suppress the bubble, keep the constraints

Three properties this has and a validation library does not: constraints are visible in the markup (so they are testable, server-mirrorable and greppable), the browser enforces them before your JS loads, and ValidityState gives you precise reason codes rather than boolean failure.

form.noValidate = true set from JavaScript is the key detail — the constraints still evaluate and checkValidity() still works; only the native bubble is suppressed. If JS fails to load, the attribute is absent and native validation protects the form.

Progressive enhancement as a failure-mode analysis

Not an ideology. The question is what the user gets in each degraded state:

FailureNative-first formJS-validation form
Bundle fails to loadworks, validates, submitsinert inputs, no submit
JS throws during hydrationworksbroken, often silently
Slow network, JS pendingusable immediatelyvisible but non-functional
Extension/CSP blocks a scriptworksbroken

This is the concrete link into fe-20 (unhappy path). The list above is not hypothetical — "visible but non-functional" is the single most common production complaint about SPAs, and it is decided by choices made in this module.

What breaks at scale

  • Design systems calcify the choice. If a system ships <Button> as a styled div, every consuming app inherits the liability, and fixing it later is a breaking change across dozens of teams. This is the highest-leverage decision a design system makes and it is usually made in week one, by one person, without measurement (fe-38).
  • ARIA drifts out of sync. aria-expanded on a div is state you must remember to update in every code path. A <details>/<summary> cannot drift because the browser owns the state. Every ARIA attribute is a manual invariant, and manual invariants decay.
  • Framework abstractions hide element choice. <Box as="button"> and styled-component factories make the underlying element a prop, so nobody reviews it. Auditing "what element does this actually render" becomes archaeology.
  • Shadow DOM changes focus and accessibility traversal. Custom elements with shadow roots need delegatesFocus, careful ::part exposure, and explicit label association. Encapsulation is not free at the accessibility boundary.
  • Automated checks pass on div soup. Contrast and alt-text checkers do not flag "this should have been a button". The div-soup variant would score well on many automated audits while exposing zero controls — which is the limit of automation the specification asks us to teach (fe-24).

Execution Guide

Tool versions

ToolVersion used
Node.js23.11.0
playwright-core1.62.1
Chrome for Testing149.0.7827.55 (arm64)

Quick start

cd fe-04-html-as-a-platform/src
npm install
npm run a11y-tree   # 1. semantic vs div soup vs ARIA, real AX tree   (~10s)
npm run dialog      # 2. native <dialog> vs custom, behaviourally     (~15s)
npm run forms       # 3. constraint validation + dirty-value trap     (~10s)
npm run all

Manual lab: npm run servehttp://localhost:8084/ (see steps/04-replace-a-component.md).

Why CDP for the accessibility tree

Accessibility.getFullAXTree returns the browser's computed accessibility tree — the same data a screen reader consumes. Everything else is inference:

ApproachProblem
Reading the markuptells you what you wrote, not what the browser computed
axe/lighthousefinds violations of specific rules; does not show the tree
DevTools Accessibility paneshows one node at a time, manually
Accessibility.getFullAXTreethe whole tree, scriptable, diffable

That distinction is the point of experiment 1: div soup passes many automated audits while exposing zero landmarks and zero controls. Only the tree shows it.

Why keyboard input rather than assertions on markup

Experiment 2 does not check "is there a focus trap in the code". It presses Tab eight times through CDP and asks where focus ended up; presses Escape and asks whether the dialog closed; checks whether the background can be focused at all. Behaviour is the requirement, so behaviour is what is measured.

Note that page.focus('#trigger') + keyboard.press('Enter') is used rather than page.click() — activating a control the way a keyboard user does, which is the population the experiment is about.

Reproducibility notes

  • Markup byte counts are whitespace-normalised so indentation style does not affect the comparison. They measure structure, not minified transfer size.
  • AX node totals vary slightly with browser version and internal nodes. The landmark, heading, interactive and focusable counts are the stable signals; compare those.
  • ::backdrop is reported as a capability, not measured visually — it exists only for modal <dialog>, which is the architectural point.
  • Experiment 3's dirty-value section uses page.type() (real keystrokes). Replacing it with page.fill() or .value = will make the trap disappear, which is itself worth trying once.

Observation Guide

Inspecting the accessibility tree by hand

Chrome DevTools → Elements → Accessibility pane shows the computed node for the selected element: role, name and how the name was computed, plus the ancestor chain. The "Full-page accessibility tree" toggle (top of the pane) is the manual version of experiment 1 and the fastest way to see div soup for what it is.

Three things to look at, in order:

  1. Landmarks. Screen-reader users navigate by region first. No landmarks means no navigation.
  2. Headings and their levels. The second navigation mode. Skipped levels and role=heading without aria-level both break it.
  3. The controls list. Anything interactive that is not in it is invisible to assistive tech — regardless of how it looks or what onclick it has.

Keyboard-only testing, which finds more than any tool

Unplug the mouse for ten minutes on a page you own:

CheckFailure signal
Tab through the whole pagefocus disappears, or lands on nothing visible
Visible focus indicator everywhereoutline: none with no replacement
Enter and Space on every controlworks on <button>, silently fails on div role="button"
Escape closes overlayscustom modals routinely miss this — measured 1/7
Focus returns after closing an overlayfocus lost to <body>; screen reader restarts at the top
Tab cannot escape an open modalbackground reachable behind the overlay

Rows 3–6 are exactly the behaviours experiment 2 measures, and exactly the ones native elements provide for free.

Screen readers, briefly

Automated tools cannot tell you whether an interface is usable, only whether it violates specific rules. Thirty minutes with a real screen reader is worth more than any audit score:

PlatformReaderStart
macOSVoiceOver⌘F5; VO = Ctrl+Option; rotor = VO+U
WindowsNVDA (free)Insert = NVDA key; elements list = NVDA+F7
WindowsJAWSstill dominant in enterprise/government

The exercise: open the rotor/elements list and try to navigate by landmark and heading. On the div soup variant there is nothing to navigate — which is the finding, and it is far more persuasive than a table of counts.

What automation catches, and what it cannot

Automated tools findAutomated tools miss
missing altalt that describes the wrong thing
contrast ratiosfocus order that makes no sense
duplicate ids, invalid ARIAa div that should have been a button
missing form labelserror messages not associated with fields
missing langkeyboard traps in custom widgets

The div soup variant would score well on many automated audits. It has no contrast failures, no missing alt text, no invalid ARIA — and exposes zero controls. This is the concrete demonstration of the specification's requirement to "explain the limits of automation" (fe-24).

Rough division: automation covers perhaps 30–40% of WCAG failures by count, and a much smaller share by user impact. Treat an audit score as a regression guard on things you already fixed, never as evidence of accessibility.

What these measurements do not tell you

  • One browser. Accessibility tree computation differs between Chrome, Firefox and Safari, and screen readers consume the platform API (UIA/AX/AT-SPI), not the browser's internal tree.
  • Tree exposure is not usability. Correct roles with a nonsensical reading order or unhelpful names measures identically to a good interface.
  • No real assistive technology was involved. Verbosity settings, browse vs focus mode, and reader-specific quirks are invisible here.
  • Byte counts are structural, not transfer size — gzip changes the ratios, though not the direction.
  • The dialog test covers seven behaviours. A real modal also has scroll locking, aria-labelling, nested-dialog behaviour, and iOS focus quirks that are not measured.

Measured Results

Chrome for Testing 149.0.7827.55 (arm64 macOS) via playwright-core/CDP. Reproduce: cd ../src && npm run all. Accessibility trees come from Accessibility.getFullAXTree — the browser's real computed tree, not inferred from markup.


1. Accessibility tree — three implementations of one UI

variantAX nodesnamedlandmarksheadingsinteractivetable rolesfocusable
semantic HTML563842373
div soup462400000
div + ARIA483342373

Landmarks exposed:

  • semantic — banner, navigation, main, contentinfo
  • div soup — (none)
  • div + ARIA — banner, navigation, main, contentinfo

Markup size, normalised:

variantbytesvs semantic
semantic HTML4591.00×
div soup6341.38×
div + ARIA7781.69×

Div soup costs 38% more markup and delivers nothing. It is not a shortcut. ARIA can rebuild the tree at 69% more markup — and note the focusable column: the ARIA version needed explicit tabindex to restore keyboard access that semantic elements provide for free. ARIA changes the accessibility tree; it does not confer behaviour.


2. Dialog behaviours — driven with real keyboard input

behaviour<dialog> showModal()custom, naivecustom, careful
opensyesyesyes
focus moves inyesNOyes
focus trapped (8 × Tab)yesNOyes
background inertyesNOyes
Escape closesyesNOyes
focus restored to triggeryesNOyes
::backdrop availableyesNONO
implementationJS bytesvs native
<dialog> showModal()2221.0×
custom, naive2411.1×
custom, careful12005.4×

The naive custom modal costs more bytes than native and passes 1 of 7 behavioural checks — and it is what "just use a div with role="dialog"" produces in practice.

The careful version is a good-faith implementation with focus trap, inert, Escape and focus restore. It reaches 6/7 at 5.4× the code, and it must keep working across new focusable element types, shadow DOM, iframes, and browser changes — forever.


3. Native constraint validation — zero lines of validation JS

fieldconstraintvaluevalidValidityState flag
emailtype=email"not-an-email"NOtypeMismatch
emailtype=email"a@b.co"yes
agemin=18"9"NOrangeUnderflow
agemin=18"30"yes
codepattern"abc-12"NOpatternMismatch
codepattern"ABC-1234"yes
sitetype=url"nope"NOtypeMismatch
sitetype=url"https://x.dev"yes
  • submission blocked while invalid: yes
  • submission allowed once valid: yes
  • :invalid matches: yes
  • :user-invalid supported: yes — and it is the one you want

The dirty-value trap

how the value was setvalidtooShort
el.value = 'short' (programmatic)truefalse
user types "short" (real keystrokes)falsetrue

minlength/maxlength apply only to values the user edited — the HTML spec's dirty value flag. A test that sets .value directly never sees tooShort and passes while the real form rejects the same input.

This is the same class of finding as fe-01's .click() result: synthetic interaction and real interaction have different semantics, and the test harness is the thing that is wrong.

What native validation does not do

  • inline error text positioned in your layout (the bubble is unstyleable)
  • aria-describedby wiring between input and error
  • localisation — browser messages follow browser locale, not application locale
  • cross-field rules (confirm password, end-date after start-date)
  • server-side validation, which is the only one that is a security control

Correct architecture: native constraints as the source of truth, read via ValidityState, with your own presentation layer on top. Reimplementing the constraints is the mistake; building the presentation is the work.

Verification Checkpoints


Checkpoint 1 — Div soup exposes nothing, and costs more

npm run a11y-tree

Pass:

  • div soup shows 0 landmarks, 0 headings, 0 interactive, 0 focusable
  • semantic shows ≥ 4 landmarks, ≥ 2 headings, ≥ 3 interactive, ≥ 3 focusable
  • div soup markup is larger than semantic (≈ 1.3–1.4×)

Fail — div soup shows landmarks: your markup accidentally used semantic elements, or Chrome inferred a role. Check the variant string.

The checkpoint is the byte column. If you cannot state that div soup costs more markup for less function, the experiment has not landed.


Checkpoint 2 — ARIA restores the tree but not behaviour

Pass: the ARIA variant matches semantic on landmarks/headings/roles, uses ≈ 1.6–1.8× the markup, and reaches 3 focusable elements only because tabindex was declared explicitly.

Say out loud what is still missing versus the semantic version: Enter/Space activation, disabled semantics, form participation, and automatic name computation.


Checkpoint 3 — Native dialog passes behaviours a naive custom one does not

npm run dialog

Pass:

  • <dialog>: yes on all seven rows
  • naive custom: NO on focus-moves-in, focus-trapped, background-inert, Escape, focus-restored
  • careful custom: yes on everything except ::backdrop
  • naive custom JS ≥ native JS bytes

Fail — naive custom passes focus trap: the test is not pressing enough Tabs, or the background buttons were removed. Focus must be able to escape for the check to mean anything.


Checkpoint 4 — Constraint validation works with zero validation JS

npm run forms

Pass: every NO row reports the correct specific ValidityState flag (typeMismatch, rangeUnderflow, patternMismatch), submission is blocked while invalid and allowed once valid, and :user-invalid is supported.


Checkpoint 5 — The dirty-value trap reproduces

Pass:

el.value = 'short'  (programmatic) -> valid=true   tooShort=false
user types 'short'  (real keys)    -> valid=false  tooShort=true

This is the highest-transfer checkpoint in the module. You must be able to state the consequence: a test that sets .value directly passes while the real form rejects the same input.

Then verify you understand it by breaking it deliberately — swap page.type() for page.fill() and watch the trap disappear.


Checkpoint 6 — You replaced something

Not a script. Take one component in a codebase you own — a modal, dropdown, accordion, tooltip, or tab set — and either:

  • replace it with the native primitive, measuring JS bytes before and after and confirming the behaviour table, or
  • write the justification for keeping it, naming which native behaviours you are now responsible for maintaining and what you tested to confirm they work.

Both are passing outcomes. The failure is having no answer.


Module completion

  • Checkpoints 1–6
  • steps/05-principal-review.md answered in writing
  • The behaviour table from experiment 2 reproduced for one control you actually ship
  • Learning log updated — particularly the dirty-value flag, which changes how you write tests

Broader Ideas

The design system decides this once, for everyone

The highest-leverage consequence of this module is not in application code. If a design system ships <Button> rendering a styled div, every consuming team inherits the liability, and changing it later is a breaking change across dozens of applications.

Questions worth asking of any design system, including one you are buying:

  1. What element does each primitive actually render? (as props make this non-obvious)
  2. Are native behaviours preserved, or reimplemented?
  3. Is there a behavioural test suite — focus, keyboard, Escape — or only visual regression?
  4. When a native element gains a capability (popover, anchor positioning), what is the adoption path?

A design system that wraps native elements gets platform improvements for free. One that replaces them must reimplement each improvement forever. That is the whole argument, and it is decided in week one by one person. Developed in fe-38.

The platform is closing the styling gap

Most "we can't use the native element" objections are styling objections with a shelf life:

ObjectionPlatform answer
can't style the modal backdropdialog::backdrop
need light-dismiss without JS statePopover API
can't style <select>appearance: base-select (emerging)
can't position anchored UICSS Anchor Positioning
can't style checkboxesaccent-color, appearance: none on the real input
can't reach shadow internals::part()

The strategic consequence for a Principal: custom control inventories are a depreciating asset. Every custom modal, tooltip and dropdown built in 2020 is now more code than the native equivalent, with fewer capabilities. Worth a standing item on a technology radar (fe-50) rather than a one-time migration.

Progressive enhancement is a reliability strategy

Reframed as failure modes rather than ideology, this connects directly to fe-44 (reliability):

FailureNative-firstJS-dependent
bundle fails / CDN blockedworksinert
hydration throwsworksbroken, often silently
slow networkusable immediatelyvisible but non-functional
CSP or extension blocks a scriptworksbroken

"Visible but non-functional" is the most common production complaint about SPAs, and it is a graceful degradation question — the same discipline as circuit breakers and kill switches, applied at the markup layer. A form built on native constraints has a working fallback for free.

Custom elements as the integration boundary

Web Components are the platform's answer to framework-agnostic shared UI, and the trade is real:

Good fit: design-system primitives consumed by multiple frameworks; long-lived widgets that must outlive framework churn; embedded third-party UI needing style isolation.

Poor fit: anything needing SSR without extra machinery (declarative shadow DOM helps, but the ecosystem is uneven); tight framework integration where the framework's own model is better; frequent complex data passing, where attribute/property marshalling becomes friction.

The accessibility caveat this module makes concrete: shadow roots change focus traversal and label association. delegatesFocus, explicit ::part exposure, and cross-root ARIA (aria-labelledby across a shadow boundary is not straightforward) are all real costs. Picked up in fe-38 and fe-41.

<details>, <dialog>, popover: state the browser owns

The deepest idea here is not "use semantic tags" — it is which component owns state.

<details><summary>More</summary>…</details>

There is no isOpen state, no toggle handler, no aria-expanded to keep in sync, no possibility of the ARIA attribute drifting from the visual state. The browser owns the invariant, so it cannot decay.

Every ARIA attribute you manage manually is an invariant that must be maintained in every code path — including error paths, cancelled transitions and interrupted animations. aria-expanded on a div will eventually disagree with what is on screen. This is the same argument as fe-02's AbortController finding and the specification's "make invalid states difficult to represent" heuristic: prefer designs where the invalid state cannot be expressed.

Reaching for <details> when you need disclosure is not a style preference. It is choosing a design in which the bug class does not exist.

Step 1 — What the Screen Reader Actually Receives

Goal

Stop reasoning about markup and start reading the accessibility tree the browser computes.

Predict first

The same visual UI — header, nav, heading, paragraph, button, data table, footer — built three ways:

  • semantic HTML (<header> <nav> <h1> <button> <table>)
  • div soup (<div class="btn" onclick>)
  • div + ARIA (<div role="button" tabindex="0">)

Predict for each: landmarks exposed, headings, interactive elements, keyboard-focusable elements, and which produces the most markup.

Most people predict semantic HTML is the most verbose. Commit before running.

Run

cd src && npm install && npm run a11y-tree

Expected output:

  variant        | AX nodes | named | landmarks | headings | interactive | table roles | focusable
  semantic HTML  |       56 |    38 |         4 |        2 |           3 |           7 |        3
  div soup       |       46 |    24 |         0 |        0 |           0 |           0 |        0
  div + ARIA     |       48 |    33 |         4 |        2 |           3 |           7 |        3

  markup size (normalised bytes):
    semantic HTML    459  (1.00x semantic)
    div soup         634  (1.38x semantic)
    div + ARIA       778  (1.69x semantic)

What just happened

Div soup exposes nothing. Zero landmarks, zero headings, zero controls. A screen-reader user has no way to navigate to a region, jump by heading, or list the controls — the three primary navigation modes. The page is a wall of undifferentiated text.

And it costs 38% more markup. This is the result that changes minds: div soup is not a shortcut that trades accessibility for speed of writing. It is more to write and less to use.

ARIA rebuilds the tree at 69% more markup — and only the tree. Look at the focusable column: the ARIA variant reaches 3 only because tabindex="0" was declared on every one. ARIA changed what is announced; it granted no behaviour. Still missing versus semantic HTML:

  • Enter/Space activation (must be implemented and tested)
  • disabled semantics
  • form participation
  • automatic accessible-name computation

ARIA describes. Elements behave.

Why the tree and not an audit tool: the div soup variant has no contrast failures, no missing alt text and no invalid ARIA. It would score well on many automated audits while exposing zero controls. Only the tree shows the problem — the concrete limit-of-automation lesson fe-24 builds on.

Checkpoint

docs/verification.md Checkpoints 1 and 2.

Do this by hand too

Open DevTools → Elements → Accessibility → enable Full-page accessibility tree, and look at a page you own. Navigate it by landmark and by heading. If you cannot, neither can your users.

Step 2 — Native <dialog> vs Your Modal

Goal

Test modals behaviourally rather than visually, and price what a custom one actually costs.

Predict first

Three modals, same appearance: native <dialog> + showModal(); a naive custom <div role="dialog" aria-modal="true">; and a careful custom one with focus trap, inert, Escape and focus restore.

For each, predict pass/fail on: focus moves in · focus trapped over 8 Tabs · background inert · Escape closes · focus restored to trigger · ::backdrop available.

Then predict JS bytes for each. Most people expect the naive custom modal to be the smallest.

Run

npm run dialog

Expected output:

  behaviour          | <dialog> showModal()| custom, naive       | custom, careful
  opens              |   yes               |   yes               |   yes
  focus moves in     |   yes               |    NO               |   yes
  focus trapped      |   yes               |    NO               |   yes
  background inert   |   yes               |    NO               |   yes
  Escape closes      |   yes               |    NO               |   yes
  focus restored     |   yes               |    NO               |   yes
  ::backdrop         |   yes               |    NO               |    NO

  JS required (normalised bytes):
    <dialog> showModal()     222 bytes  (1.0x native)
    custom, naive            241 bytes  (1.1x native)
    custom, careful         1200 bytes  (5.4x native)

What just happened

The naive custom modal passes 1 of 7 — and costs more bytes than native. It is exactly what "just use a div with role="dialog" and aria-modal="true"" produces. aria-modal is a promise to assistive technology that the rest of the page is unavailable; it does not make it so. Focus walks straight out the back.

The careful version reaches 6/7 at 5.4× the code. It is a genuine good-faith implementation — focus trap with wrap-around, inert on siblings, Escape handling, focus restore. It is also now yours forever: it must keep working as new focusable element types appear, across shadow DOM boundaries, inside iframes, and through browser focus-behaviour changes.

Note how the tests are written. Not "is there a focus trap in the code" but: press Tab eight times through CDP and ask where focus is. Press Escape and ask whether it closed. Try to focus a background button and see whether the browser allows it. Behaviour is the requirement, so behaviour is what is measured — and this is the test suite you should demand of any component library before adopting it.

The one native gap is ::backdrop, available only to modal <dialog>. Which points at the real trade: the objection to native elements is almost always styling, and the platform has been closing that gap (::backdrop, Popover API, anchor positioning, appearance: base-select). See docs/analysis.md.

The decision, stated properly

You are not choosing markup. You are choosing an ownership boundary. The question is not "can we build this?" — you can — but "will it still be correct in three years, after four team changes, a framework upgrade, and a browser focus-behaviour change?"

Measured cost for the simplest possible case: 5.4× the code and still one behaviour short. A combobox is an order of magnitude beyond that.

Checkpoint

docs/verification.md Checkpoint 3.

Step 3 — Constraint Validation, and a Trap That Breaks Test Suites

Goal

Establish how much validation the platform does for free, exactly where it stops — and meet a spec behaviour that makes tests pass while production fails.

Predict first

A form with type=email, min/max, pattern, minlength, type=url and zero lines of validation JavaScript.

  1. Which constraints are enforced?
  2. Is submission blocked while invalid?
  3. Does el.validity.tooShort become true when a minlength=10 field contains "short"?

Question 3 is the one that matters. Commit to an answer.

Run

npm run forms

Expected output (abridged):

  field | constraint    | value              | valid | validity flag
  email | type=email    | "not-an-email"     |    NO | typeMismatch
  age   | min=18        | "9"                |    NO | rangeUnderflow
  code  | pattern       | "abc-12"           |    NO | patternMismatch
  site  | type=url      | "nope"             |    NO | typeMismatch

  submission blocked while invalid : yes
  :user-invalid supported          : yes

  THE DIRTY-VALUE TRAP (minlength):
    el.value = 'short'  (programmatic) -> valid=true   tooShort=false
    user types 'short'  (real keys)    -> valid=false  tooShort=true

What just happened

The platform enforced every constraint and blocked submission, with no validation code. And it gave precise reason codes through ValidityStatetypeMismatch, rangeUnderflow, patternMismatch — not a boolean.

Then the trap. minlength and maxlength apply only to values the user edited — the HTML spec's dirty value flag. Setting .value programmatically does not set it.

Consequence, stated plainly:

A test that sets .value directly never sees tooShort, and passes while the real form rejects the same input.

This is the same class of finding as fe-01's .click() result: synthetic interaction and real interaction have different semantics, and the harness is the thing that is wrong. Verify you understand it by breaking it — swap page.type() for page.fill() and watch the trap vanish.

What native validation does not do

  • inline error text in your layout (the bubble is unstyleable)
  • aria-describedby wiring between input and error
  • localisation — messages follow browser locale, not application locale
  • cross-field rules (confirm password, end date after start date)
  • server-side validation, the only one that is a security control

The architecture that survives

Native constraints as the source of truth, read through ValidityState, with your own presentation layer:

form.noValidate = true;                 // set from JS: suppresses the bubble, keeps the constraints
form.addEventListener('submit', (e) => {
  if (!form.checkValidity()) { e.preventDefault(); showErrors(form); }
});

Setting noValidate from JavaScript is the load-bearing detail: if the bundle fails to load, the attribute is never set and native validation still protects the form. That is progressive enhancement as a failure-mode strategy, not an ideology (fe-20).

Reimplementing the constraints is the mistake — that is the part you get free. Building the presentation is the actual work.

Also prefer :user-invalid to :invalid: the latter matches an untouched empty required field, so your form renders as an error on first paint.

Checkpoint

docs/verification.md Checkpoints 4 and 5.

Step 4 — Replace a Component

Goal

Apply the module to code you own, and produce a defensible decision either way.

The exercise

Pick one component from a codebase you work on:

If you have…Native primitive
custom modal<dialog> + showModal()
custom dropdown / menupopover + popovertarget, or <select>
custom accordion<details> / <summary>
custom tooltippopover + CSS anchor positioning
custom checkbox/radioreal <input> + appearance: none
custom progress / meter<progress> / <meter>

1. Measure the current one

Run it through the experiment-2 behaviour table. Do not read the code — test it:

  • Does focus move in when it opens?
  • Press Tab 10 times: does focus escape?
  • Does Escape close it?
  • Is focus restored to the trigger?
  • Can you reach the background while it is open?
  • Does it work with Enter and Space?

Record JS bytes for the component and its dependencies.

2. Build the native version

Same visual result. Measure the same table and the same bytes.

3. Decide, and write it down

Either outcome is a pass. What is required is the reasoning:

If you replace it: what did you lose? Usually specific styling. Is there a platform answer (::backdrop, ::part(), appearance, anchor positioning)?

If you keep the custom one: name every native behaviour you are now responsible for maintaining, and say what test proves each still works. If no test proves it, that is the finding — write the tests before writing the justification.

Deliverable

## Component: <name>
Current: <element>, <N> bytes JS, behaviour table <X>/7
Native:  <element>, <M> bytes JS, behaviour table <Y>/7
Lost by switching:
Platform answer (if any):
Decision:
If keeping custom — behaviours I now own, and the test that proves each:

Why this step exists

The measurements in steps 1–3 are persuasive and belong to someone else's code. This step is where the module either transfers or does not. It is also the exercise that most often surfaces an uncomfortable result: the custom component fails behaviours nobody knew it failed, because nobody had ever tested it with a keyboard.

Checkpoint

docs/verification.md Checkpoint 6.

Step 5 — Principal Engineer Review

Answer in writing.


1. A designer requires a modal backdrop your team says <dialog> cannot produce. Walk through how you verify the claim, what you propose, and what you would accept as a reason to hand-roll.

2. Your design system ships <Button> as a styled div with role="button". It is used by 40 teams. What is the blast radius of fixing it, what is the migration strategy, and what do you do in the meantime?

3. An engineer argues semantic HTML is "verbose boilerplate that hurts bundle size". You have the measurement from Step 1. Write the review comment — persuasive, not smug, and correct about what the numbers do and do not show.

4. Your automated accessibility audit reports zero violations on a page built from div soup. Explain to a PM why the audit is not evidence, and propose what you would gate on instead.

5. Given the dirty-value-flag finding, audit your test strategy. Which existing tests are lying to you? What is the general rule you would adopt about synthetic versus real interaction, and what does it cost in suite runtime?

6. Your team wants a shared useModal() hook wrapping a custom implementation. Specify the API so that native <dialog> can be swapped in later without a breaking change. What must the API not expose?

7. Web Components are proposed for design-system primitives so three frameworks can share them. Argue for and against. What does shadow DOM cost you at the accessibility boundary specifically?

8. Custom control inventories depreciate as the platform gains capabilities. Design the standing process that catches this — who reviews what, how often, and what triggers a migration rather than a note?

9. A legacy application is div soup throughout. You cannot rewrite it. Give a prioritised remediation plan and say how you would measure progress in something other than audit score.

10. Argue the strongest case against this module's thesis. Where does "prefer platform primitives" genuinely fail, and what would you want to see before overriding it?


Record

Answers in RESULTS.md. Questions 2, 6 and 8 are natural ADRs for ../fe-00-roadmap/docs/decisions/ — question 2 in particular, since reversibility and migration are the whole substance of it.

Concepts — CSS Architecture, Containment & Invalidation

Phase 1 · Platform substrate · Specification area §4 (cascade, inheritance, specificity, cascade layers, custom properties, containment, content-visibility, design tokens, theming) plus the CSS strategy comparison. Feeds §11 (performance), §26 (design systems), §38.

1. What is it

CSS is two things engineers routinely conflate:

  • A resolution algorithm — the cascade decides which declaration wins, using origin, importance, layer, specificity and source order, in that priority.
  • An invalidation system — every change tells the browser to redo some amount of style recalculation, layout, paint and compositing. How much is determined by what you have promised it about your boxes.

Most CSS architecture debate is about the first. Most CSS performance is the second. A Principal Engineer needs both, and needs to know which one a given problem is.

2. Why it matters

Rendering cost is a property of your CSS, not your DOM size. Measured here: an identical 261,011-node DOM took 401.9 ms of initial layout with no containment and 7.9 ms with content-visibility: auto — a 98% reduction with no nodes removed and no JavaScript. The DOM was never the problem; the instruction to the browser was.

Invalidation scope is a design decision. Mutating one card among 500 cost 12.7 ms of layout unbounded, 10.5 ms with contain: layout, and 2.5 ms (80% less) with contain: strict. Same mutation, same DOM, same forced-layout count — only the promise differed.

Cascade layers eliminate a whole class of organisational conflict. Measured: a layered rule with specificity 0,1,0 beats an unlayered-priority rule at 1,1,0, because layer order is evaluated before specificity. That is what lets a design system ship defaults an application can override with ordinary selectors — no specificity inflation, no !important war, no :where() tricks.

This is where design systems succeed or fail (fe-38). Token architecture, theming and override strategy are all cascade decisions, and they are extremely expensive to change once dozens of teams depend on them.

3. How it works

+==================================================================+
|  THE CASCADE — first difference decides                           |
+==================================================================+

  1. ORIGIN + IMPORTANCE
       transition > !important(UA > user > author) > animation
       > author > user > UA
       ^ author !important REVERSES layer order (measured)
  2. LAYER ORDER          <- later layer wins, BEATS specificity entirely
       unlayered styles form an implicit FINAL layer (measured)
  3. SPECIFICITY          (id, class, type)
  4. SOURCE ORDER


+==================================================================+
|  INVALIDATION — what one change forces                            |
+==================================================================+

  change a value
       |
       +-- inherited property?  -> invalidate descendants
       +-- affects box size?    -> LAYOUT (and possibly siblings, ancestors)
       +-- affects pixels only? -> PAINT
       +-- transform/opacity?   -> COMPOSITE only (off main thread)
       |
       v
  containment BOUNDS how far this can travel:
       contain: layout   internal layout cannot affect outside
       contain: paint    descendants never paint outside the box
       contain: size     box size does not depend on contents
       contain: style    counters/quotes cannot escape
       contain: strict   = layout + paint + size + style
       content-visibility: auto  -> skip rendering work entirely while offscreen

Measured

DOM nodesinitial styleinitial layout
no containment261,01199.5 ms401.9 ms
content-visibility: auto261,01110.9 ms7.9 ms
contain: strict261,01189.6 ms398.4 ms
mutation inside one of 500 cardslayout timevs none
no containment12.7 ms
contain: layout10.5 ms−18%
contain: strict + fixed height2.5 ms−80%

contain and content-visibility solve different problems. contain: strict barely helped initial rendering (398 ms vs 402 ms) because everything was still being rendered — it bounds propagation. content-visibility: auto skips the work entirely for offscreen subtrees. Reaching for the wrong one produces no improvement and a confident belief that "containment doesn't work".

4. Core terminology

TermDefinition
CascadeThe algorithm selecting a winning declaration: origin/importance → layer → specificity → source order
Cascade layer@layer group; later layers win regardless of specificity
Unlayered stylesForm an implicit final layer — they beat every explicit layer
Specificity(id, class, type) triple; only consulted after layer order
:where()Zero-specificity wrapper; the pre-layers technique for low-priority defaults
Custom property--x; inherited, resolved at computed-value time, dynamic at runtime
ContainmentPromise that limits how far a change can propagate
content-visibility: autoSkip rendering for offscreen subtrees; implies contain: layout style paint
contain-intrinsic-sizePlaceholder size for skipped subtrees; prevents scrollbar jumping
Style recalculationMatching selectors and computing values
Layout / reflowComputing geometry
Stacking contextIsolated z-ordering scope, created by transform, opacity < 1, will-change, etc.
Logical propertyFlow-relative (margin-inline-start) rather than physical; correct under RTL (fe-26)

5. Mental models

CSS is a set of promises to the rendering engine. contain: layout says "nothing inside this box can change layout outside it." The browser gives you 80% less layout work in exchange for that promise. Every containment property is this bargain, and breaking the promise (a child that must overflow) breaks the layout, not just the optimisation.

Cascade layers are an ownership model, not a feature. @layer reset, base, components, utilities is a statement about which team's CSS wins by default. Getting it right removes the need for specificity games permanently; getting it wrong is very expensive to change once shipped.

Custom properties are runtime, everything else is build time. A Sass variable is gone after compilation. A custom property is inherited, live, overridable per subtree, and readable from JS. That is why theming, dark mode and per-component overrides use custom properties and cannot use preprocessor variables — but it also means they cost more, and it is why you cannot transition them without @property.

Reach for content-visibility before virtualization. Measured: 98% less initial layout for one CSS declaration, with no JS, no scroll handlers, no measurement code, and no accessibility risk. Virtualization (fe-30) is powerful and expensive; try the declarative option first.

6. Common misconceptions

  1. "Large DOM is inherently slow." Measured false. 261,011 nodes rendered in 7.9 ms of layout with content-visibility: auto and 401.9 ms without. The node count was identical.

  2. "contain makes things faster." It bounds propagation. Measured: contain: strict gave no meaningful initial-render improvement, and 80% on the update path. Wrong tool → no result → wrong conclusion.

  3. "Specificity determines the winner." Only within a layer. Measured: 0,1,0 in a later layer beat 1,1,0 in an earlier one. And unlayered styles beat everything layered.

  4. "!important is always last resort but at least it's predictable." Measured: author !important reverses layer order, so the earliest layer wins. A design system's !important defeats the application's.

  5. "Custom properties are just variables." They are inherited and resolved at computed-value time, so a change repaints every subtree that inherits them. Their power and cost are the same property.

  6. "CSS-in-JS is slower, full stop." The runtime-vs-build-time distinction matters more than the syntax. Zero-runtime extraction and plain CSS Modules land in the same place; the debate is ergonomics, bundling and dynamic theming (see docs/analysis.md).

7. Interview talking points

  • "Large DOM isn't inherently slow — we measured 261,000 nodes at 402 ms of layout without containment and 7.9 ms with content-visibility: auto. Same nodes. The cost was what we'd told the browser it was allowed to skip."
  • "contain and content-visibility solve different problems and teams conflate them. contain bounds propagation — 80% off our update path. It did essentially nothing for initial render, because everything was still being rendered."
  • "Cascade layers are an ownership model. Layer order beats specificity entirely, so a design system can ship defaults in an early layer and applications override with ordinary selectors. It removes the !important war structurally rather than by convention."
  • "The catch is !important inverts layer order — so a design system using !important for its defaults becomes unoverridable. That's worth an explicit rule in the system's contribution guide."
  • "Before reaching for virtualization I try content-visibility. One declaration, no scroll handlers, no measurement code, no accessibility risk — and in our measurement a 98% cut in initial layout."

8. Connections to other modules

  • fe-01 — forced synchronous layout (98× measured there) is the same pipeline; this module is about scoping it, that one about ordering it.
  • fe-06 (layout) — intrinsic sizing and container queries decide when containment is possible; contain: size and contain-intrinsic-size are the bridge.
  • fe-11 (performance) — style and layout are the presentation term of INP.
  • fe-26 (i18n) — logical properties are the difference between an RTL-capable stylesheet and a rewrite.
  • fe-30 (large-scale UI)content-visibility is the declarative alternative to virtualization, and the comparison is measured there.
  • fe-38 (design systems) — layer architecture, token strategy and override policy are the system's most expensive-to-reverse decisions.
  • browser-framework-internals.md §10 (CSS Engine), §12 (Layout Engine) — cross when you need invalidation implementation rather than behaviour.

References — CSS Architecture, Containment & Invalidation

Specifications

Explanatory

Tooling

Deliberately excluded

"CSS performance tips" articles recommending shallow selectors and avoiding the descendant combinator. Selector matching has not been the dominant cost in mainstream engines for many years; the measurable costs are scope of invalidation and how much you ask the browser to render — which is what these experiments isolate. Advice predating cascade layers should also be re-read with layer order in mind, since much of it exists to work around specificity conflicts that layers resolve directly.

Analysis

Choosing between contain and content-visibility

They are not alternatives; they answer different questions.

QuestionToolMeasured effect
"Most of this is offscreen — can the browser skip it?"content-visibility: auto98% off initial layout
"A change inside this box must not disturb the page"contain: layout (+ size)80% off the update path
"This box paints nothing outside itself"contain: paintenables clipping optimisations, creates a containing block
"This box's size is independent of its content"contain: sizethe piece that made layout go from 18% → 80%

Always pair content-visibility: auto with contain-intrinsic-size. Without it, skipped subtrees measure as zero height, the scrollbar is wrong, and scroll position jumps as content is revealed. contain-intrinsic-size: auto 240px tells the browser to assume 240px until it knows better, and to remember the real value once measured.

Where content-visibility: auto does not apply: anything that must be findable by in-page search or reachable by sequential focus while offscreen behaves differently (skipped content is still searchable and focusable in modern Chromium, which is why it beats display:none, but be explicit about testing it). Anything animating offscreen. Anything whose size genuinely cannot be estimated.

Cascade layers as organisational architecture

A layer stack is a statement about which team wins by default:

@layer reset, tokens, base, components, patterns, utilities, overrides;

Properties worth understanding before adopting:

  • Order is fixed at first declaration. The @layer a, b, c; statement should live in one place, loaded first. Layers declared later slot into the existing order; unknown layers append.
  • Unlayered wins. Any stylesheet you do not control — a third-party widget, an unmigrated legacy file — outranks your entire layer stack. During migration this is a feature: legacy CSS keeps working while new CSS is layered underneath.
  • !important inverts. Measured. A design system using !important for defaults becomes unoverridable. This deserves a lint rule, not a convention.
  • Specificity still matters inside a layer. Layers do not remove the need for discipline; they remove the need for escalation.

The pre-layers technique was :where() for zero specificity, which is still useful for selector-level control. Layers work at file/team scale; :where() works at rule scale.

Custom properties: power and cost are the same property

:root { --space: 8px; }
.card { padding: var(--space); }
[data-theme="compact"] { --space: 4px; }   /* rebrands an entire subtree */

Because custom properties are inherited and resolved at computed-value time:

  • changing one on :root invalidates every element that inherits it — powerful for theming, and a real cost on large trees;
  • they cannot be interpolated by transitions/animations unless registered with @property, which gives them a syntax and type;
  • they are readable and writable from JS (getComputedStyle(el).getPropertyValue('--x')), which is what makes them the interop layer between design tokens and runtime theming;
  • they are not available at build time, so they cannot be used in media-query conditions.

The design-system consequence (fe-38): tokens should be custom properties, not preprocessor variables, precisely because runtime overridability is the requirement. The cost is that a token rename is a runtime break rather than a compile error — which is an argument for generating the token layer from a single source and type-checking consumers.

The CSS strategy comparison, honestly

ApproachRuntime costScopingDynamic themingWhere it hurts
Global CSS + BEMnoneconvention onlycustom propertiesdiscipline decays across teams
CSS Modulesnonebuild-time hashingcustom propertiescomposition across packages is awkward
Runtime CSS-in-JSstyle injection + serialisation per renderautomatictrivialSSR complexity, hydration cost, RSC friction
Zero-runtime CSS-in-JSnone (extracted)automaticlimited to what is statically knownbuild complexity; dynamic values fall back to inline styles or custom properties
Atomic / utility-firstnonen/a (no cascade to manage)custom propertiesmarkup verbosity; ejecting is a rewrite

Two observations that matter more than the table:

  1. The runtime/build-time axis dominates the syntax axis. The meaningful question is whether styles are computed per render. Everything else is ergonomics — real, but not a performance decision.
  2. Cascade layers change the calculus. Much of the appeal of CSS Modules and utility-first CSS was escaping specificity conflicts. Layers address that directly, which makes plain CSS more viable at scale than it was when those approaches were adopted. Worth re-examining a decision made before layers shipped (fe-49).

What breaks at scale

  • will-change overuse. It promotes elements to their own compositor layer, consuming GPU memory. Applied broadly (.card { will-change: transform } over thousands of cards) it degrades the thing it was meant to improve. Apply immediately before an animation and remove after.
  • Accidental stacking contexts. transform, opacity < 1, filter, will-change, contain: paint all create one. A z-index that "stops working" is nearly always an ancestor that quietly became a stacking context — and contain: paint, added for performance, is a common culprit.
  • Specificity inflation is a ratchet. Without layers, every override raises the floor permanently. The measurable symptom is !important count over time; it only ever goes up.
  • Custom property invalidation on the root. Theme switching by rewriting :root variables invalidates the whole document. Usually acceptable once per user action; not acceptable per frame, where it becomes a hidden animation cost.
  • Physical properties block internationalisation. margin-left is a rewrite when RTL arrives; margin-inline-start is not. This is the cheapest possible time to adopt logical properties and the most expensive to retrofit (fe-26).
  • Containment promises get broken by content. contain: size with content that must grow produces clipped or zero-height boxes. The optimisation and the correctness constraint are the same declaration, so containment belongs in code review, not in a stylesheet-wide sweep.

Execution Guide

ToolVersion
Node.js23.11.0 · playwright-core 1.62.1
Chrome for Testing149.0.7827.55 (arm64)
cd fe-05-css-architecture-and-invalidation/src
npm install
npm run containment    # 1. content-visibility vs contain, initial render (~20s)
npm run invalidation   # 2. how far one change propagates              (~20s)
npm run cascade        # 3. layers vs specificity, behavioural         (~5s)
npm run all

Tuning: CARDS=, ROWS_PER=, MUTATIONS=.

Why Performance.getMetrics rather than performance.now()

Style recalculation and layout happen after your JavaScript returns, inside the rendering steps (fe-01). A performance.now() wrapper around a DOM mutation measures the mutation, not the work it caused. Performance.getMetrics exposes the renderer's own cumulative RecalcStyleDuration and LayoutDuration, so sampling before and after captures what the pipeline actually paid.

Metrics are cumulative, so every measurement is a delta, and each is preceded by a settle step:

await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.evaluate(() => document.body.offsetHeight);

Two rAFs to get past the next rendering opportunity, then a forced layout to guarantee it ran. Without this you sample whatever happened to have finished.

Why the invalidation experiment forces layout synchronously

Experiment 2 calls document.body.offsetHeight after every mutation, deliberately committing the fe-01 sin of forced synchronous layout. This pins layout count to 120 in every variant, so the comparison is layout cost, not layout frequency. Without it the browser coalesces differently per variant and the numbers are not comparable.

Read that as a general rule: when comparing cost, hold the count fixed and measure the time; when comparing strategies, measure both.

Reproducibility notes

  • Viewport is pinned to 1200×800. content-visibility: auto results depend entirely on how much is offscreen; a different viewport gives different numbers.
  • Fresh page per variant.
  • Absolute milliseconds vary by machine; the ratios are the finding.
  • Headless rendering is not identical to headed rendering for compositing decisions. These experiments measure style and layout, which are less affected — but do not extend the numbers to paint or compositing claims.

Observation Guide

DevTools for style and layout

WhereWhat it tells you
Performance → Recalculate Style blockshow long selector matching + value computation took, and how many elements
Performance → Layout blocksgeometry cost; the "Nodes That Need Layout" line is the scope
Performance → red triangleforced synchronous layout, with the causing stack (fe-01)
Rendering → Paint flashingwhat actually repainted; large flashes on small changes mean bad containment
Rendering → Layer borderscompositor layers; a sea of borders means will-change overuse
Rendering → Layout Shift RegionsCLS contributors
Elements → Computed → the funnel iconwhich rule won, and what it beat
Elements → Styles → strikethroughoverridden declarations — the cascade, visualised

"Nodes That Need Layout: 12 of 40,000" in a Layout block is the single most useful containment diagnostic. A high ratio means one change is invalidating the world, which is exactly what contain: layout + contain: size bounds.

Reading a containment decision

Before adding content-visibility: auto, check:

  1. Is most of it offscreen? If everything is visible there is nothing to skip.
  2. Can you estimate a size? Without contain-intrinsic-size the scrollbar will be wrong and scroll position will jump.
  3. Does anything inside need to be measured while offscreen? Sticky headers, IntersectionObserver targets and scroll-spy will behave differently.
  4. Does find-in-page still work? Test it. Skipped content remains searchable in modern Chromium — that is precisely why this beats display: none — but verify rather than assume.

Before adding contain: size, check that the box genuinely has an author-determined size. Content that must grow inside a size-contained box is clipped or collapses to zero. The optimisation and the correctness constraint are the same declaration.

Healthy vs unhealthy

Healthy:

  • Layout blocks show a small "nodes that need layout" relative to total
  • Paint flashing highlights only what changed
  • Layer borders appear on a handful of elements, not hundreds
  • !important count is flat or falling over time
  • Long lists carry content-visibility or are virtualized deliberately

Unhealthy:

  • Recalculate Style blocks over ~10 ms on interaction — usually a :root custom-property change invalidating everything, or a very expensive selector
  • Layout blocks touching most of the document for a local change — missing containment
  • will-change in a base component class rather than applied around an animation
  • Rising !important count — specificity ratchet; the structural fix is layers
  • z-index escalation — usually an unrecognised stacking context

What these measurements do not tell you

  • Headless is not headed for compositing decisions. These experiments measure style and layout; do not extend the numbers to paint or GPU claims.
  • Viewport-dependent. Every content-visibility result is a function of how much is offscreen.
  • One engine. Containment support and invalidation heuristics differ across engines; Safari and Firefox will not reproduce these ratios.
  • Synthetic content. Real cards contain images, custom fonts and nested components with their own invalidation behaviour.
  • No paint or raster cost measured. contain: paint benefits are not captured by RecalcStyleDuration or LayoutDuration at all.

Measured Results

Chrome for Testing 149.0.7827.55 (arm64 macOS), viewport 1200×800, via playwright-core/CDP. Style and layout timings come from Performance.getMetrics (RecalcStyleDuration, LayoutDuration) — the renderer's own accounting, not a performance.now() wrapper, which cannot see work the browser does after your JavaScript returns.

Reproduce: cd ../src && npm run all.


1. Containment — 3,000 cards × 12 rows (261,011 DOM nodes)

variantDOM nodesinitial styleinitial layout
no containment261,01199.5 ms401.9 ms
content-visibility: auto261,01110.9 ms7.9 ms
contain: strict261,01189.6 ms398.4 ms

content-visibility: auto cut initial layout by 98% — 401.9 ms → 7.9 ms — with an identical node count, no JavaScript, and one CSS declaration.

contain: strict did essentially nothing here (398.4 vs 401.9 ms), and that is the important half of the result. Containment does not skip rendering; it bounds propagation. Everything was still onscreen-or-not-yet-known and therefore still rendered. A team that reaches for contain expecting content-visibility's effect measures no improvement and concludes containment is useless.


2. Invalidation scope — 500 cards, 60 mutations inside one of them

variantstyle recalclayoutlayout count
no containment0.5 ms12.7 ms120
contain: layout0.5 ms10.5 ms (−18%)120
contain: strict + fixed height1.5 ms2.5 ms (−80%)120

Same mutation, same DOM, same number of forced layouts. The only variable is how far the browser is permitted to let the change travel.

contain: layout alone gives 18% because the box can still change size, so following siblings may still move. Adding contain: size (via strict) plus a fixed height removes that too — the box cannot resize, so nothing outside it can be affected, and the layout collapses to the card's own subtree.

Note the small style-recalc increase under strict (0.5 → 1.5 ms): containment is not free, it adds bookkeeping. It pays here by a factor of five, and would not pay on a small subtree.


3. Cascade layers vs specificity

casewinnerwhy
specificity only, no layersRED (1,1,0)highest specificity wins, as expected
@layer reset, base, app — app rule 0,1,0, base rule 1,1,0BLUE (0,1,0)later layer wins; specificity never consulted
unlayered .btn vs layered #widget .btn.primaryBLUE (unlayered)unlayered styles form an implicit final layer
!important in an earlier layerRED (earlier layer)author !important reverses layer order

Resolution order confirmed (first difference decides):

  1. origin + importance — author !important reverses layer order
  2. layer order — later layer wins, beats specificity entirely
  3. specificity
  4. source order

The fourth row is the trap. A design system that ships defaults with !important becomes unoverridable by applications, which is the exact opposite of the intent. Worth an explicit rule in any system's contribution guide.


Harness notes

  • Performance.getMetrics is cumulative, so every measurement is a delta around the operation, with requestAnimationFrame×2 plus a forced offsetHeight to make the pipeline actually run before sampling. Without the settle step you measure whatever happened to have completed.
  • Fresh page per variant, for the reasons established in fe-02 and fe-03.
  • The invalidation experiment forces layout synchronously on every mutation (document.body.offsetHeight) so that layout count is identical across variants — 120 in every row. Without that, the browser would coalesce differently per variant and the comparison would be meaningless.
  • A mechanical trap, twice now: escaped apostrophes inside single-quoted console.log strings in these scripts, and backticks inside page template literals in fe-03. Both produce syntax errors reported at a confusing line.

Verification Checkpoints


Checkpoint 1 — content-visibility collapses initial layout

npm run containment

Pass: content-visibility: auto reduces initial layout by ≥ 80% versus no containment, with an identical DOM node count in all three rows.

Fail — no improvement: the cards are all within the viewport (raise CARDS), or contain-intrinsic-size is missing so every card is measured anyway.


Checkpoint 2 — contain: strict does not fix initial render

Pass: contain: strict is within ~10% of the no-containment row for initial layout.

This checkpoint passes when the number is unimpressive. That is the point: containment bounds propagation, it does not skip rendering. Conflating the two is the most common containment mistake, and the measurement is what prevents it.


Checkpoint 3 — Containment pays on the update path

npm run invalidation

Pass: contain: strict + fixed height reduces layout time by ≥ 50% versus none, with identical layout counts across all three variants (120 here).

Fail — layout counts differ: the forced synchronous layout is missing, so the browser coalesced differently per variant. The comparison is invalid until counts match.

Also note the increase in style recalc under strict (0.5 → 1.5 ms). Containment has bookkeeping cost. You should be able to say when that cost would exceed the benefit.


Checkpoint 4 — Layer order beats specificity, and !important inverts it

npm run cascade

Pass: all four cases resolve as predicted — in particular case 2 (0,1,0 in a later layer beats 1,1,0 in an earlier one) and case 4 (!important in the earlier layer wins).

Case 4 is the checkpoint that matters. State the consequence: a design system shipping defaults with !important becomes unoverridable by every consuming application.


Checkpoint 5 — You can pick the right tool from a symptom

Not a script. For each symptom, name the tool and say why the other one would not help:

SymptomYour answer
50,000-row report takes 3 s to first paint
Expanding one accordion row janks the whole page
A third-party widget's styles override ours
z-index: 9999 stopped working after a perf change
Theme switch causes a visible full-page flash

Pass: five correct tools with a reason. Row 4 requires recognising that contain: paint creates a stacking context — a performance change causing a z-index bug.


Module completion

  • Checkpoints 1–5
  • steps/04-principal-review.md answered
  • content-visibility applied to one real long list you own, with before/after numbers — or a documented reason it does not apply
  • A layer stack drafted for one real codebase, including where unmigrated legacy CSS sits

Broader Ideas

content-visibility versus virtualization

Two answers to "this list is too long", with very different costs:

content-visibility: autovirtualization
Codeone CSS declarationscroll handling, measurement, windowing, key management
DOMfull (261,011 nodes, measured)small
Memoryfull DOM retainedbounded
Find-in-pageworksbroken for unrendered rows
Sequential focus / a11yworksneeds care
Scroll restorationbrowser handles ityou handle it
Measured initial layout7.9 ms vs 401.9 mscomparable, at much higher complexity

Try the declarative option first. Virtualization remains necessary when the DOM itself is the constraint — memory on low-end devices, or hundreds of thousands of rows — but it buys DOM size at the cost of correctness features the browser was giving you free. Measured properly in fe-30.

Layers make the CSS strategy decision reversible again

Much of the appeal of CSS Modules, styled-components and utility-first CSS was escaping specificity conflicts — hashing, scoping, or abolishing the cascade because coordinating it across teams had failed.

Cascade layers address that directly and structurally. A decision made in 2019 to adopt runtime CSS-in-JS "because our CSS was unmanageable" was reasonable then and may not be now. That makes it a good candidate for the re-examination process in fe-49 (migrations) and a standing item on a technology radar (fe-50).

The general pattern is worth naming: platform capability changes invalidate past architectural decisions. A Principal Engineer's job includes noticing when a constraint that justified a decision has quietly disappeared — nobody sends a notification.

Design tokens are a cascade decision

The token pipeline that survives contact with multiple teams:

design source (Figma / JSON)
        ↓  build
CSS custom properties in an early @layer
        ↓  inherited, runtime-overridable
components consume var(--token)
        ↓
themes / density / brands override at any subtree

Two properties this gets which preprocessor variables cannot: runtime overridability (theme switching, per-subtree density, user preferences) and a JS interop boundary (getComputedStyle(el).getPropertyValue('--x')).

The cost, which should be designed for rather than discovered: a token rename is a runtime break, not a compile error. Mitigations — generate the token layer from one source, type the consumer API, keep deprecated aliases for one major version — are the substance of fe-38.

Containment as an architectural contract

contain: layout is a claim about a component's boundaries: nothing inside can affect layout outside. That is the same claim a well-designed component makes about state, data flow and side effects.

Where this becomes interesting: a component that cannot honour contain — because it must grow its parent, or escape its bounds — is usually a component with a leaky interface in other ways too. Containment failure is a useful smell for design problems that have nothing to do with CSS.

The measured 80% reduction is the mechanical payoff. The architectural payoff is a boundary you can reason about, which is why this connects forward to fe-19 (application architecture) rather than only to fe-11 (performance).

Logical properties are the cheapest i18n decision you will ever make

margin-left: 1rem;            /* a rewrite when RTL arrives */
margin-inline-start: 1rem;    /* correct in both directions, same cost today */

Retrofitting is expensive and error-prone; adopting is free. The full RTL story — bidirectional text, mirrored icons, logical scroll direction — is fe-26, but the stylesheet-level decision is made here and should simply be a lint rule.

The same argument applies to inset, padding-block, border-inline-end, and text-align: start. There is no case for physical properties in new code except deliberate physical positioning (a shadow that must fall to the right regardless of writing direction).

Step 1 — What the Browser Is Allowed to Skip

Goal

Establish that rendering cost is a property of your CSS instructions, not your DOM size — and distinguish two tools that are constantly confused.

Predict first

3,000 cards × 12 rows = 261,011 DOM nodes, in a 1200×800 viewport. Three variants: no containment, content-visibility: auto, contain: strict.

Predict initial layout time for each. Then predict which of the two containment variants helps initial render more, and by how much.

Run

cd src && npm install && npm run containment

Expected output:

  variant                    | DOM nodes | initial style | initial layout
  no containment             |    261011 |    99.5ms     |   401.9ms
  content-visibility: auto   |    261011 |    10.9ms     |     7.9ms
  contain: strict            |    261011 |    89.6ms     |   398.4ms

What just happened

content-visibility: auto cut initial layout by 98% — 401.9 ms to 7.9 ms — with an identical node count, no JavaScript, and one declaration. The DOM was never the problem.

contain: strict did essentially nothing (398.4 vs 401.9 ms). This is the more important half.

The two solve different problems, and conflating them is the most common containment mistake:

QuestionTool
"Most of this is offscreen — may the browser skip it?"content-visibility: auto
"A change inside this box must not disturb the page"contain: layout (+ size)

A team that adds contain expecting content-visibility's effect measures no improvement and concludes containment is useless. The measurement is what prevents that.

Always pair content-visibility: auto with contain-intrinsic-size. Without it, skipped subtrees measure as zero height: wrong scrollbar, jumping scroll position. contain-intrinsic-size: auto 240px says "assume 240px until you know better, then remember."

Try before continuing

CARDS=200 npm run containment

The advantage should shrink or vanish — everything now fits near the viewport, so there is nothing to skip. Being able to predict that is the checkpoint, not the original number.

Checkpoint

docs/verification.md Checkpoints 1 and 2.

Step 2 — How Far Does One Change Travel?

Goal

Measure invalidation scope, and understand containment as a promise you make to the engine.

Predict first

500 cards. Sixty times, we insert a row into card #10, force layout, and remove it. Three variants: no containment, contain: layout, contain: strict + fixed height.

Layout is forced synchronously every time, so the layout count is identical in all three. Predict the layout time for each.

Run

npm run invalidation

Expected output:

  variant                        | style recalc | layout    | layout count
  no containment                 |     0.5ms    |    12.7ms |          120
  contain: layout                |     0.5ms    |    10.5ms |          120
  contain: strict + fixed height |     1.5ms    |     2.5ms |          120

What just happened

Same mutation, same DOM, same 120 forced layouts. Only the permitted propagation differed.

contain: layout alone gave 18% — because the card can still change size, so following siblings may still move. The containment promise was partial.

Adding contain: size (via strict) plus a fixed height gave 80% — the box cannot resize, so nothing outside it can be affected, and layout collapses to the card's own subtree.

Note the style-recalc increase: 0.5 → 1.5 ms. Containment is not free; it adds bookkeeping. It paid 5× here and would not pay on a small subtree. You should be able to say when it would not.

The promise is also a constraint

contain: size means the box's size does not depend on its contents. If content must grow, it is clipped or collapses to zero. The optimisation and the correctness constraint are the same declaration, which is why containment belongs in code review rather than a stylesheet-wide sweep.

Why the experiment forces layout deliberately

Calling document.body.offsetHeight after every mutation is the fe-01 sin, committed on purpose: it pins layout count so the comparison measures layout cost. Without it the browser coalesces differently per variant and the numbers are not comparable.

General rule: when comparing cost, hold the count fixed. When comparing strategies, measure both.

Checkpoint

docs/verification.md Checkpoint 3.

Step 3 — Layers, Specificity, and the !important Inversion

Goal

Verify the cascade's real resolution order, and find the trap that makes a design system unoverridable.

Predict first

For each, which colour wins?

  1. #widget .btn { red } (1,1,0) vs .btn.primary { green } (0,2,0) — no layers
  2. @layer reset, base, app; with @layer app { .btn { blue } } (0,1,0) and @layer base { #widget .btn { red } } (1,1,0)
  3. @layer b { #widget .btn.primary { red } } vs an unlayered .btn { blue }
  4. @layer base, app; with @layer app { .btn { blue } } and @layer base { .btn { red !important } }

Case 4 is the one to think hardest about.

Run

npm run cascade

Expected output:

  specificity only (no layers)      -> RED    high-specificity #id rule wins
  layers: reset < base < app        -> BLUE   later LAYER wins despite lower specificity
  unlayered beats every layer       -> BLUE   unlayered = implicit final layer
  !important INVERTS layer order    -> RED    important reverses layer precedence

What just happened

The cascade resolves in this order — first difference decides:

  1. origin + importance
  2. layer order — later wins, and specificity is never consulted
  3. specificity
  4. source order

Case 2 is why layers matter. A rule with specificity 0,1,0 beat one with 1,1,0. Layer order is evaluated before specificity, so a design system can ship defaults in an early layer and applications override them with ordinary selectors — no specificity inflation, no !important, no :where() tricks.

Case 3 is the migration story. Unlayered styles form an implicit final layer, so anything you do not control — third-party widgets, unmigrated legacy CSS — outranks your entire stack. During migration that is a feature: legacy CSS keeps winning while new CSS is layered underneath.

Case 4 is the trap. Author !important reverses layer order. A design system that ships its defaults with !important becomes unoverridable by every consuming application — the exact opposite of the intent. That deserves a lint rule, not a convention.

Design your stack

@layer reset, tokens, base, components, patterns, utilities, overrides;
  • Declare the order once, loaded first.
  • Decide explicitly where unmigrated legacy CSS sits (it is unlayered, so: on top).
  • Ban !important inside system layers.
  • Specificity still matters within a layer — layers remove escalation, not discipline.

Checkpoint

docs/verification.md Checkpoints 4 and 5.

Step 4 — Principal Engineer Review


1. A team reports "our app is slow because the DOM is too large — we need virtualization everywhere." You have the containment measurement. How do you respond, what do you ask them to try first, and when would you agree with them?

2. Your design system ships defaults with !important "so they aren't accidentally overridden." Explain the consequence, and propose what to do instead. What is the migration path for consumers already working around it?

3. Design the cascade layer stack for a codebase with: a third-party widget you cannot modify, 5,000 lines of unmigrated legacy CSS, a design system, and application code. Where does each sit, and why is the legacy CSS's position actually convenient?

4. An engineer adds contain: strict to every component in the design system. What breaks? What would you require before accepting containment into shared components?

5. z-index: 9999 stopped working after a performance change. Explain the likely mechanism and how you would confirm it in under two minutes.

6. Your organisation chose runtime CSS-in-JS in 2019 because "our CSS was unmanageable at scale." Cascade layers now exist. Is that decision worth revisiting? Give the criteria, the cost of being wrong in each direction, and how you would run the evaluation without it becoming a year-long debate.

7. Theme switching causes a visible full-page flash. Explain the mechanism in terms of custom property inheritance, and give two fixes with different trade-offs.

8. Write the lint rules you would enforce on CSS in a large codebase. For each: what it catches, its false-positive rate, and why it is worth the friction. Include at least one that most teams do not have.

9. A token rename broke three applications at runtime with no build-time error. Design the token pipeline that would have caught it, and say what it costs.

10. Argue against this module's emphasis. When is CSS architecture not where the leverage is, and what would tell you that from a profile?


Answers in RESULTS.md. Questions 2, 3, 6 and 9 are natural ADRs for ../fe-00-roadmap/docs/decisions/.

Concepts — Layout: Flex, Grid, Intrinsic Sizing & Container Queries

Phase 1 · Platform substrate · Specification area §4 (flexbox, grid, subgrid, container queries, media queries, intrinsic sizing, min/max-content, stacking contexts, positioning). Depends on fe-05.

1. What is it

Layout is the pipeline stage that turns styled boxes into geometry. This module covers the algorithms you choose between (block, flex, grid, absolute), the sizing vocabulary that decides how much measuring the browser must do, and container queries — the first mechanism that lets a component respond to its own context rather than the viewport.

2. Why it matters

Layout-mode folklore is wrong, and expensive. Measured: block, inline-block, float, flex, grid and absolute positioning across 20,000 items span 1.4× — and absolute positioning, the mode usually described as fastest, was the slowest (75.5 ms vs block's 55.1 ms). On relayout, flex was the fastest (7.3 ms). Teams contort component APIs to avoid flex/grid on performance grounds that do not survive measurement.

Where layout cost actually lives is how much you ask the browser to lay out. Compare this module's 1.4× spread against fe-05's content-visibility result: 98% off initial layout, same DOM. Layout mode is a rounding error; layout scope is the decision.

Container queries change what a component can be. Measured: a @container rule responded when its container narrowed to 400 px while the viewport stayed at 1200 px; the equivalent @media rule did nothing. That is not a performance difference, it is a capability difference — and it is what makes genuinely reusable components possible (fe-38).

3. How it works

+==================================================================+
|  SIZING: how much must the browser MEASURE?                       |
+==================================================================+

  width: 200px        -> no measurement            (cheapest)
  width: 20%          -> depends on parent          (one pass)
  width: auto          -> depends on context
  width: max-content   -> measure content, no wrap
  width: fit-content   -> min(max-content, available)
  width: min-content   -> measure longest unbreakable unit  (dearest: 52.1ms vs 33.1ms)

+==================================================================+
|  RESPONSIVE: what is the rule allowed to ask about?               |
+==================================================================+

  @media     -> the VIEWPORT.  A component cannot know it is in a sidebar.
  @container -> the nearest ancestor with container-type.
                Requires containment on that ancestor -- which is the cost.

     .wrap { container-type: inline-size; }   <- promises size containment
     @container (min-width: 700px) { ... }

Measured

layout mode (20,000 items)initial layoutrelayout
block55.1 ms23.5 ms
inline-block55.7 ms9.5 ms
flex59.3 ms7.3 ms
grid62.5 ms14.7 ms
float75.2 ms36.2 ms
absolute75.5 ms9.8 ms

Spread: 1.4×.

responsive strategy (8,000 items)initial layoutinitial style
none31.8 ms4.3 ms
@media31.8 ms4.8 ms
@container33.2 ms7.3 ms
container narrowed to 400 px, viewport unchangedpadding
@container8px → 0px — responded
@media8px → 8px — ignored

4. Core terminology

TermDefinition
Formatting contextThe layout algorithm governing a box's children (block, inline, flex, grid)
Intrinsic sizingSizing from content: min-content, max-content, fit-content
min-contentWidth of the largest unbreakable unit; requires measuring every word
Containing blockThe ancestor box a percentage or absolute offset resolves against
Stacking contextIsolated z-ordering scope; created by transform, opacity<1, contain: paint, …
Container query@container; matches against an ancestor with container-type
container-type: inline-sizeEnables inline-axis container queries; implies size containment on that axis
Container query unitcqw, cqh, cqi, cqb — relative to the query container
SubgridChild grid inheriting its parent's tracks, so nested content can align across items
aspect-ratioReserves space before content loads; a CLS tool (fe-11)
GapSpacing that does not participate in the box model; works in flex, grid and multicol

5. Mental models

Choose layout mode for expressiveness, not speed. The 1.4× spread means the question is which algorithm expresses your intent with the fewest workarounds. Grid for two-dimensional relationships, flex for one-dimensional distribution, block for flow. A layout that needs no wrapper divs and no magic numbers will outperform a "faster" one that needs both.

Sizing keywords are measurement requests. min-content costs 1.6× a fixed width because the browser must measure every unbreakable unit. That is a real cost and usually the right trade — fit-content and max-content measured close to fixed, and they remove entire classes of magic number.

Container queries move the responsive boundary from the page to the component. A component that queries the viewport is not reusable: dropping it in a sidebar produces a layout designed for a main column. Container queries make "responsive" a property of the component rather than of the page, which is what a design system needs to be honest.

Containment is the price of a container query. container-type: inline-size implies size containment on that axis, so the container's inline size may no longer depend on its contents. Measured at +1.4 ms layout and +3.0 ms style over 8,000 items — cheap, but not free, and the constraint matters more than the cost.

6. Common misconceptions

  1. "Flex and grid are slow; use block or absolute." Measured backwards. Absolute was slowest overall (75.5 ms) with the highest style cost, and flex was fastest on relayout (7.3 ms).

  2. "Absolute positioning avoids layout." It takes the element out of flow; it does not avoid layout. It measured worst here, partly because per-element inline positioning inflates style cost (27.7 ms vs ~11 ms).

  3. "Intrinsic sizing is too expensive." 1.6× worst case, and only min-content is notably dearer. fit-content and max-content measured within ~7% of a fixed width.

  4. "Container queries are expensive." +1.4 ms of layout across 8,000 items. The real cost is the containment constraint, not the query.

  5. "Container queries replace media queries." They answer different questions. Page-level decisions — how many columns in the shell, whether a nav collapses — are viewport questions. Component-level decisions are container questions.

  6. "Layout performance is about picking the right properties." fe-05 measured 98% off initial layout from content-visibility. This module measured 1.4× across every layout mode. Scope dominates mode by two orders of magnitude.

7. Interview talking points

  • "We measured six layout modes over 20,000 items and the spread was 1.4×, with absolute positioning slowest and flex fastest on relayout. Choosing layout mode for performance is optimising the wrong variable — layout scope is worth ~98%, mode is worth ~40%."
  • "Container queries are a capability change, not a performance one. A component that queries the viewport can't be reused in a sidebar. We verified it: narrowing the container to 400px changed @container styles and left @media untouched at a 1200px viewport."
  • "The cost of a container query is container-type, which implies size containment. That's a constraint on your layout, not a millisecond figure — the milliseconds were negligible."
  • "min-content is the one intrinsic keyword with a real cost, because the browser measures every unbreakable unit. fit-content is nearly free and removes most magic numbers."

8. Connections to other modules

  • fe-05 — direct comparison: containment/content-visibility (98%) versus layout mode (1.4×). Same pipeline stage, two orders of magnitude apart.
  • fe-01 — forced synchronous layout is what makes any of this expensive per frame.
  • fe-11aspect-ratio and explicit sizing are the primary CLS tools.
  • fe-26 (i18n) — logical properties, and why gap/grid survive RTL where margin hacks do not.
  • fe-30 — when layout scope genuinely cannot be reduced declaratively.
  • fe-38 — container queries are what make design-system components context-independent.
  • browser-framework-internals.md §12–13 (Layout Engine) — cross for the algorithms themselves.

References — Layout

Specifications

Explanatory

Tooling

Deliberately excluded

Articles ranking layout methods by performance. Measured spread across six modes was 1.4×, with the usual "fast" recommendation slowest. Most such advice predates modern layout engines, and all of it is two orders of magnitude away from the decision that matters (content-visibility, fe-05).

Analysis

Choosing a layout mode

Since the measured spread is 1.4×, choose for expressiveness:

NeedModeWhy
Two-dimensional relationships, alignment across rows and columnsgridthe only mode that expresses both axes; subgrid extends it to children
One-dimensional distribution, unknown item countflexgap, flex-wrap, and content-based sizing without magic numbers
Document flow, proseblockcheapest, and what the content actually is
Overlay, tooltip, precise placement relative to a containing blockabsolutebut measure — it was slowest here, with 2.4× the style cost
Legacy compatibility onlyfloatslowest on both axes; no reason to choose it in new code

The performance-shaped question that is actually worth asking is not "which mode" but "how many boxes am I asking the browser to lay out, and how often?" That is fe-05's territory (content-visibility, containment) and fe-01's (avoiding forced synchronous layout).

Why absolute positioning measured slowest

Two contributions, and separating them matters:

  1. Style cost, 27.7 ms vs ~11 ms. Every item carried inline left/top. Inline styles bypass selector matching but still require value computation per element, and they defeat the shared computed-style caching that identical elements otherwise benefit from.
  2. Layout is not avoided. Out-of-flow boxes still need their containing block resolved, their own sizing, and their own paint order. "Out of flow" means "does not affect siblings", which helps invalidation scope — visible in the relayout column (9.8 ms, better than block's 23.5 ms) — not initial cost.

The lesson generalises: a technique can be worse on one axis and better on another, and folklore usually remembers only one of them.

Container queries: capability, cost, constraint

Capability. A component can respond to the space it is given. This is what makes a design-system component honest — the same card in a 320 px sidebar and a 900 px main column can differ without the consuming page passing a prop describing where it is.

Cost. +1.4 ms layout, +3.0 ms style over 8,000 elements. Negligible at any realistic component count.

Constraint — the part that matters. container-type: inline-size implies size containment on the inline axis: the container's inline size may not depend on its contents. Consequences:

  • A container whose width is determined by its content (a fit-content wrapper, a table cell, an inline-block sized to text) cannot be a query container without changing its sizing behaviour.
  • container-type: size (both axes) is stricter still and will collapse height unless one is set.
  • Query containers cannot query themselves — styles inside @container apply to descendants, and a container cannot restyle its own width based on its own width. That circularity is why the containment requirement exists.

Practical rule: put container-type on a wrapper you control that has an externally-determined width, and query it from the component inside. Do not put it on the component root and then try to size that root from its contents.

When media queries remain correct

Container queries are not a replacement:

DecisionQuery
How many columns in the page shell@media
Whether the primary nav collapses@media
Print styles@media print
prefers-reduced-motion, prefers-color-scheme, forced-colors@media (user preference, not size)
Whether a card shows a horizontal or stacked layout@container
Whether a data table becomes a definition list@container

The distinction: page-level composition is a viewport question; component internals are a container question. A codebase that uses only media queries cannot build reusable components; one that uses only container queries cannot express its page shell.

Intrinsic sizing in practice

min-content is the expensive keyword (1.6×) and also the most commonly needed one — it is what prevents a flex item collapsing below its content. The idiom worth knowing:

.item { min-width: 0; }            /* opts OUT of the default min-content floor */
.item { min-width: min-content; }  /* opts back in explicitly */

Flex and grid items default to min-width: auto, which resolves to min-content — the single most common cause of "my flex item won't shrink" and of unexpected overflow in grid. Setting min-width: 0 is the fix, and it is also a small performance win because it removes a measurement.

fit-content measured within 7% of a fixed width and removes most magic numbers; prefer it over hardcoded widths where the content should decide.

What breaks at scale

  • Subgrid support assumptions. subgrid is the correct tool for aligning nested content across cards, and its absence is usually worked around with fixed heights that break under long strings or large text settings (fe-26). Check support before designing around it.
  • aspect-ratio and CLS. Reserving space for media before it loads is the cheapest CLS fix available, and it is a layout decision made here rather than a performance patch later (fe-11).
  • Container query units in shared components. cqw inside a component makes it depend on being inside a query container. If a consumer forgets container-type, units resolve against the nearest ancestor that is one — or the small-viewport fallback — silently. Design-system components should either declare their own container or document the requirement loudly.
  • Stacking contexts created accidentally. Every transform, opacity < 1, filter, and contain: paint creates one; z-index then only competes within it. The commonest version is a performance change (will-change, contain) breaking an overlay (fe-05).
  • Layout thrash is orthogonal to all of this. fe-01 measured 98× from interleaved reads and writes. No layout mode choice recovers that.

Execution Guide

ToolVersion
Node.js 23.11.0 · playwright-core 1.62.1Chrome for Testing 149.0.7827.55 (arm64)
cd fe-06-layout/src
npm install
npm run modes      # 1. six layout modes over 20,000 items   (~30s)
npm run sizing     # 2. intrinsic sizing + container queries (~30s)
npm run all

Tuning: ITEMS=.

Method notes

  • Performance.getMetrics deltas, as in fe-05, with a two-rAF + forced-offsetHeight settle before each sample. Style/layout happen after your JS returns; a performance.now() wrapper cannot see them.
  • Fresh page per variant.
  • Viewport pinned to 1200×800. Layout results depend on available width.
  • The relayout measurement sets wrap.style.width and forces layout synchronously, so every variant performs the same number of layouts and the comparison is cost, not frequency.
  • The absolute-positioning variant is deliberately realistic: per-element inline left/top, which is how absolute layouts are actually built. Its elevated style cost (27.7 ms) is part of the technique, not an artefact — but it is worth knowing which part of the number that is.
  • The container-query behavioural check narrows the container while leaving the viewport at 1200 px, then reads getComputedStyle. That is the whole experiment: the media-query variant is unable to notice.

Reproducibility

Absolute milliseconds are machine-specific; ratios and orderings are the findings. The 1.4× spread in experiment 1 is the point — if you measure 3× or 1.05×, the conclusion (mode is not where layout cost lives) is unchanged, and the comparison against fe-05's 98% is what carries it.

Observation Guide

DevTools for layout

WhereUse
Elements → Layout panetoggle grid/flex overlays; shows line numbers, areas, gaps
Grid overlay badgesthe grid/flex badge next to an element in Elements
Performance → Layout blocksduration and "Nodes That Need Layout: N of M"
Performance → red triangleforced synchronous layout, with the causing stack
Rendering → Layout Shift Regionswhat moved; the CLS diagnostic
Elements → Computed → containerwhich ancestor a container query resolves against

"Nodes That Need Layout: 12 of 40,000" remains the most useful single number. A high ratio for a local change means containment is missing (fe-05), not that the layout mode is wrong.

Debugging container queries

Three failures account for most container-query bugs:

  1. No container. @container with no ancestor carrying container-type never matches. Check Computed → container-type on ancestors.
  2. Wrong container. Queries resolve against the nearest qualifying ancestor. A nested query container silently intercepts. Name them (container-name) and query by name in shared code.
  3. Container sized by its contents. container-type: inline-size means the inline size may not depend on contents. A fit-content or floated wrapper will change behaviour when it becomes a container — this reads as "adding container-type broke my layout", and it is the containment promise being enforced.

Debugging flex and grid

SymptomCause
Flex item will not shrink below its contentmin-width: automin-content. Fix: min-width: 0
Grid item overflows its tracksame, on min-width/min-height
justify-content does nothingitems already fill the line; you probably want flex: 1 or justify-items
Gap ignorednot a flex/grid container, or using an old multicol context
Nested content will not align across cardsneeds subgrid; the usual workaround is fixed heights, which breaks under long strings

The min-width: auto default is the single most common flex/grid confusion and worth internalising: flex and grid items refuse to shrink below their content by default.

What these measurements do not tell you

  • No paint or compositing cost measured. RecalcStyleDuration and LayoutDuration only.
  • Headless is not headed for compositing; these numbers are style/layout, which are less affected, but do not extend them.
  • Synthetic uniform content. Real layouts mix images, fonts, and nested components with their own intrinsic sizing, which changes the measurement burden.
  • One viewport. Every result depends on available width.
  • No layout thrash here. fe-01 measured 98× from interleaved reads and writes; that dominates everything in this module and is invisible to these experiments by construction.

Measured Results

Chrome for Testing 149.0.7827.55 (arm64), viewport 1200×800, Performance.getMetrics deltas with a two-rAF + forced-layout settle. Fresh page per variant. Reproduce: cd ../src && npm run all.


1. Layout modes — 20,000 items, identical content and visual result

modeinitial layoutinitial stylerelayout (width change)
block55.1 ms11.7 ms23.5 ms
inline-block55.7 ms11.3 ms9.5 ms
flex59.3 ms11.3 ms7.3 ms
grid62.5 ms10.5 ms14.7 ms
float75.2 ms11.0 ms36.2 ms
absolute75.5 ms27.7 ms9.8 ms

Spread: 1.4×. Fastest initial: block. Slowest: absolute.

Three results that contradict common advice:

  • Absolute positioning was slowest, and carried 2.4× the style cost of every other mode — the per-element inline left/top declarations. "Take it out of flow to make it fast" does not hold.
  • Flex was fastest on relayout (7.3 ms), beating block (23.5 ms) by 3.2×.
  • Float was second-slowest on both measures, and is the mode most often retained for compatibility reasons.

Compare against fe-05: content-visibility cut initial layout by 98%. Layout mode spans 1.4×. Scope dominates mode by two orders of magnitude.


2A. Intrinsic sizing — 8,000 items

widthinitial layoutrelayout
200px33.1 ms1.5 ms
20%31.9 ms14.6 ms
min-content52.1 ms2.1 ms
max-content35.4 ms1.4 ms
fit-content35.2 ms1.7 ms
auto33.9 ms9.5 ms

Spread: 1.6×. Only min-content is notably expensive — the browser must measure the longest unbreakable unit in every box. fit-content and max-content land within ~7% of a fixed width.

Note the relayout column inverts the picture: 20% and auto are dearest to re-lay-out (14.6 ms, 9.5 ms) because they depend on the parent, while the intrinsic keywords are nearly free once measured.


2B. Container queries vs media queries — 8,000 items

strategyinitial layoutinitial stylerelayout
none31.8 ms4.3 ms9.1 ms
@media31.8 ms4.8 ms9.7 ms
@container33.2 ms7.3 ms9.3 ms

Container queries cost +1.4 ms layout and +3.0 ms style over 8,000 elements.

The behavioural result — the reason they exist

Container narrowed to 400 px, viewport unchanged at 1200 px:

padding beforepadding after
@container (min-width: 700px)8px0px — responded
@media (min-width: 700px)8px8px — ignored

The media query cannot see the container. A component styled with media queries produces a main-column layout when dropped into a sidebar, and there is no way to fix it from inside the component. That is a capability gap, not a performance one.

The real cost is the constraint: container-type: inline-size implies size containment on the inline axis, so the container's width may no longer be derived from its contents.

Verification Checkpoints


Checkpoint 1 — Layout mode barely matters

npm run modes

Pass: spread across all six modes is under ~3×, and flex and grid are not the slowest.

The checkpoint is that the result is boring. If you predicted flex/grid would be markedly slower, record the correction in the learning log — it is one of the most widely repeated pieces of frontend folklore.

Fail — spread over 10×: one variant is doing different work. The absolute variant must position every item, and the block variant must not wrap; check the generated markup.


Checkpoint 2 — Absolute positioning is not free

Pass: the absolute variant's style cost is markedly higher than the others (~2×+), and its initial layout is not the fastest.

You should be able to attribute the style cost to per-element inline left/top, and explain why its relayout number is nevertheless good (out-of-flow bounds invalidation scope).


Checkpoint 3 — Only min-content is expensive

npm run sizing

Pass: min-content is the slowest sizing keyword; fit-content and max-content are within ~15% of a fixed width.

Also note the inversion in the relayout column: % and auto are dearest to re-lay-out. You should be able to say why (they depend on the parent).


Checkpoint 4 — Container queries respond; media queries cannot

Pass:

@container  padding: 8px -> 0px    responded to the CONTAINER
@media      padding: 8px -> 8px    ignored it; viewport did not change

and @container's measured overhead is small (a few ms over 8,000 elements).

This is the module's central result. State the consequence: a component styled with media queries cannot be reused across contexts, and no amount of care inside the component fixes it.


Checkpoint 5 — You can place container-type correctly

Not a script. Given a card component that should stack below 400 px and sit horizontally above it, write the CSS. Then answer:

  1. Which element carries container-type, and why not the card root?
  2. What happens if a consumer forgets it?
  3. What sizing behaviour did you give up by adding it?
  4. When would @media still be correct for part of this component?

Pass: container-type on a wrapper with an externally-determined width; a clear statement that size containment means the container's inline size no longer derives from contents; and recognition that user-preference queries (prefers-reduced-motion) stay @media.


Module completion

  • Checkpoints 1–5
  • steps/03-principal-review.md answered
  • One real component converted from viewport queries to container queries, with the constraint documented
  • Learning log updated with the layout-mode correction

Broader Ideas

Container queries make design systems possible

Before container queries, a "reusable" component had three bad options for context-awareness: a size prop (the consumer must know, and can be wrong), a viewport media query (wrong in every non-full-width context), or ResizeObserver (JavaScript, layout thrash risk, hydration mismatch).

@container removes the coordination entirely. The component asks about the space it was given.

This is a genuine capability change, and it invalidates prior architecture: component APIs designed around size="compact" | "comfortable" props may now be expressing in TypeScript something CSS can determine. Worth re-examining as part of fe-38 and the technology-radar process in fe-50 — the same pattern noted in fe-05, where cascade layers invalidated the reasoning behind CSS-in-JS adoption.

ResizeObserver is now mostly a fallback

The historical pattern — observe an element, set a class, style from the class — costs a JS round-trip per resize, risks layout thrash (fe-01), and produces SSR/hydration mismatches because the server does not know the size.

@container is declarative, runs in the rendering steps, and is correct on first paint including server-rendered HTML. ResizeObserver remains right when you need the number in JavaScript (canvas sizing, virtualization measurement, chart scales), not when you need styling.

Note the connection back to fe-01: ResizeObserver callbacks run inside "update the rendering", can loop, and are specified to drop notifications rather than hang — the platform choosing to lose work instead of freezing.

Intrinsic sizing removes magic numbers, which removes bugs

.badge { width: 80px; }               /* breaks: long strings, i18n, user font size */
.badge { width: fit-content; }        /* correct; measured within 7% of fixed */

Every hardcoded dimension is an assumption about content that will eventually be false — German compounds, Arabic, a user with a 200% font size, a product name nobody anticipated (fe-26). Intrinsic sizing costs a measurement and removes the whole class.

The exception is deliberate truncation, where a fixed size is the design decision. Then it should be paired with text-overflow, a title, and a plan for what a screen reader announces.

Layout is where CLS is won

Cumulative Layout Shift is a layout-stability problem, decided by choices in this module rather than by anything in a performance sprint:

CauseFix, at design time
images without dimensionswidth/height attributes, or aspect-ratio
ads/embeds of unknown sizereserve with min-height or aspect-ratio
web fonts swapping metricssize-adjust, ascent-override on @font-face
content injected above the foldreserve space, or inject below
content-visibility without contain-intrinsic-sizealways pair them (fe-05)

The last row connects the two modules directly: the 98% layout win from fe-05 introduces a CLS risk if contain-intrinsic-size is omitted, because skipped subtrees measure as zero.

The folklore-correction pattern

This module's headline result — 1.4× across every layout mode, with the supposedly-fastest mode slowest — is the third instance of the same pattern in Phase 1:

ModuleFolkloreMeasured
fe-03property order is the hidden-class hazard1.14×; delete is 11.9×
fe-04semantic HTML is verbosediv soup is 1.38× larger
fe-06flex/grid are slow, absolute is fast1.4× spread; absolute slowest

The transferable skill is not the individual corrections — those will drift with engine versions. It is the reflex: when advice is repeated confidently and cheaply, measure it before you design around it. Every one of these had a real effect somewhere, misremembered as a general rule.

Step 1 — Does Layout Mode Matter?

Goal

Test a widely-repeated performance claim, and calibrate where layout cost actually lives.

Predict first

20,000 items, identical content and identical visual result, laid out six ways: block, inline-block, float, flex, grid, absolute.

Rank them by initial layout time, and predict the spread between fastest and slowest. Most engineers predict flex/grid slowest and absolute fastest, with a large spread. Commit.

Run

cd src && npm install && npm run modes

Expected output:

  mode         | initial layout | initial style | relayout (width change)
  block        |    55.1ms      |    11.7ms     |    23.5ms
  inlineBlock  |    55.7ms      |    11.3ms     |     9.5ms
  float        |    75.2ms      |    11.0ms     |    36.2ms
  flex         |    59.3ms      |    11.3ms     |     7.3ms
  grid         |    62.5ms      |    10.5ms     |    14.7ms
  absolute     |    75.5ms      |    27.7ms     |     9.8ms

  spread across all six modes: 1.4x   (fastest: block, slowest: absolute)

What just happened

1.4× across every layout mode. Flex and grid are within noise of block. And the two modes most often recommended for performance — absolute and float — were the two slowest.

Absolute positioning carried 2.4× the style cost (27.7 ms vs ~11 ms). That is the per-element inline left/top: inline styles skip selector matching but still require per-element value computation, and they defeat computed-style sharing between identical elements.

But absolute relayouts well (9.8 ms vs block's 23.5 ms), because out-of-flow boxes do not shift their siblings. A technique can be worse on one axis and better on another; folklore usually remembers one.

Flex was fastest on relayout — 7.3 ms, 3.2× better than block.

The calibration that matters

effect on layout
choosing the "fastest" layout mode1.4×
content-visibility: auto (fe-05)98% reduction
avoiding forced synchronous layout (fe-01)98× on a thrashing loop

Layout mode is a rounding error. Choose it for expressiveness: grid for two-dimensional relationships, flex for one-dimensional distribution, block for flow. A layout needing no wrapper divs and no magic numbers beats a "faster" one that needs both.

Checkpoint

docs/verification.md Checkpoints 1 and 2. Log the correction if you predicted otherwise — this is the third Phase-1 module where confident folklore failed measurement.

Step 2 — Intrinsic Sizing and Container Queries

Goal

Price intrinsic sizing, then establish that container queries are a capability change rather than a performance one.

Predict first

A. 8,000 items sized by 200px, 20%, min-content, max-content, fit-content, auto. Which is dearest, and by how much?

B. The same list with no responsive rule, a @media (min-width: 700px) rule, and a @container (min-width: 700px) rule. What does the container query cost?

C. The container is narrowed to 400 px while the viewport stays at 1200 px. What happens to each rule?

Run

npm run sizing

Expected output:

  width value    | initial layout | relayout
  fixed          |    33.1ms      |     1.5ms
  min-content    |    52.1ms      |     2.1ms
  fit-content    |    35.2ms      |     1.7ms
  auto           |    33.9ms      |     9.5ms
  spread: 1.6x

  strategy       | initial layout | initial style
  none           |    31.8ms      |     4.3ms
  media          |    31.8ms      |     4.8ms
  container      |    33.2ms      |     7.3ms

  BEHAVIOUR — container narrowed to 400px, VIEWPORT unchanged at 1200px:
    @container  padding: 8px  -> 0px    responded to the CONTAINER
    @media      padding: 8px  -> 8px    ignored it

What just happened

Only min-content is expensive (1.6×) — the browser must measure the longest unbreakable unit in every box. fit-content and max-content are within ~7% of a fixed width, so most magic-number removal is nearly free.

The relayout column inverts it: % (14.6 ms) and auto (9.5 ms) are dearest to re-lay-out because they depend on the parent. Intrinsic keywords are nearly free once measured.

Container queries cost +1.4 ms of layout over 8,000 elements — negligible.

And they do something media queries structurally cannot. The media query could not see the container narrow, because the viewport never changed. A component styled with media queries produces a main-column layout when dropped into a sidebar, and no amount of care inside the component fixes it.

The real cost is the constraint, not the milliseconds

container-type: inline-size implies size containment on the inline axis: the container's inline size may no longer depend on its contents.

  • A wrapper sized by its content (fit-content, floated, inline-block) changes behaviour when it becomes a container. This reads as "adding container-type broke my layout" — it is the promise being enforced.
  • A container cannot query itself. That circularity is exactly why containment is required.

Practical rule: put container-type on a wrapper with an externally-determined width, and query it from the component inside.

Media queries are not obsolete

DecisionQuery
page shell columns, nav collapse, print@media
prefers-reduced-motion, prefers-color-scheme, forced-colors@media
card stacks vs sits horizontally@container

Page-level composition is a viewport question; component internals are a container question.

Checkpoint

docs/verification.md Checkpoints 3, 4 and 5.

Step 3 — Principal Engineer Review


1. An engineer proposes replacing flexbox with absolute positioning in a hot list "for performance". You have the measurement. Write the review comment, including what they may be right about.

2. Your design system's <Card> takes a size="compact" | "comfortable" prop that consumers set based on where they are placing it. Container queries now exist. Should the prop go? Give the migration path and the case for keeping it.

3. A team reports "adding container-type broke our layout — the wrapper collapsed." Diagnose it without seeing the code, and give the two possible fixes.

4. Where should container-type live in a design-system component: the component root, a wrapper the component renders, or the consumer's element? Argue each, then decide.

5. Your CLS is 0.28. Enumerate the likely causes in order of probability, and say which are layout decisions versus loading decisions.

6. A component uses cqw units. A consumer forgets container-type. What happens, why is it hard to notice, and how would you make the failure loud?

7. Phase 1 has now produced three measured folklore corrections (fe-03 delete vs property order; fe-04 div soup size; fe-06 layout modes). Design the team practice that catches this class of error before it becomes a standard. What does it cost, and what stops it becoming bureaucratic?

8. When is a fixed pixel dimension the correct choice over fit-content? Give two cases and what you must pair them with.

9. subgrid would solve an alignment problem cleanly but you must support a browser without it. Give three strategies with different failure modes, and say which you would ship.

10. Argue against this module's conclusion. Construct a realistic scenario in which layout mode choice is the dominant cost, and say how you would recognise it from a profile.


Answers in RESULTS.md. Questions 2, 4 and 7 are natural ADRs.

Concepts — Browser & Framework Internals

1. What is it

A systems-construction track driven by browser-framework-internals.md (the specification), which lists 52 areas spanning Chromium/Blink internals, the rendering pipeline, and build-it-yourself framework runtimes. This module converts that flat list into an executable order: which areas gate which, what runs in parallel, where the local build actually matters, and what "done" means.

The unit of work is a numbered module directory (bi-NN-topic/) containing CONCEPTS.md, docs/ (execution, observation, verification, analysis), src/, and references.md — mirroring the fe-NN- convention used by the sibling track.

Root PROGRESS.md tracks this track. fe-00-roadmap/docs/progress.md tracks the other one.

2. Why it matters

Read in specification order, the 52 areas look sequential: build Chromium, then read Blink, then build frameworks. That reading produces three expensive failures.

  • Blocking on the build. The specification puts "Build Chromium From Source" at §3, which reads as a prerequisite. It is not. Source navigation, spec↔implementation mapping, tracing and archaeology all work on a stock browser with zero build. Treating the build as a gate can stall the track indefinitely — as it did here, where the local toolchain turned out to be 15 months below trunk's floor. Everything through Phase 2 continued regardless.
  • Copying Fiber instead of deriving it. §27 lists mini-React's stages in order, ending at scheduling and interruptible work. Attempted before browser scheduling (§16), those stages become transcription: the learner reproduces a data structure without having felt the frame budget that forced it. The specification's own §45 forbids this ("Do not begin with production source"), but its ordering invites it.
  • Building the mini-browser as a monolith. §26 presents 15 milestones as one project. Built as a block after §6–§15, every "why is Blink like this?" question has already been answered by prose, and the comparison teaches nothing.

The ordering is the deliverable. Enumerating browser subsystems is easy; sequencing them so each build precedes the reading it motivates is what makes the track work.

3. How it works

SPECIFICATION (52 areas, flat)
  |
  v
TWO PARALLEL STRANDS + ONE SPINE
  |
  +-- BROWSER STRAND ------ parser -> DOM -> bindings -> CSS -> layout
  |                          -> paint -> compositor -> scheduling -> tests
  |        ^
  |        +-- SPINE: mini-browser milestones, interleaved 1:1
  |
  +-- FRAMEWORK STRAND ---- mini-redux -> mini-react I -> reactivity/signals
  |                          -> mini-react II -> compilers -> tooling
  |
  v
JOIN: cross-layer vertical traces
  v
CHROMIUM CONTRIBUTION  ->  CAPSTONE

3.1 The four structural decisions

The mini-browser is a spine, not a phase. Its 15 milestones map 1:1 onto the browser strand. Each is built immediately before the corresponding Blink reading, so the reading answers a question already felt. This is the specification's §45 loop applied at module granularity rather than track granularity.

mini-redux comes first, not last. It is the only production library in the track small enough to read end to end — the Level-1 rung of the §44 reading ladder. The capability it builds ("I have understood an entire real system") is a prerequisite for facing 30M lines of Chromium, not a reward afterwards.

Chromium C++ is just-in-time. Reading Blink structure — call graphs, ownership, layout — needs almost no C++. Reading Blink semantics needs specific idioms, learned the first time each one blocks a real read. A C++ course front-loaded before any browser reading is three weeks that teach nothing about browsers.

mini-React splits across two phases. Stages 1–7 (element model through effects) have no browser prerequisite. Stages 8–11 (batching, scheduling, interruptible work) require the real event loop, because the frame budget is the constraint that makes Fiber-shaped structures necessary rather than arbitrary.

3.2 Setup is two things, not one

Conflating these is the most common way this track stalls.

Read-only setupBuild setup
Whenday 1needed by Phase 3
Costzero~100 GB, hours, a current toolchain
Unlockssource reading, archaeology, spec mapping, tracingnative debugging, tests, WPT, contribution
ToolsCode Search, checkout + git grep/git log -S, DevTools, Perfettodepot_tools, GN, autoninja, lldb

A checkout without a working compiler still delivers most of the read-only value: git grep over the full tree is faster than Code Search, and git log -S is the archaeology tool §25 depends on. Checkout and build are separable, and separating them buys months of runway.

3.3 Module sequence

ModuleCovers spec areasPhaseBuild needed
bi-01-navigating-chromium§2, §25, §440no
bi-02-architecture-process-model§2, §18, §191no
bi-03-html-parsing§6, §71no
bi-04-dom-internals§81no
bi-05-bindings-v8§9, §171no
bi-06-chromium-cpp§42 (JIT)no
bi-07-css-engine§10, §112no
bi-08-layout§12, §133helpful
bi-09-paint§143helpful
bi-10-compositor-gpu§154helpful
bi-11-scheduling§164helpful
bi-12-debugging-tracing§20, §214yes
bi-13-tests-wpt§22, §236yes
bi-14-contribution§24, §496yes
fw-01fw-12§27–§421–5, parallelno
bi-15-vertical-traces§43joinpartly
bi-16-capstone§46–§48, §507yes

4. What "done" means

Per the specification's §44, no source-reading exercise is complete until eight questions are answered in writing: why the code exists, what invariant it maintains, who calls it, what it calls, which process/thread runs it, what happens if removed, how it is tested, and what simpler design would fail.

Per §45, no abstraction is understood until it has been used, predicted, rebuilt in miniature, broken deliberately, debugged, compared against production source, and — where practical — modified in production.

A module is complete when its docs/verification.md checkpoints pass against measured or observed output, not when its prose has been read.

5. Known constraints on this machine

  • Trunk does not compile here. macOS 15.0 / Xcode 16.2 vs trunk's floor of macOS 26.2 / Xcode 26.5 (requirement landed 2026-05-13). See bi-00-roadmap/docs/chromium-build-debug-trace.md §2.0 for the diagnosis and the ranked options. Phases 0–2 are unaffected.
  • Disk. Checkout ~26 GB plus a ~24 GB git-cache mirror; ~90 GB free. One symbol-rich output directory fits; two do not.

6. Relationship to the sibling track

Shared, not duplicated: fe-00-roadmap/docs/learning-log.md §3 is the source-reading log for both tracks; fe-00-roadmap/docs/decisions/ holds ADRs from either.

Cross-track sequencing that matters:

Their moduleThis trackDirection
fe-01 execution modelbi-11 schedulingtheirs first — the JS-observable model before the Blink implementation
their rendering pipelinebi-08bi-10theirs first, shallow; this track supplies the mechanism
their React/Fiber modulefw-02/fw-04 mini-reactthis track first — derive before reading
their state-management modulefw-01, fw-03this track first, same reason

References — bi-00-roadmap

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

Observation — bi-00-roadmap

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

G01 — Chromium: Build, Debug, Trace, Test

Operational reference. Verified against upstream 2026-08-10 — re-verify anything here older than ~6 months and update the Verification Log in PROGRESS.md.

Spec areas §3 (build), §20 (debugging), §21 (tracing), §22 (tests).

Rule for this file: it records procedures and the reasoning behind them, not a frozen command list. When a command here fails, the fix is to consult docs/ in your own checkout — which is the authoritative, versioned copy — and then update this file. In-tree docs beat any external source including this one.


1. Your specific machine

Apple M2 Pro · 12 cores · 32 GB RAM · ~146 GB free at start.

This is a comfortable build machine on CPU and RAM, and a tight one on disk. Budget:

ItemSizeWhere
src + .git (full history)~80–100 GB~/chromium/src
git cache mirror~24 GB~/Library/Caches/depot_tools/git_cachemeasured; a surprise
One out/ dir, component + symbol_level=0~15–25 GB~/chromium/src/out/
One out/ dir with usable symbols~40–80 GB~/chromium/src/out/

The git-cache trap (measured on this machine, 2026-08-10)

fetch --git-cache is the fast-checkout path upstream recommends, and it works — but it keeps a full bare mirror outside the checkout, in addition to the working checkout's own .git. Peak storage is therefore roughly double what the checkout size suggests.

Measured after fetch completed here:

~/chromium/src                              26 GB
~/Library/Caches/depot_tools/git_cache      24 GB   ← the mirror
free space                                  146 GB → 90 GB

The mirror location is set by cache_dir in ~/chromium/.gclient — read that file rather than guessing; it is not ~/.cache, and conflating the two will send you deleting the wrong thing.

What to do about it:

  • The mirror is a cache, not a dependency. Once src is synced it can be deleted; the cost is that a future fetch of a second checkout loses its speedup, and the next gclient sync re-fetches more over the network. With one checkout on a disk-constrained machine, deleting it is usually right.
  • If you keep it, budget for it from the start rather than discovering it at 90 % full mid-build.
  • Check with du -sh "$(grep cache_dir ~/chromium/.gclient | cut -d'"' -f2)".

Remaining budget here: 90 GB free, ~20 GB needed for a component build with modest symbols. Comfortable, but not comfortable enough for two symbol-rich output directories.


2. Build configuration

cd ~/chromium/src
gn gen out/Default
gn args out/Default        # opens an editor
is_debug = false                    # release-mode codegen: much faster builds, usable perf
is_component_build = true           # many small dylibs → fast incremental links. Essential.
symbol_level = 1                    # function names + line numbers, no full type info
blink_symbol_level = 2              # full symbols for Blink only; cheap, since it's a subset
dcheck_always_on = true             # ← the one most people omit, and the one you want most

# --- toolchain workaround for this machine; see §2.1. Remove once Xcode is 26+ ---
use_clang_modules = false
use_unified_system_module = false

Verified working on this checkout (gn gen → 32,319 targets, 2026-08-10).

Why each, because copying build flags without understanding them is how you end up unable to debug at the moment you need to:

  • is_component_build = true is the single largest iteration-time win. A static build relinks a ~1 GB binary on every change; a component build relinks one small shared library. Non-negotiable for a learning checkout.
  • symbol_level: 0 is fastest but stack traces become useless addresses. 1 gives you function names and line numbers — enough for almost all of this track. 2 gives full type info for variable inspection, and costs a lot of disk. blink_symbol_level = 2 is the compromise: full fidelity exactly where you'll be setting breakpoints.
  • dcheck_always_on = true keeps DCHECKs in a release build. This is the highest-value flag for learning: DCHECKs are Blink's invariants written as executable assertions (bi-01 Technique 7), and having them fire when you break something turns a confusing misrender into a precise message naming the invariant you violated. Enable it and leave it on.
  • is_debug = true is deliberately not recommended as your default. It is far slower to build and to run, and its main benefit (assertions) you already have from dcheck_always_on. Reach for it only when you need libc++ debug iterators or full unoptimised stepping.

2.0 WARNING - BLOCKER: trunk cannot compile on this machine (2026-08-10)

Status: checkout works, compilation does not. Read this before spending time on 2.1.

This machine:   macOS 15.0 (Sequoia) - Xcode 16.2 - SDK 15.2
Trunk requires: macOS 26.2+ (Tahoe)  - Xcode 26.5+ - SDK 26.5

git log dates the requirement exactly:

2026-05-13  mac: Switch to Xcode 26.5 17F42 (2026-05-11) and SDK 26.5 25F70

Trunk has required Xcode 26.5 for over a year. The failure is not configuration - it is a missing SDK symbol:

// base/process/launch_mac.cc
if (__builtin_available(macOS 26, *)) {
  DPSXCHECK(posix_spawn_file_actions_addchdir(&file_actions_, path));     // SDK 26 only
} else {
  DPSXCHECK(posix_spawn_file_actions_addchdir_np(&file_actions_, path));  // SDK 15 has this
}

__builtin_available is a runtime check, but the symbol must still exist at compile time. SDK 15.2's spawn.h declares only the _np variant. This pattern recurs across the tree, so it is not patchable in any sane way - it is a genuine toolchain-floor problem.

The gn workarounds in 2.1 get gn gen to succeed. They do not and cannot fix this.

What this does not block

The checkout is not wasted. Everything except compilation works right now:

  • git grep -n over 30M lines - faster than Code Search, and it sees generated inputs
  • git log -S'...' archaeology (section 25) - the most valuable local capability
  • reading docs/, DEPS, OWNERS, .json5, .idl, .mojom in-tree
  • everything in bi-01, bi-02, bi-03, bi-04, bi-07 and Labs 01-02

Phases 0-2 require no build at all. A local build is first genuinely needed at Phase 3-4 (section 20, native debugging) and again at Phase 6 (sections 22/23 tests, 24 contribution). On the phase plan that is roughly two months of runway.

Your options, ranked

OptionCostConsequence
AUpgrade macOS to 26.2+, then Xcode to 26.5+multi-hour OS upgrade + ~15 GB Xcode; reboot; some risk to existing toolchains (homebrew, anaconda, rust)Recommended. Trunk builds; local source matches Code Search; contribution path stays open.
BCheck out a revision from before 2026-05-13 and gclient syncanother long sync; ~15 months of source driftBuilds today, but local source no longer matches Code Search or these modules - actively confusing while learning, and it forecloses section 24 contribution.
CDefer the build; work from the checkout + stock ChromenoneZero risk, no loss before Phase 3.

Recommendation: C now, A before Phase 3. There is no reason to take an OS upgrade during Phase 0, and no reason to arrive at Phase 3 without one. Option B is a genuine fallback only if you decide against upgrading at all - the staleness cost is real and it ends the contribution track, which is the stated target of section 24.

Do not delete out/Default or the checkout in the meantime; both are reusable the moment the toolchain is current.

2.1 Case study: trunk required a toolchain this machine didn't have

This happened on the first gn gen here, and it is worth reading in full because the diagnostic path is the transferable part — the specific flags will be obsolete within a year.

Symptom. gn gen failed with three ERROR Input to targets not generated by a dependency errors naming files that do not exist:

//out/Default/sdk/xcode_links/MacOSX15.2.sdk/usr/include/DarwinFoundation1.modulemap
                                                          DarwinFoundation2.modulemap
                                                          DarwinFoundation3.modulemap

Diagnosis, step by step.

  1. The missing files are in the SDK, not in Chromium. So this is a toolchain-version problem, not a checkout problem. ls "$(xcrun --show-sdk-path)/usr/include" | grep modulemap showed DarwinFoundation.modulemap but no numbered variants — they exist only in newer SDKs.
  2. xcodebuild -version → Xcode 16.2, SDK 15.2. grep mac_sdk_official_version build/config/mac/mac_sdk.gni26.5. Chromium trunk had moved to the macOS 26 SDK.
  3. Who wants those files? grep -rn DarwinFoundation1 build/ buildtools/buildtools/third_party/libc++/modules.gni, inside if (use_clang_modules).
  4. Why is that path taken at all? build/config/c++/modules.gni: use_autogenerated_modules = !(is_apple && use_system_xcode) — on macOS with a system Xcode this is false, which selects the manual modulemap path that hardcodes the numbered files. The autogenerated path asserts xcode_version_int >= 2600 anyway, so both branches require Xcode 26.
  5. Is there an escape? grep -n "use_clang_modules =" build/config/c++/c++.gni showed it inside a declare_args() block → it is a settable gn arg, not a computed constant. That single observation is the whole fix.

Fix. use_clang_modules = false reduced the failure from four toolchain variants to one remaining target (//build/modules:system_modulemap), which build/modules/BUILD.gn gates on a second arg, use_unified_system_module. Setting both to false generated cleanly.

Cost of the workaround. Clang header modules are a compile-time optimisation for libc++ headers. Disabling them means somewhat slower compiles and no -fmodules-strict-decluse include hygiene checking. Nothing about Blink's behaviour changes, so it is a sound trade for a learning checkout. Revisit it if you upgrade Xcode.

The transferable lessons, which matter more than the flags:

  • A gn gen error naming a nonexistent SDK file is a toolchain-version problem. Check mac_sdk_official_version against xcrun --show-sdk-version before anything else.
  • Chromium trunk tracks the newest toolchain aggressively. Being one Xcode major behind is enough to break the build. Expect this again.
  • Before concluding "I must upgrade," check whether the offending behaviour sits behind a declare_args() value. grep -n "<name> =" build/config/**/*.gni and look for the enclosing declare_args() block. A surprising amount of Chromium's build is switchable.
  • When it breaks again after a gclient sync, re-run exactly this procedure. It will; a workaround pinned to a toolchain gap has a shelf life.

If you do upgrade Xcode later, delete both workaround lines and re-run gn gen — keeping dead workarounds is how build configs become unexplainable.

Verified target names (2026-08-10)

//content/shell:content_shell
//:blink_tests
//third_party/blink/renderer/controller:blink_unittests

Do not trust this list — including here. Regenerate it with gn ls out/Default | grep -E ':(content_shell|blink_tests)$'.

Inspect what you actually got:

gn args out/Default --list --short          # every arg and its current value
gn args out/Default --list=symbol_level     # docs for one arg, from the build files

Building

autoninja -C out/Default chrome           # the full browser
autoninja -C out/Default content_shell    # minimal embedder — prefer this
autoninja -C out/Default blink_tests       # content_shell + web test infrastructure

autoninja selects the correct underlying executor (ninja or siso) and the right parallelism. Use it rather than invoking ninja directly; the wrapper is where upstream encodes build-system migrations.

Prefer content_shell over chrome for everything in this track. It is a minimal embedder of //content — no bookmarks, no sync, no extensions, no UI. It builds far faster, starts far faster, and it is what the web tests run against. If your question is about Blink, chrome is 90 % irrelevant code.

The build system is searchable, and almost nobody learns this. These are the gn equivalents of bi-01's Code Search techniques:

gn ls out/Default                                  # every target
gn ls out/Default | grep -i blink                  # find the real target names — do this
                                                   # instead of trusting any doc's list
gn refs out/Default third_party/blink/renderer/core/html/parser/html_tree_builder.cc
                                                   # which targets contain this file?
gn desc out/Default //third_party/blink/renderer/core:core deps
gn path out/Default //chrome //third_party/blink/renderer/core:core
                                                   # why does A depend on B?

gn refs <file> answers "what do I have to rebuild to test this change," and gn path answers "why is this even linked in," which is a genuine architecture question.

When the build breaks after a sync

In order, cheapest first:

gclient sync -D            # sync deps, delete stale ones
gn clean out/Default       # clear generated files, keep the args
rm -rf out/Default && gn gen out/Default   # nuclear; costs a full rebuild

Most post-sync failures are stale generated files, so gn clean resolves them. Reach for the third option rarely — on this machine it is an hour you did not need to spend.


3. Running

out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell https://example.com
out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell --run-web-tests <test path>
out/Default/Chromium.app/Contents/MacOS/Chromium --user-data-dir=/tmp/cr-profile

Always pass --user-data-dir to a scratch directory when running a local build, so you never touch your real profile.

Flags that matter for this track:

FlagUse
--user-data-dir=<path>isolate profile — always
--renderer-startup-dialogpause each renderer at startup so you can attach
--disable-hang-monitorstop Chrome killing a renderer you're stopped in
--enable-blink-features=Footurn on a runtime-enabled feature by name
--disable-blink-features=Foo...and off, to A/B a behaviour
--enable-logging=stderr --v=1see LOG()/VLOG() output
--single-processone process — convenient, frequently broken, never trust it for behaviour
--no-sandboxlast resort for debugging; changes the security model, so never conclude anything about behaviour from a --no-sandbox run

The --enable-blink-features / --disable-blink-features pair is the fastest way to answer "is this behaviour behind a flag" — a question bi-01 Technique 4 says you should ask early and often.


4. Debugging with lldb

Setup

Chromium ships lldb helpers in-tree. Wire them in once:

# see docs/lldbinit.md in your checkout for the current recommended contents
echo "command script import ~/chromium/src/tools/lldb/lldbinit.py" >> ~/.lldbinit

Without this, WTF::String, std::u16string and friends print as raw pointers and you will waste time. Check docs/lldbinit.md in your own checkout for the current form — this is exactly the kind of instruction that drifts.

Attaching to the right process

This is the part that trips everyone. Chromium is multiprocess; a breakpoint in Blink must be set in a renderer, not the browser process you launched.

# 1. Launch with renderers paused at startup
out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell \
  --renderer-startup-dialog --disable-hang-monitor <url>

It prints something like:

Renderer (80156) paused waiting for debugger to attach. Send SIGUSR1 to unpause.
# 2. Attach, set breakpoints, then release it
lldb -p 80156
(lldb) breakpoint set --name blink::HTMLConstructionSite::FosterParent
(lldb) process handle SIGUSR1 -n true -p true -s false   # don't stop on the unpause signal
(lldb) continue
# in another terminal:
kill -USR1 80156

Alternatively, find the renderer by inspecting Chrome's own task manager, or by --wait-for-debugger style flags for other process types (--utility-startup-dialog, and the equivalent GPU flag).

--disable-hang-monitor matters: without it, sitting at a breakpoint for 30 seconds gets your renderer killed for being unresponsive, and you lose the state you were inspecting.

Useful lldb, for this track specifically

(lldb) breakpoint set -n blink::Document::UpdateStyleAndLayout
(lldb) breakpoint set -f html_tree_builder.cc -l 812
(lldb) breakpoint set -n blink::Element::SetAttribute -c 'name == "class"'   # conditional
(lldb) breakpoint command add 1
> bt 12
> continue
> DONE
(lldb) thread backtrace all       # every thread — reveals the thread boundaries directly
(lldb) frame variable
(lldb) expression -- node->DebugName()

thread backtrace all is underrated here: it is the fastest way to see the renderer's thread structure — main, compositor, raster, IO — which is otherwise an abstract claim from bi-02. Do it once early and read the thread names.

Getting a breakpoint to hit at all

The three reasons a Blink breakpoint doesn't hit, in order of frequency:

  1. You attached to the browser process, not a renderer.
  2. The function was inlined. Set the breakpoint on the caller, or build that file with less optimisation.
  3. The code path is behind a runtime-enabled feature that is off.

5. Tracing

Tracing is rung 3 of the bi-01 ladder — above search, below the debugger — and it is the right first tool when you have a behaviour and no hypothesis. It also works on stock Chrome with no build at all, which is why Phase 0 can start tracing before the build finishes.

Three levels, increasing power:

  1. DevTools → Performance. Curated view, JS-centric, good for main-thread work.
  2. Perfetto UI (ui.perfetto.dev), or chrome://tracing in older builds. All categories, all processes, all threads. This is where you see the compositor thread, the GPU process, and cross-process flow arrows.
  3. Command-line tracing — startup tracing flags for capturing things that happen before you can click record.

What to actually do with it in this track:

  • Record with the blink, cc, gpu, viz, toplevel and devtools.timeline categories and identify, by name, the events for: style recalculation, layout, pre-paint, paint, commit, activation, raster, and frame presentation. Those names are the vocabulary the rest of the browser modules use.
  • Follow a single frame across processes using flow arrows: renderer main → compositor → GPU → presented. This makes §15's "the app does not own the whole budget" concrete rather than rhetorical.
  • Correlate a trace event name back to source: trace events are declared in the code with TRACE_EVENT macros, so the event name is a greppable string. This is the single best bridge between rung 3 and rung 1 — see something in a trace, grep its name, land in the implementation.

That last bullet is the technique that makes tracing a navigation tool rather than only a performance tool. TRACE_EVENT0("blink", "...") names are searchable identifiers into the exact code that ran.


6. Tests

autoninja -C out/Default blink_tests
strip ./out/Default/Content\ Shell.app/Contents/MacOS/Content\ Shell   # macOS: recommended upstream

third_party/blink/tools/run_web_tests.py -t Default
third_party/blink/tools/run_web_tests.py -t Default fast/forms
third_party/blink/tools/run_web_tests.py -t Default fast/fo\*

Direct, without the harness (you diff by hand):

out/Default/content_shell --run-web-tests fast/forms/001.html

Known failures are declared, not deleted:

third_party/blink/web_tests/TestExpectations

Read TestExpectations early — it is a map of Chromium's known interop gaps, and therefore a map of tractable first contributions (§24 rung 3). An entry there is a documented, accepted, currently-wrong behaviour with a bug attached.

C++ tests:

gn ls out/Default | grep unittests        # find the current target names; don't guess
autoninja -C out/Default <target>
out/Default/<target> --gtest_filter='HTMLTreeBuilder*'

Remember the Blink/non-Blink naming split from bi-01 Technique 6: foo_test.cc in Blink, foo_unittest.cc elsewhere.


7. Iteration-time discipline

Ranked by effect on this specific machine:

  1. is_component_build = true — do not build without it.
  2. Build the smallest target that answers your question: content_shell over chrome; a single unit-test target over blink_tests.
  3. Use gn refs to learn which targets a file belongs to, and build only those.
  4. symbol_level = 1 + blink_symbol_level = 2 rather than symbol_level = 2 everywhere.
  5. ccache, if you frequently switch branches. Little benefit for linear work.
  6. Do not run gclient sync more often than you need. Every sync is a partial rebuild.

The general principle: a Chromium question answered by a 10-minute rebuild was usually answerable by a 30-second search or a 2-minute trace. The ladder in bi-01 exists to protect build time, which is your scarcest resource.


8. Verification checklist

Update PROGRESS.md §8 whenever you confirm or refute one of these.

  • args.gn values still valid (gn args out/Default --list)
  • blink_tests, content_shell target names still current (gn ls)
  • run_web_tests.py path still current
  • TestExpectations path still current
  • docs/lldbinit.md contents still match what you put in ~/.lldbinit
  • --renderer-startup-dialog still the documented attach mechanism
  • Perfetto vs chrome://tracing — which does upstream currently document?

bi-01 — Navigating Chromium Independently

Phase 0 · Instrumentation · Spec areas §2 (architecture map), §25 (source archaeology), §44 (reading ladder). Prerequisite for every other B-module.

Cross-track hook: none. This module is deliberately self-contained and requires no local build.


Why a Principal Engineer needs this

Chromium is roughly 30 million lines across ~500,000 files. Nobody has read it. The engineers who are effective in it are not the ones who have memorised more of it — they are the ones whose search converges. That is a learnable, mechanical skill, and it is the difference between "I could probably find that in an afternoon" and "I don't know where to start."

Three reasons this is the first module rather than a footnote:

1. Memorised paths rot; navigation does not. In the last few years Blink renamed every ng_* layout file, moved the issue tracker, and rewrote the parser's fast path. Anyone whose knowledge was a list of paths is now wrong. Anyone whose knowledge was "spec term → grep → xref" is unaffected. The tracker's Verification Log exists to keep score on exactly this.

2. The expensive failure mode is searching in the wrong subsystem, confidently. Most wasted Chromium time is not slow reading. It is two hours in //content for something that lives in //third_party/blink/renderer/core, because the engineer never asked which process the behaviour belongs to. §3 below is the antidote.

3. Your value is arbitration, not recall. "Is this a Blink bug, a compositor bug, or our bug?" is the question you will actually be asked. Answering it requires locating evidence quickly in a codebase you do not own.

The rule for this entire track: you are never given a path. You are given a technique, and you record the query that worked. The queries transfer. The paths don't.


Mental Model

The tool ladder

Five tools, in strictly increasing cost. Never reach for rung N+1 before rung N is exhausted — the most common time sink in Chromium work is debugging something that a 30-second search would have answered.

RungToolCostAnswers
1Code Search (source.chromium.org)secondsWhere is it? Who calls it? When did it change?
2Local grep on a checkoutsecondsSame, plus generated files, plus git log -S
3Tracing (DevTools → Perfetto)minutesDoes this code even run? On which thread? How often?
4Local build + logging~minutes/iterationWhat are the actual values?
5Debugger (lldb)slow, high valueExact call stack, exact state, exact ordering

Note that tracing is rung 3, above search but below the debugger. This is deliberate and it is the ordering most people get wrong. Tracing answers whether and where far faster than a breakpoint does, and it works on a stock Chrome with no build at all. When you have a behaviour and no hypothesis, trace first — a breakpoint requires you to already know where to put it.

The four questions that start every navigation

Before searching, answer these. Guessing them costs you minutes; skipping them costs you hours.

  1. Which process? Browser, renderer, GPU, network service, or utility.
  2. Which thread? Main, compositor, raster, IO, worker.
  3. Is it web-exposed? If yes, there is an .idl file and a spec, and both are entry points.
  4. Is it spec-defined? If yes, spec-phrase search will land you within one or two hits. If no, you need the naming grammar instead (§ Technique 3).

Question 1 is answerable from the directory path alone, which is the single highest-value fact in this module.

The path is the architecture

Chromium's directory layout is not organisational convenience — it is enforced. DEPS files declare which directories may include which, and presubmit rejects violations. So the path tells you the layer, and the layer tells you the process.

PathProcessNotes
//content/browser/Browserprivileged; trusts nothing from renderers
//content/renderer/Rendererthe content-layer side of the renderer
//content/common/bothIPC/shared definitions only
//content/public/boththe embedder-facing API surface
//third_party/blink/renderer/Rendererthe engine implementation
//third_party/blink/public/boundarywhat //content is allowed to see of Blink
//cc/Renderer (+ viz)compositor; runs on main and impl threads
//gpu/, //components/viz/GPUcommand buffer, display compositor
//services/network/Network serviceits own process
//net/network servicethe stack itself
//v8/rendererseparate project, separate repo, separate bug tracker
//base/allthreading, callbacks, containers
//mojo/allthe IPC system itself

The public/ convention is the one to internalise. //content/public and //third_party/blink/public exist so that layers below cannot reach into layers above. When you find yourself wondering "how does the browser process tell Blink to do X," the answer is nearly always: it doesn't directly — it goes through a Mojo interface declared in a .mojom file, and the Blink side lives behind public/. Searching //content for Blink internals is the single most common wasted hour.

Blink's own split, from its README.md:

  • platform/ — no DOM. Graphics primitives, fonts, geometry, WTF containers, threading. Depends on nothing above it.
  • core/ — the Web Platform features that everything else needs: DOM, HTML, CSS, style, layout, paint, events, editing.
  • modules/ — self-contained web-exposed features that core does not need in order to render a page: WebAudio, IndexedDB, WebRTC, Bluetooth, and so on.
  • bindings/ — the V8 boundary. Mostly generated.
  • controller/ — the code that drives the whole thing.

The dependency direction is platform → core → modules, one way, enforced. So:

If a feature can be removed without breaking basic page rendering, look in modules/ first. If removing it would break rendering a plain HTML page, it is in core/.

This one heuristic resolves maybe a third of "which directory" questions immediately. Blink also ships SpecMapping.md at renderer/ root, which maps specifications to directories — read it once; it is the officially maintained version of the guess you would otherwise be making.


Under the Hood: eight techniques

Technique 1 — Spec-phrase search (highest yield)

Blink's spec-implementing code quotes the specification in comments, often step by step. This makes the spec a search index over the implementation.

Take the most distinctive multi-word term of art from the algorithm — not a common word — and search it as a quoted phrase.

"appropriate place for inserting a node"
"reconstruct the active formatting elements"
"in-flow"    ← too common, will not converge

Convergence check: if the top hits are in the subsystem you predicted, your architectural model is correct. If they are somewhere else entirely, stop and fix the model before reading any code — you have just been handed a free correction.

Works extremely well for: HTML parsing, DOM, events, CSS cascade, fetch, URL. Works poorly for: compositor, GPU, scheduling — these are implementation inventions with no spec text (see Technique 3).

Technique 2 — Symbol, then cross-reference

symbol:ShouldFosterParent          declaration, not the 200 mentions
class:HTMLConstructionSite
function:FindFosterSite

Then use the cross-references panel. It gives you callers and callees mechanically.

Gate questions 3 ("who calls it") and 4 ("what does it call") are never to be answered by guessing or by reading nearby code. They are xref lookups. If you find yourself inferring callers from context, you are doing archaeology on a tool that would have told you the answer.

The xref panel also reveals call-site count, which is diagnostic on its own. A function with one caller is an extracted helper — read it with its caller. A function with 200 callers is an invariant, and changing it is a cross-component change (§24 rung 5).

Technique 3 — The naming grammar

For non-spec code, names are the index. Chromium's conventions are consistent enough to search by shape:

Suffix / prefixMeans
FooImplthe concrete implementation of interface Foo, often a Mojo interface
FooClientcallback interface into the layer above (inverted dependency)
FooObservermulti-subscriber notification
FooDelegatesingle-subscriber policy hook, usually embedder-provided
FooBaseshared base for several implementations
FooTraitscompile-time policy/customisation
FooBuilderstaged construction of an immutable object
ScopedFooRAII — does something in the ctor, undoes it in the dtor
FooHandle, FooTokenopaque identity, safe to pass across processes
blink::Foo vs FooBlink type vs content/browser type — often both exist

That last row matters more than it looks. There are frequently two types with the same name on either side of the Blink/content boundary (e.g. a WebFoo in blink/public and a Foo in blink/renderer). Landing on the wrong one and concluding the code "does nothing" is a classic beginner hour.

Also search the inverted direction: Client and Delegate suffixes tell you where a dependency was deliberately inverted to keep the layering legal. Those are the seams of the architecture.

Technique 4 — Generated code: the #1 reason grep fails

If you grep for something that obviously must exist and find nothing, it is generated.

Chromium generates enormous amounts of C++ at build time from declarative inputs:

InputGenerates
*.idl (Web IDL)V8 bindings — the JS-visible surface of every Web API
*.mojomIPC interfaces, both ends, for every language
css_properties.json5property IDs, parsing, computed-style storage, longhand expansion
css_value_keywords.json5every CSS keyword identifier
html_tag_names.json5, html_attribute_names.json5tag/attribute atoms
runtime_enabled_features.json5RuntimeEnabledFeatures::FooEnabled() for every flag
computed_style_extra_fields.json5 + friendsComputedStyle field storage

Consequences, in order of how often they bite:

  1. A CSS property's parsing/storage is declared, not written. Looking for where contain is handled? Start at css_properties.json5, not at a .cc file. The entry there tells you the property's inheritance, initial value, whether it is animatable, and which custom parsing function (if any) it uses.
  2. A Web API's entry point is its .idl. document.querySelector is declared in an .idl; the generated binding calls a Blink method whose name is derived by rule (querySelectorQuerySelector). Searching for "querySelector" in .cc files finds usage; searching .idl finds the definition.
  3. RuntimeEnabledFeatures::XEnabled() means the behaviour is conditional. When observed behaviour contradicts the source, check for a feature flag before concluding you misread. This is also how you find code that is written but not yet shipping.
  4. On a local checkout, generated files live under out/<dir>/gen/. They are real, readable C++ and reading them once — especially a generated binding — is worth an hour.

Technique 5 — History as documentation

The commit message on the CL that introduced a line is usually better documentation than any comment, because Chromium requires CLs to explain why, link a bug, and survive review.

  • In Code Search: use blame on the line, then open the CL.
  • Locally: git log -S'<exact string>' --oneline -- <path> finds the commit that introduced or removed that string. This is the single most useful git invocation in this track.
  • git log --follow survives renames — necessary in a tree that renamed every ng_* file.

The question "why does this weird special case exist" is nearly always answered by a bug link in a CL description. §46's notebook is largely fed by this technique.

Technique 6 — Tests as executable specification

Tests answer "what is this supposed to do" more reliably than the implementation, which only tells you what it currently does.

KindLocation shapeRuns
Unit testfoo_test.cc next to foo.cc (Blink), foo_unittest.cc (rest of Chromium)in-process, fast
Browser test*_browsertest.ccreal multi-process browser
Web testthird_party/blink/web_tests/content_shell, HTML+expectations
WPTthird_party/blink/web_tests/external/wpt/cross-browser contract

The Blink-vs-rest naming split (_test.cc vs _unittest.cc) is a real gotcha: searching only one form silently halves your results. Search both.

Reading order that works: find the test before you finish reading the implementation. The test names enumerate the edge cases the implementation exists to handle, which turns an opaque function into a checklist.

Technique 7 — DCHECK is the invariant, written down

DCHECK(x) is an assertion compiled out of release builds. Chromium uses it heavily, and it is the most honest documentation in the codebase: it states what the author believed must always be true.

When answering gate question 2 ("what invariant does it maintain?"), read the DCHECKs first. They are frequently a more precise answer than any prose you would write.

Related: CHECK is retained in release (a security or memory-safety invariant — treat it as load-bearing), and NOTREACHED() marks a state the author believed impossible. NOTREACHED() in a switch over an enum is a strong hint that the enum is exhaustive by contract.

Technique 8 — OWNERS and DEPS are the architecture diagram

  • OWNERS tells you who is accountable for a directory — and by proxy, where a subsystem boundary is. Two adjacent directories with disjoint OWNERS are two different teams and usually two different design philosophies.
  • DEPS declares legal include directions. Reading a DEPS file tells you the intended layering faster than reading any code, and an include_rules entry with a comment explaining an exception is a documented architectural compromise.

These are also the files you must consult before proposing a change (§24), so the reading you do now is not throwaway.


Deep dive: the generated-code surface, catalogued

Technique 4 said "if you grep for something that must exist and find nothing, it is generated." Here is the actual inventory, so you can recognise which declarative file to open. Blink alone contains 57 .json5 files (measured 2026-08-10).

The ones you will actually use

FileGeneratesOpen it when you want to know
core/css/css_properties.json5property IDs, parsing, computed-style storage, longhand expansionanything about a CSS property
core/css/css_value_keywords.json5every CSS keyword identifierwhy a keyword is/isn't recognised
core/css/computed_style_field_aliases.json5ComputedStyle field storagehow a property is stored
platform/runtime_enabled_features.json5RuntimeEnabledFeatures::XEnabled()whether a behaviour is flag-gated
core/html/html_tag_names.json5-style name tablestag/attribute AtomicString atomswhy tag comparison is a pointer compare
core/events/event_type_names.json5event type atomsthe canonical list of event names
core/events/event_target_names.json5event target nameswhat can be an event target
core/svg/svg_tag_names.json5, mathml_tag_names.json5foreign-content element tablesparser foreign-content behaviour
platform/fonts/font_family_names.json5generic family atomsfont fallback plumbing
core/probe/core_probes.json5DevTools instrumentation probeshow DevTools observes Blink

That last one is worth a detour on its own: DevTools instrumentation is generated from a declarative probe list. When you wonder "how does DevTools know a style recalc happened," the answer is a probe declared in core_probes.json5 and injected into Blink at generated call sites.

Anatomy of a css_properties.json5 entry

This is the single highest-value declarative file in Blink. A real entry:

{
  name: "contain",
  property_methods: ["ParseSingleValue", "CSSValueFromComputedStyleInternal"],
  field_group: "*",
  field_size: 5,
  field_template: "primitive",
  default_value: "kContainsNone",
  name_for_methods: "Contain",
  type_name: "unsigned",
  converter: "ConvertFlags<Containment>",
  keywords: ["none", "strict", "content", "size", "layout", "style", "paint", "inline-size"],
  typedom_types: ["Keyword"],
  invalidate: ["layout"],
  is_animation_affecting: true,
}

Read it field by field and notice how much you just learned without opening a .cc file: the legal keywords, that it is stored in 5 bits, its initial value, that it participates in animation, that custom parsing exists — and, crucially, that changing it invalidates layout.

invalidate: is a declarative, machine-readable statement of which pipeline stage a property change dirties. It turns "does this property cause layout?" from folklore into a grep.

bi-07 develops this properly. For navigation purposes the point is: a declarative file can answer a question you were about to answer by reading algorithms. Always check whether the subsystem has one.

Anatomy of a runtime_enabled_features.json5 entry

{ name: "ViewTransitionAsyncFinished", status: "stable" },
{ name: "ViewTransitionLongCallbackTimeoutForTesting", status: "test" },

The status vocabulary is exactly three values — valid_values: ["stable", "experimental", "test"] — and status may be per-platform, keyed by ["Android", "Win", "ChromeOS", "Mac", "Linux", "iOS", "Fuchsia"]. Omitting "default" in a per-platform dictionary means the feature is off on unlisted platforms.

Entries can also declare implied_by and depends_on, forming a small dependency graph between features.

What to do with this:

  • status: "stable" — shipping. Behaviour you read in the code is behaviour you get.
  • status: "experimental" — behind --enable-experimental-web-platform-features.
  • status: "test" — test-only; never assume it reflects product behaviour.
  • Per-platform status is why "it works in Chrome" is ambiguous. Desktop and Android can genuinely differ, declaratively, in this file.

When observed behaviour contradicts the source, check here before concluding you misread. This is also how you find code that is written but not yet shipping — a good source of context when evaluating whether to depend on a platform feature.


Deep dive: reading the build graph as architecture

BUILD.gn files are not just build plumbing; they encode component boundaries.

gn refs out/Default path/to/file.cc      # which targets contain this file
gn desc out/Default //some:target deps   # direct dependencies
gn path out/Default //a:b //c:d          # WHY does a depend on d
gn ls  out/Default | grep -i blink       # real target names, never guessed

gn path is the interesting one. It answers "why is this even linked in," which is an architecture question disguised as a build question. If gn path shows a surprising edge, you have found either a layering violation or an abstraction you did not know existed.

Also read the visibility and public_deps declarations in a BUILD.gn. A target with narrow visibility is telling you "this is internal to a component" more precisely than any comment.


Deep dive: git archaeology recipes

With a local checkout these are your highest-leverage commands. They work even when the tree does not compile.

# Which commit introduced or removed this exact string?
git log -S'CausesFosterParenting' --oneline -- third_party/blink/renderer/core/html/parser/

# Same, but regex, and show the patch
git log -G'FosterParent\(' -p --  <path>

# Who last touched each line, and in which CL?
git blame -L 100,140 -- <file>

# Follow a file across renames (essential in a tree that renamed every ng_* file)
git log --follow --oneline -- <file>

# What changed in a directory in the last 90 days?
git log --since=90.days --oneline -- third_party/blink/renderer/core/css/ | head -40

# Find the commit that changed a build requirement
git log -1 -S'mac_sdk_official_version = "26.5"' -- build/config/mac/mac_sdk.gni

That last recipe is not hypothetical — it is exactly how this track dated Chromium's Xcode 26.5 requirement to 2026-05-13, which turned "the build is broken" into "the toolchain floor moved on this date." A build failure with a date attached is a decision; without one it is a mystery.

Reading a CL description properly

Chromium requires CLs to explain why, link a bug, and survive review. So a commit message is usually better documentation than any comment. When you find the CL that introduced a puzzling special case, look for:

  • Bug: footer — the issue often contains the reproduction and the user impact.
  • Design-doc links — larger changes reference one.
  • Fixed: vs Bug: — whether this closed the issue or merely touched it.
  • The reviewers — OWNERS of the affected directories, i.e. the people who understand it.

Deep dive: reading a subsystem cold, in order

A repeatable procedure for a subsystem you have never opened. Roughly 45 minutes.

  1. README.md in the directory, if it exists. Blink's better subsystems have unusually good ones (core/css/, core/layout/, core/paint/, platform/scheduler/, platform/heap/).
  2. DEPS — what may this depend on? That is the layering, stated.
  3. OWNERS — who is accountable; adjacent directories with disjoint owners are different teams.
  4. BUILD.gn — what is the component, what is public, what is internal.
  5. The .json5 / .idl / .mojom inputs, if any — the declarative surface.
  6. The test files*_test.cc names enumerate the edge cases the code exists to handle. Read these before the implementation; they turn an opaque function into a checklist.
  7. Only now the implementation, entered from a specific question, never from the top.

Step 6 is the one people skip and the one that pays most. A test file is an enumeration of everything the author was afraid of.


Deep dive: how far down does the question go?

A practical stopping rule, because Chromium reading does not terminate on its own.

Before descending another layer, ask: would knowing this change what I do? Keep a written deferred list of names you chose not to follow. At the end, that list is your map of the subsystem — and reviewing it usually shows that two or three names recur, which are the ones actually worth learning.

The failure mode this prevents: depth-first reading that ends four hours later in //base/containers, having learned nothing about the original question. That is not diligence, it is a lack of a stopping rule.


Anti-Patterns

Reading top-down from main(). Chromium has no meaningful single entry point for feature work, and startup code is unrepresentative of everything else. Start from a behaviour, not from the root.

Searching for a concept instead of a token. "layout" returns tens of thousands of hits. symbol:UpdateLayout returns something you can read. Convert the concept to an identifier or a spec phrase before searching.

Concluding "this code is dead" too early. Between generated code, feature flags, platform #ifdefs, and Mojo indirection, apparent deadness is usually one of those four. Check runtime_enabled_features.json5 and search the .mojom before believing it.

Reading the implementation before the spec, for spec-defined behaviour. You will mistake a compatibility workaround for the design. Case B in the parsing lab is exactly this trap.

Answering "who calls this" by reading. Use the xref panel. Always.

Trusting an LLM's Chromium paths — including mine. Chromium moves faster than any model's training data. Every path in this repository has a date on it in the Verification Log. Re-check anything older than about six months; that is a habit, not a one-off.


Trade-offs

Code Search vs local grep. Code Search has cross-references, blame, and no setup; local grep has generated files, git log -S, and works offline at full speed. Use Code Search for structure and history, local grep for exhaustiveness. git grep -n on a checkout is dramatically faster than most people expect — it is the right tool for "find all 400 call sites," which Code Search paginates badly.

Depth-first vs breadth-first reading. Depth-first (follow every callee) does not terminate in Chromium. The discipline is: read one layer, write down the names of the things you deferred, and only descend into the one that actually matters for your question. The deferred list is itself a deliverable — it is your map of the subsystem.

Precision vs recall in search. Start precise (symbol:, quoted phrase). If you get zero hits, that is information — usually "generated" or "wrong subsystem" — not a reason to immediately broaden to a soup of common words.


Lab — the navigation drills, Navigation drills

See bi-01-navigating-chromium/docs/execution.md. Eight timed drills. The deliverable is the query log, not the answers.


Debugging Exercise

Without a local build, using only Code Search and a stock Chrome:

  1. Determine whether element.scrollIntoView({behavior: 'smooth'}) animation is driven by the main thread or the compositor. State your evidence. (Hint: rung 3 of the ladder beats rung 1 here — and noticing that is the actual lesson.)
  2. Find one behaviour in Chromium currently behind a runtime-enabled feature flag that is not yet enabled by default. Name the flag and the file that declares it.
  3. Pick any CSS property added to the web platform in the last three years. Find its declaration in css_properties.json5, and from that entry alone state: is it inherited? is it animatable? does it have custom parsing?

Testing & QA Considerations

Locating tests is a navigation skill, not a testing skill, and it is graded here:

  • For a given .cc file, find its unit test. Note the naming convention that applies.
  • For a given web-exposed behaviour, find both the Blink web test and the WPT.
  • Determine whether a WPT is currently expected to fail in Chromium, and where that expectation is recorded. (Blink records known failures declaratively rather than by deleting tests — finding that mechanism is the exercise.)

The last one matters disproportionately: a WPT that Chromium fails on purpose is a documented interop gap, and those are the richest source of tractable first contributions (§24 rung 3).


Further Reading (primary sources first)

Authoritative, in-tree — read these in the tree, not in a blog:

  • third_party/blink/renderer/README.md — the core/modules/platform split, threading, type conventions. The single best orientation document in Blink.
  • third_party/blink/renderer/SpecMapping.md — specification → directory.
  • docs/ at the repo root — build, contribution, debugging, testing.
  • third_party/blink/renderer/core/css/README.md, style-calculation.md, style-invalidation.md — unusually good subsystem docs; you will use these in bi-07.
  • Any DEPS and OWNERS file in a directory you are studying.

External

  • Chromium Code Search: source.chromium.org/chromium/chromium/src
  • Google Code Search operator reference (for symbol:, class:, function:, case:, content:, comment:, usage:, pcre:yes, - negation, AND/OR).
  • Life of a Pixel (Chromium team talk, refreshed roughly annually) — the standard orientation to the rendering pipeline. Watch it once now, again after bi-09.
  • chromium.googlesource.com/chromium/src/+/main/docs/README.md — index of in-tree docs.

Deliberately not on this list: blog posts explaining Blink internals. The half-life is under two years and the failure mode — a confidently stated stale path — is exactly what this module exists to inoculate against.


Principal Engineer Review

Answer in writing. Several have more than one defensible answer; "it depends" is acceptable only if you say on what.

  1. A bug report says "the page renders correctly but screenshots taken by our automation are blank." Name the three processes that could be responsible and the single cheapest piece of evidence that discriminates between them.

  2. You grep the entire tree for a CSS property name and get only test files. Give four distinct explanations, ranked by likelihood, and the check that eliminates each.

  3. An engineer on your team says "I read the Blink source and it clearly does X, but the browser does Y." Enumerate the ways both statements can be simultaneously true. Which do you check first, and why that one?

  4. You need to know whether a given DOM operation runs on the compositor thread. Compare answering this by (a) reading source, (b) tracing, (c) a debugger breakpoint. Rank by time-to-answer and by confidence in the answer, and explain why the rankings differ.

  5. Why does //third_party/blink/public exist at all, given that everything in it is compiled into the same binary as //third_party/blink/renderer? What class of bug does deleting the boundary reintroduce, and what would you expect to break first?

  6. Argue that Chromium's heavy use of generated code from .json5/.idl/.mojom is good architecture. Then argue it is a navigability tax paid by every newcomer. What would you actually change, if anything, and who bears the cost of that change?

  7. You are given 30 minutes to determine whether a rendering bug is in Blink or in the GPU process, on a machine with no Chromium checkout. Write the procedure.

  8. DCHECK is compiled out of release builds. Make the case that this is the right decision, then the case that it hides real bugs from real users. What does Chromium's choice of CHECK vs DCHECK for a given invariant tell you about how the author classified the failure?

  9. A teammate proposes documenting your team's "map of Chromium" as a wiki page of paths. You think this is a trap. Make the argument, and propose what to write instead that still transfers knowledge to a new hire.

  10. You find a function with 200 call sites that you believe has an off-by-one error. Before writing any code, what do you need to establish, and in what order? What is the strongest evidence that you have misread it — and how do you go looking for that evidence rather than for confirmation?

References — bi-01-navigating-chromium

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

Authoritative, in-tree — read these in the tree, not in a blog:

  • third_party/blink/renderer/README.md — the core/modules/platform split, threading, type conventions. The single best orientation document in Blink.
  • third_party/blink/renderer/SpecMapping.md — specification → directory.
  • docs/ at the repo root — build, contribution, debugging, testing.
  • third_party/blink/renderer/core/css/README.md, style-calculation.md, style-invalidation.md — unusually good subsystem docs; you will use these in bi-07.
  • Any DEPS and OWNERS file in a directory you are studying.

External

  • Chromium Code Search: source.chromium.org/chromium/chromium/src
  • Google Code Search operator reference (for symbol:, class:, function:, case:, content:, comment:, usage:, pcre:yes, - negation, AND/OR).
  • Life of a Pixel (Chromium team talk, refreshed roughly annually) — the standard orientation to the rendering pipeline. Watch it once now, again after bi-09.
  • chromium.googlesource.com/chromium/src/+/main/docs/README.md — index of in-tree docs.

Deliberately not on this list: blog posts explaining Blink internals. The half-life is under two years and the failure mode — a confidently stated stale path — is exactly what this module exists to inoculate against.



When a claim here proves stale, record it in PROGRESS.md section 7.

bi-01 — Analysis

What this module is actually training

Not knowledge of Chromium. Convergence of search. The measurable output is: given a browser behaviour you have never investigated, how many queries until you are reading the right code, and do you notice when you are in the wrong subsystem?

Required invariants of a good navigation

  1. The prediction is made before the search. Naming the expected subsystem first is what turns a search into a calibration measurement. A search without a prediction teaches nothing about your model.
  2. Zero hits is information, not failure. It means one of: generated code, wrong subsystem, wrong spelling of a term of art, or the feature is flag-gated. Broadening to a soup of common words discards that information.
  3. Callers and callees come from cross-references, never from reading. Gate questions 3 and 4 have a mechanical answer; inferring them from nearby code is how wrong call graphs get written down and repeated.
  4. The query is recorded, not the path. Paths rot — this repository has two dated instances (ng_ prefixes, TraceWrapperMember). Queries transfer across subsystems and across years.

Failure modes

FailureSymptomCorrection
Concept instead of token"layout" returns tens of thousands of hitsconvert to symbol: or a spec phrase
Wrong layertwo hours in //content for Blink internalsanswer "which process?" before searching
Assuming dead code"this is never called"check generated code, .mojom, feature flags
Depth-first descentfour hours in //base/containerskeep a deferred list; stop when the answer stops changing
Trusting a remembered pathconfidently wrongcheck the date in the verification log

What would falsify the approach

If spec-phrase search failed on spec-defined subsystems, Technique 1 would be worthless. It does not, and the reason is structural: Blink quotes specification step text in comments, which makes the specification a search index over the implementation. That property is why the parsing lab is tractable on day one with no build.

The technique degrades precisely where there is no spec — compositor, scheduler, GPU. There the naming grammar (Technique 3) and the trace-event bridge (bi-12) take over. Knowing which technique applies to which subsystem is the actual skill.

Evidence this module produces

  • a query log with failures included
  • a prediction-accuracy count out of 8
  • one candidate failing WPT recorded for bi-14
  • the three techniques that worked best for you, which differ by person more than people expect

Execution — Chromium Navigation Drills

Module bi-01. Phase 0. No local build required. Tools: Code Search + stock Chrome.

The deliverable is the query log, not the answers. Every drill is scored on the search you ran, how many attempts it took to converge, and what you learned when it didn't. Record everything in docs/observation.md, including failed queries — the failures are the data.

Time-box each drill to 10 minutes. Blowing the box is a result, not a failure: write down where your model was wrong and move on. Total ~90 minutes.


Scoring

For each drill record:

Field
Predicted subsystem/process before searching
Query 1hits: ? converged: y/n
Query 2..n
Time to converge
Was the prediction right?
Technique that worked (bi-01 §1–8)

A drill where you predicted the wrong subsystem and found out in 90 seconds is a better outcome than one you got right by luck. The point is calibrating the prediction.


Drill 1 — Spec-phrase search (Technique 1)

Find Blink's implementation of the CSS cascade's "specificity" comparison.

Then: find where the spec's phrase for the algorithm that orders declarations of equal specificity is quoted in a comment. Which file? What does the comment say the tie-break is?

Check your model: did you predict core/css/? If you predicted core/style/, note why — the split between those two is a recurring confusion and bi-07 depends on it.

Drill 2 — Symbol + cross-reference (Technique 2)

Find Document::UpdateStyleAndLayout (or whatever the current name is — finding out that a name has changed is part of the drill).

Answer from the xref panel only, without reading bodies:

  • How many callers does it have, roughly?
  • Name three callers from different subsystems.
  • What does the caller count tell you about changing its signature?

Drill 3 — Naming grammar (Technique 3)

Without using the word "compositor" in your query, find the class in //cc that owns the compositor's main-thread state, and its impl-thread counterpart. Use the naming grammar.

Then: find one *Client and one *Delegate in Blink and state, for each, which direction the dependency was inverted and why that was necessary.

Drill 4 — Generated code (Technique 4)

document.querySelector — find:

  1. the .idl declaration
  2. the Blink C++ method the binding calls
  3. the name-mangling rule that connects them

Then pick a CSS property added in the last three years and, from css_properties.json5 alone, state whether it is inherited, whether it is animatable, and whether it uses custom parsing.

Finally: search for RuntimeEnabledFeatures:: and find one feature that is declared but not enabled by default. Where is "enabled by default" recorded?

Drill 5 — History (Technique 5)

Take the <input type=hidden> special case you found in the parsing lab. Find the CL or spec change that justifies it. Quote the reasoning.

Then find any line in the parser directory that has survived unchanged for more than ten years, and one that changed in the last six months. What does the contrast tell you about where the risk is in this subsystem?

Drill 6 — Tests (Technique 6)

For HTMLConstructionSite:

  • find its unit test
  • find one Blink web test covering foster parenting
  • find the WPT covering the same behaviour
  • find where Chromium records that a WPT is expected to fail, and find one currently- failing WPT in the HTML parsing area

That last item is a candidate for §24 rung 3. Write down the test name; you may come back to it in Phase 6.

Drill 7 — Invariants (Technique 7)

Find three DCHECKs in core/dom/ and, for each, write the invariant in one English sentence. Then find one CHECK (not DCHECK) in the renderer and explain why the author classified that failure as unrecoverable rather than merely a bug.

Drill 8 — Layering (Technique 8)

Read third_party/blink/renderer/DEPS and one core/*/DEPS.

  • Name one include rule you would not have predicted.
  • Find an include_rules exception with a comment justifying it. What compromise is documented there?
  • From OWNERS files alone, identify a boundary between two teams inside Blink.

Cross-check drill — the ladder (bi-01 §"tool ladder")

Determine whether smooth scrollIntoView is main-thread or compositor-driven.

Do it twice: once by source reading only, once by tracing only (DevTools Performance or chrome://tracing). Record time-to-answer and your confidence for each. Then answer:

Why is tracing rung 3 and source reading rung 1, if tracing gave the faster answer here?

Getting this reconciliation right is the whole point of the ladder.


Done when

  • All 8 drills logged with queries, including failures
  • Prediction accuracy tallied (how many subsystems did you predict correctly?)
  • Cross-check drill answered, including the reconciliation question
  • One candidate failing-WPT recorded for Phase 6
  • The three techniques that worked best for you noted in PROGRESS.md
  • Source readings logged in fe-00-roadmap/docs/learning-log.md §3

Observation — Navigation Drills (query log)

The queries are the deliverable. Log failures.

Scorecard

DrillPredicted subsystemCorrect?Queries to convergeTimeTechnique that worked
1 specificity
2 xref
3 naming
4 generated
5 history
6 tests
7 invariants
8 layering

Prediction accuracy: __ / 8


Drill 1 — specificity

Queries tried:

Answer: Where my model was wrong:

Drill 2 — xref

Queries tried:

Caller count: Callers from different subsystems: What the caller count implies about changing the signature:

Drill 3 — naming grammar

Queries tried:

cc main-thread owner: impl-thread counterpart: *Client found: inverted dependency direction & why: *Delegate found: inverted dependency direction & why:

Drill 4 — generated code

.idl declaration: Blink method: Name-mangling rule: CSS property chosen: inherited? animatable? custom parsing? Feature flag not enabled by default: where "enabled by default" is recorded:

Drill 5 — history

CL/spec justification for <input type=hidden>: Oldest surviving line found: Most recently changed line found: Where the risk lives in this subsystem:

Drill 6 — tests

Unit test: Web test: WPT: Where expected-failures are recorded: Candidate failing WPT for Phase 6:

Drill 7 — invariants

DCHECKInvariant in one sentence

CHECK found: why unrecoverable rather than a bug:

Drill 8 — layering

Unexpected include rule: Documented exception + the compromise: Team boundary inferred from OWNERS:


Cross-check — the ladder

Source-only: time __ confidence __ answer __ Tracing-only: time __ confidence __ answer __

Why is tracing rung 3 if it was faster here?

Verification — bi-01-navigating-chromium

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • All 8 drills logged with queries, including failures
  • Prediction accuracy tallied (subsystems predicted correctly / 8)
  • Cross-check drill answered, incl. the tool-ladder reconciliation question
  • One candidate failing-WPT recorded for Phase 6
  • The three techniques that worked best for you noted in PROGRESS.md

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-01 — Broader Ideas

Where this generalises

The techniques here are not Chromium-specific. They are how you enter any codebase too large to read.

TechniqueGeneralisation
Spec-phrase searchany implementation of a written standard: TCP stacks, PDF renderers, compilers, SQL engines
Path ⇒ layer ⇒ processany layered system with enforced dependency direction
Generated-code detectionProtobuf, GraphQL codegen, ORM models, OpenAPI clients — the same "grep finds nothing" failure
git log -Sthe fastest way to date any behaviour in any repository
Tests as enumerated fearsuniversal

The habit worth exporting to your team

Record the query, not the path. A team wiki full of file paths is stale within two release cycles and actively misleads. A wiki that says "to find how X is enforced, search for the spec phrase Y, then follow xrefs from symbol Z" stays true across refactors.

This is a concrete thing to change in your organisation's onboarding docs on Monday.

Next steps in this track

  • bi-02 gives you the process/thread map that makes "which layer?" answerable rather than guessed.
  • bi-12 adds tracing as rung 3 — the instrument that tells you where to look when you have no hypothesis at all.
  • bi-14 is where navigation becomes falsifiable: you cannot land a CL in code you cannot find.

An exercise beyond the module

Take a codebase you own. Write the six-step cold-read procedure for it: what is the README, where is the layering declared, what is generated, where are the tests, what are the naming conventions. If you cannot write it, new hires are reverse-engineering it individually — which is a cost you are paying without measuring.

bi-02 — Chromium Architecture & the Process Model

Phase 1 · Foundations · Spec areas §2 (architecture map), §18 (multiprocess), §19 (security architecture). Prerequisite: bi-01.

Cross-track hook: frontend-principal-engineering.md §2 (browser architecture), §13 (security). This module supplies the enforcement mechanism behind claims that track makes about origins and isolation.


Why a Principal Engineer needs this

1. It is the layer at which "whose bug is it" is decided. A rendering artifact could be Blink, the compositor, or the GPU process. A hanging page could be the renderer main thread, a blocked Mojo call, or the network service. You cannot triage across those without a process model, and triage across team boundaries is your job, not your team's.

2. Every web security guarantee you rely on is a process boundary or a check inside one. "Same-origin policy protects us" is not a statement about JavaScript. It is a statement about which process holds which data and which checks run in the privileged one. Engineers who learned SOP as a JS-level rule consistently mis-reason about postMessage, about SharedArrayBuffer, and about what an XSS actually gets an attacker.

3. The performance model is a process/thread model. "Move it off the main thread" is meaningless until you can say which main thread, and what the target thread is allowed to touch. Compositor-driven scroll, off-main-thread raster, and the reason getBoundingClientRect is expensive are all consequences of this diagram.

4. It bounds what is even implementable. When a product asks for a Web API that needs privileged OS access, the design question is which part goes in Blink, which crosses Mojo, and what the browser process must independently verify. That is §18's design exercise, and it is a real interview and real design-review question at this level.


Mental Model

The processes

┌──────────────────────────────────────────────────────────────┐
│ BROWSER PROCESS                    privileged · trusts nobody │
│  UI, tabs, navigation, permissions, profile/disk, process     │
│  allocation, the security decisions that actually matter      │
└──────┬──────────────┬───────────────┬──────────────┬─────────┘
       │ Mojo         │ Mojo          │ Mojo         │ Mojo
┌──────▼──────┐ ┌─────▼───────┐ ┌─────▼──────┐ ┌─────▼────────┐
│ RENDERER A  │ │ RENDERER B  │ │    GPU     │ │   NETWORK    │
│ sandboxed   │ │ sandboxed   │ │  sandboxed │ │   SERVICE    │
│ site a.com  │ │ site b.com  │ │            │ │              │
│  ├ Blink    │ │             │ │ raster,    │ │ sockets,     │
│  ├ V8       │ │             │ │ GL/Vulkan, │ │ HTTP cache,  │
│  └ cc(main) │ │             │ │ display    │ │ cookies*     │
└─────────────┘ └─────────────┘ └────────────┘ └──────────────┘
                                          (* the authoritative cookie
                                             store is browser-side)

The invariant that generates the whole design:

A renderer process is assumed to be compromised. Every decision it reports to the browser process is treated as attacker-controlled input.

Read that again, because almost every "why is this so complicated" question in Chromium bottoms out there. If a renderer could be trusted, half of Mojo's validation, all of site isolation, and most of the browser process would be unnecessary.

Who owns what

ConcernProcessWhy there
DOM, CSS, layout, paint, JSRendereruntrusted content; must be sandboxed
Compositing (main-thread half)Rendererneeds layout output
Compositing (impl half), rasterRenderer + GPUmust survive a busy main thread
GL/Vulkan command executionGPUdrivers are fragile and a large attack surface
Sockets, TLS, HTTP cacheNetwork serviceshared across renderers; must not be per-origin-controlled
Cookie authorityBrowsera compromised renderer must not read another site's cookies
Permissions, navigation, URL barBrowserthe user-trust surface
Disk / profileBrowsersandboxed processes have no file access

The pattern: anything a compromised renderer must not be able to do lives outside the renderer. That single rule predicts the location of most subsystems, and it is a far better navigation heuristic than memorising the list.

Threads inside a renderer

The process diagram is the half people know. This is the half that explains performance.

RENDERER PROCESS
├── Main thread ("renderer main")
│     Blink: parsing, DOM, style, layout, paint, JS (V8), rAF, most Web APIs
│     ← everything your app code perceives as "the browser"
├── Compositor thread ("impl")
│     input handling for scroll, layer property trees, tiling decisions,
│     produces frames from already-painted content
├── Raster / worker pool
│     turns display items into pixels; may hand work to the GPU process
├── IO thread
│     Mojo message send/receive. Never do work here.
└── Web Worker / Worklet threads
      separate V8 isolates, no DOM

Two consequences that matter constantly:

  • Compositor-driven scroll works while the main thread is blocked. That is not an optimisation detail; it is the reason a janky page still scrolls. It also explains why scroll becomes janky only when you add a non-passive wheel/touchstart listener — you have forced the compositor to consult a thread that is busy.
  • requestAnimationFrame is main thread. So "use rAF for smoothness" does nothing for a main thread that is already saturated. This is the mechanical basis for the sibling track's M01 conclusions.

Sequences, not threads

Chromium's task abstraction is a sequence: an ordered series of tasks that never run concurrently with each other, but which need not be pinned to one physical thread. In-tree guidance is explicit that you should prefer sequences to physical threads.

Why this matters to a reader of the code: when you see scoped_refptr<base::SequencedTaskRunner> you should read it as "this work is ordered relative to itself, but you may not assume which thread." SequenceChecker in a class is the executable statement of that contract, and — like DCHECK — it is documentation you can trust because it is enforced.

Blink adds its own rule on top: cross-thread communication is message passing, not shared memory. renderer/README.md states that mutexes and atomics are "strongly discouraged," citing use-after-free security bugs and stability problems as the historical reason, and notes that existing shared-memory patterns in the tree should not be cargo-culted. This is a rare case where the codebase tells you directly that some of its own code is not exemplary — worth remembering when you find a mutex and assume it is the house style.

Mojo, in one paragraph

Mojo is Chromium's IPC system. You declare an interface in a .mojom file; the build generates both ends in C++ (and other languages). A message pipe carries messages between them; endpoints can be passed through pipes, which is how capabilities are delegated. The important property is not the syntax — it is that the receiving side must validate everything, because on the browser side the sender may be a compromised renderer. When you read a browser-process Mojo implementation and it looks paranoid, that is the design working.

Site isolation, precisely

Each site (scheme + eTLD+1 — not origin, and not URL) gets its own renderer process, including cross-site iframes, which are rendered out-of-process and composited together.

Two follow-on facts that are constantly misremembered:

  • Site ≠ origin. https://a.example.com and https://b.example.com are the same site. Origin-keyed isolation is available but is not the default.
  • Site isolation is a defence against a compromised renderer, not only against script. Same-origin policy already stops script from reading cross-origin data. Site isolation is what stops a renderer whose sandbox has been escaped-into (or a Spectre-style side channel) from having that data in its address space at all. Spectre is precisely why "the data was never in that process" became a necessary guarantee, rather than "the checks stop it."

That last distinction is the single most common gap in otherwise-strong frontend engineers' security models, and it is where COOP/COEP and SharedArrayBuffer gating come from.


Under the Hood: a navigation, end to end

Trace this once and the diagram stops being abstract. Roughly:

  1. Browser process receives the navigation intent (omnibox, link click forwarded from a renderer, redirect). The renderer asks; the browser decides — a renderer cannot navigate itself anywhere it likes.
  2. Browser consults the process model: which site is this, is there an existing suitable process, must a new one be created? A "spare" renderer often exists precisely to hide process-startup latency.
  3. The request goes to the network service, not the renderer. The renderer never holds the socket.
  4. Response headers come back. Security decisions — CSP, COOP/COEP, X-Frame-Options, MIME sniffing, whether this response may even be delivered to this renderer (cross-origin read blocking) — are made where they cannot be lied about.
  5. The body is streamed to the chosen renderer, which begins parsing (bi-03) on the main thread while the preload scanner speculatively requests subresources.
  6. Blink builds DOM → style → layout → paint (bi-04–bi-09), producing display items and property trees rather than pixels.
  7. The compositor turns that into layers/tiles; raster produces pixels, potentially in the GPU process.
  8. The GPU process composites the final frame and presents it at vsync.

Every arrow between numbered steps is a Mojo boundary or a thread hop, and each is a place where you can observe the system with tracing. That is the practical payoff: this list is a checklist for reading a trace.

Where to look, without being told

Apply bi-01's path-to-process table. //content/browser/ for step 1–2 and 4, //services/network/ for 3, //third_party/blink/renderer/core/ for 5–6, //cc/ for 7, //gpu/ + //components/viz/ for 8. In-tree docs worth reading in your own checkout: docs/process_model_and_site_isolation.md, docs/mojo_and_services.md, docs/threading_and_tasks.md, and docs/security/compromised-renderers.md — the last is the clearest statement anywhere of what the browser assumes an attacker already controls.


Deep dive: the process-model vocabulary

Chromium's process-allocation logic is not ad hoc. It is built on four named abstractions, and almost every confusing process-model question dissolves once you can name which one is in play. These are quoted from docs/process_model_and_site_isolation.md in the tree.

ConceptClassWhat it answers
Security principalSiteInfoWhich data is this execution context allowed to access?
Principal instanceSiteInstanceWhich specific process should this document use?
Browsing context groupBrowsingInstanceWhich documents can find each other by name?
Process lockProcessLockWhich sites is this process permitted to host?

Why the principal is a site, not an origin

This is the fact everyone gets wrong. The principal is scheme + eTLD+1 because document.domain lets same-site documents reach into each other synchronously. If a.example.com and b.example.com could be in different processes while still able to script each other, you would have cross-process synchronous DOM access — which is not a performance problem, it is a data race on two heaps.

So the rule is not "origins are the security boundary, and Chromium is sloppy." The rule is:

Any two documents with the same principal in the same browsing context group MUST live in the same process, because they have synchronous access to each other's content — cross-document scripting and shared memory (SharedArrayBuffer).

Put the other way round: process separation is only available where synchronous access is already impossible. That is why origin-keyed isolation requires opting out of document.domain (via Origin-Agent-Cluster), and why crossOriginIsolated (COOP+COEP) is a precondition for SharedArrayBuffer — you must prove nothing cross-origin shares your agent cluster before you are handed shared memory.

The agent cluster correspondence

SiteInstance corresponds roughly to the HTML spec's agent cluster, and the in-tree docs are careful to say the match is not exact:

  • multiple agent clusters may share one principal instance (e.g. data: URLs stay with their creator),
  • principals track factors the spec's agent-cluster key does not (e.g. StoragePartition).

This gap is worth internalising as a general lesson. Implementations track more state than specs, because specs define observable behaviour while implementations also carry policy, storage partitioning, and product requirements. When implementation and spec vocabulary diverge, it is usually because the implementation is deciding something the spec left to the UA.

BrowsingInstance: why popups are special

A browsing context group is the set of tabs and frames that hold references to each other — frames in a page, popups with window.opener, named targets. Any two documents in the group can find each other by name, so same-principal documents in the group must share a process.

Consequences you can observe:

  • window.open() without noopener keeps you in the same browsing context group, which constrains process allocation for the popup.
  • rel="noopener" is therefore not merely a tidiness measure — it lets the browser put the new page in a different browsing context group, which unlocks a different process and severs the synchronous relationship.
  • A tab can change browsing context group on navigation: a Cross-Origin-Opener-Policy header, or a browser-initiated cross-site navigation, both do this. That is exactly what COOP is for.

If you have ever wondered why rel="noopener" shows up in security and performance advice, this is why: it is the same mechanism seen from two sides.

ProcessLock: the enforcement, not the intention

A ProcessLock is attached to a RenderProcessHost (the browser-side object representing a renderer) and restricts which sites may load there and which data the process may access. Locks have varying granularity:

  • a single site (https://example.com),
  • a single origin (https://accounts.example.com),
  • an entire scheme (file://),
  • or a special "allow-any-site" value for processes permitted to host multiple sites.

Processes start unlocked and acquire a lock once content is loaded. chrome:// URLs are never allowed to share a process with other sites, on every platform, regardless of isolation mode.

The distinction that matters: SiteInstance expresses intent ("this document belongs to this principal instance"); ProcessLock is the enforcement point the browser consults when a renderer asks for data. A compromised renderer can lie about who it is — the lock is what makes the lie useless.


Deep dive: Mojo, precisely

From docs/mojo_and_services.md:

  • A message pipe is a pair of endpoints. Writing at one endpoint enqueues on the peer. Pipes are bidirectional.
  • A mojom file describes interfaces: strongly-typed collections of messages (roughly analogous to protobuf messages).
  • Given an interface and a pipe, one endpoint is designated a Remote (sends) and the other a Receiver (receives). Replies flow back from Receiver to Remote.
  • A Receiver must be bound to an implementation. A received message is dispatched as a scheduled task invoking the corresponding method.

That last point is the one with real consequences and it is easy to skim past:

Mojo message dispatch is a task, not a call. It is posted to a sequence and runs when that sequence gets to it.

So a Mojo "call" is asynchronous by default, ordering is guaranteed per pipe, and two messages on two different pipes have no ordering relationship at all. A large class of subtle Chromium bugs is exactly this: code that assumed two IPCs would arrive in the order they were sent, when they travelled on different pipes. (Associated interfaces exist precisely to share a pipe and recover ordering — that is what they are for.)

Capability passing

Endpoints can be sent through pipes. That is how privilege is delegated: instead of the renderer asking "may I do X?" and the browser answering, the browser hands the renderer an endpoint that is the ability to do X, scoped to what it was created for. This is a capability model, and it is why you should read a browser-side Mojo implementation as "what am I willing to hand out, and scoped to what?" rather than "what checks do I run?"


Deep dive: what the browser must never trust

docs/security/compromised-renderers.md is the single most useful security document in the tree because it is organised as an inventory of things a compromised renderer must not be able to obtain. Its section list is effectively a threat model:

Site Isolation foundations · Cross-Origin HTTP resources · Contents of cross-site frames
Cookies · Passwords · Security-sensitive UI (Omnibox) · Permissions · Web storage
Messaging · JavaScript code cache · Cross-Origin-Resource-Policy · frame-ancestors CSP
and X-Frame-Options · HTTP request headers · SameSite cookies · User gestures/activations
Web Accessible Resources of extensions · Non-Web resources · Android-specific gaps
Renderer processes hosting the DevTools frontend

Two entries deserve special attention because they surprise application engineers:

frame-ancestors CSP and X-Frame-Options are enforced in the browser process. They must be, because the renderer being framed is the one that would otherwise decide, and it may be compromised. This is why those headers are meaningfully stronger than a JavaScript framebusting check — the check is on the other side of a trust boundary.

The JavaScript code cache is in the list. A shared compilation cache is a cross-origin information channel if you are not careful; it is partitioned for that reason. Caches are side channels by default, and every partitioned cache in the browser (HTTP cache, code cache, connection pools) is there because someone demonstrated the leak.

Cross-origin data: the read-blocking layer

Same-origin policy classically stopped a page from reading a cross-origin response. Site isolation raises the bar: the response should never reach the renderer's address space at all, because a compromised renderer can read its own memory regardless of what checks it is supposed to run. That is the job of cross-origin read blocking (and its successor work): decide, in the network service or browser, whether this response may even be delivered to this process.

This is the concrete architectural consequence of Spectre. Before speculative-execution attacks, "the renderer has the bytes but the checks stop it" was an acceptable design. After, it was not — because a side channel can read what the checks forbid. The move from check to never deliver is one of the largest architecture changes in the browser's history, and it is why site isolation shipped despite its memory cost.


Deep dive: the Rule of Two

docs/security/rule-of-2.md states a constraint worth carrying into your own systems. Code must not do all three of the following at once:

  1. process untrustworthy inputs,
  2. in an unsafe language (C/C++),
  3. without a sandbox.

Pick at most two. This single rule explains a startling amount of Chromium's architecture:

  • parsers for untrusted formats (images, fonts, audio) get moved into sandboxed utility processes — dropping requirement 3;
  • new code is increasingly written in Rust or in memory-safe wrappers — dropping requirement 2;
  • data that can be validated to a trusted schema before reaching unsafe code — dropping 1.

When you see a whole process that seems to exist for one small job, the Rule of Two is usually the reason.


Numbers worth carrying

These are order-of-magnitude anchors, not benchmarks — verify on your own hardware before quoting.

QuantityRough scaleWhy it matters
Renderer process baseline memorytens of MB before your pagewhy process-per-site has a real cost
Process startupmilliseconds, not microsecondswhy a spare renderer is kept warm
Mojo messagea task hop, not a function callwhy chatty IPC designs fail
Frame budget at 60 Hz16.7 ms total across all stagesyour script is one term
Frame budget at 120 Hz8.3 msraster costs do not halve

The spare-renderer trick is worth knowing about specifically: the browser keeps an unused renderer warm so that a navigation does not pay process startup on the critical path. It is a latency optimisation that only makes sense once you know process creation is expensive — and it is a good example of the browser spending memory to buy responsiveness, the same trade you will be asked to make in application architecture.


Anti-Patterns

"The renderer" as a single thing. Renderer ≠ Blink ≠ main thread. Most confused performance conversations collapse these three.

Assuming SOP is enforced in JavaScript. It is enforced by a combination of Blink checks and the browser process refusing to deliver data. If your model has only the first, you cannot explain cross-origin read blocking or why Spectre changed the architecture.

Reasoning about cookies from the renderer's perspective. document.cookie is a restricted view. The authority is browser-side, which is why HttpOnly is meaningful at all.

Treating --single-process results as evidence. It collapses the boundaries this module is about, is not maintained to production quality, and will happily "prove" things that are false in the shipping configuration.

Believing a diagram (including this one) over a trace. Process and thread assignments change between releases and platforms — Android differs from desktop in process limits and reuse. thread backtrace all in lldb, or a Perfetto trace, tells you what is true today on your machine.


Trade-offs

Process-per-site costs memory. Site isolation measurably increases RAM, which is why it shipped on desktop before mobile and why Android uses process-reuse heuristics. If you ever argue that a browser "should just isolate everything," you are arguing for a product that loses on low-end devices — which is most devices.

IPC costs latency and complexity. Every Mojo hop is serialisation, a thread hop, and validation code that must be written and reviewed. The alternative — trusting the renderer — is what the pre-2008 single-process browsers did, and its failure mode was "one bad page takes down the browser, and one exploit takes the machine."

Out-of-process iframes complicate everything. Input routing, focus, hit testing, compositing, and the accessibility tree all become cross-process problems. This is the clearest example in the codebase of a security requirement imposing large architectural cost, and it is the best §46 notebook entry in this module.


Lab — the process/thread lab, process and thread archaeology

See bi-02-architecture-process-model/docs/execution.md.


Debugging Exercise

On your local content_shell build:

  1. Launch it, then enumerate the actual processes and their types on your machine. Which process types appear for a trivial page? Which appear only after you load something that paints, or something cross-origin?
  2. Attach lldb to a renderer and run thread backtrace all. Identify main, compositor, raster, and IO threads by name. Write the names down — you will grep for them in traces for the rest of the track.
  3. Load a page with a cross-site iframe. Confirm from process inspection that a second renderer exists. Then confirm the same-site iframe case does not create one.
  4. Block the main thread with a long synchronous loop, and scroll. Explain what still works and why, in terms of threads rather than in terms of "the compositor is fast."

Testing & QA Considerations

  • Find a *_browsertest.cc that exercises a cross-process behaviour. What can it assert that a unit test structurally cannot?
  • Find a Mojo interface with validation code in its browser-side implementation. Find the test that feeds it invalid input. That test is the compromised-renderer model expressed as code — the most concrete form of the invariant in this module.
  • Determine how Chromium tests site isolation itself, and what a regression there would look like in CI.

Further Reading (primary sources first)

In-tree, read in your own checkout

  • docs/process_model_and_site_isolation.md
  • docs/security/compromised-renderers.md — read this one twice
  • docs/mojo_and_services.md, docs/mojo_ipc_conversion.md
  • docs/threading_and_tasks.md (+ _faq.md) — sequences vs threads
  • third_party/blink/renderer/README.md §"Threading model", §"Dependencies"
  • content/README.md and the DEPS files around //content/public

External

  • Life of a Pixel — the standard rendering-pipeline orientation.
  • The Chromium security team's site-isolation writeups (and the Spectre-era rationale).
  • W3C/WHATWG: HTML origin and site definitions — confirm for yourself that "site" is scheme + eTLD+1, because everyone misquotes it.

Principal Engineer Review

  1. A page hangs: the tab is unresponsive but the browser UI is fine, and the spinner keeps spinning. Enumerate the possible locations of the hang across processes and threads, and give the single cheapest discriminating observation for each.

  2. Your company wants to embed third-party widgets. Compare <iframe>, Shadow DOM, and a same-origin script include, purely in terms of process and trust boundaries. What does each actually protect against, and what does each definitely not?

  3. Explain to a senior engineer why an XSS on a.example.com is not automatically a total compromise of b.example.com, given both are the same site. Then explain the case where your reassurance is wrong.

  4. Site isolation costs significant memory. Construct the strongest argument for shipping a browser configuration without it, then rebut it. What device class makes this a genuine product decision rather than a security-vs-laziness one?

  5. A Web API needs privileged OS access (say, a hardware sensor). Specify the split: what lives in Blink, what crosses Mojo, what the browser process must independently verify, and what it must not trust from the renderer. Name a concrete way a naive split leaks authority.

  6. Chromium prefers sequences to physical threads, and Blink prefers message passing to shared memory. Both trade performance for safety. Under what conditions is that trade wrong, and how would you recognise you are in one of those conditions?

  7. A teammate proposes moving an expensive computation "off the main thread" using a Web Worker. What must be true about the data for this to be a win? Give a realistic case where it is a loss, and quantify what dominates.

  8. Out-of-process iframes made input routing, focus, hit testing, and accessibility substantially harder. Argue that this complexity is essential; then argue it is the price of a security model chosen for other reasons. Which do you actually believe, and what evidence would change your mind?

  9. You are told a rendering bug reproduces only with GPU acceleration enabled. Describe how you would narrow it to Blink, the compositor, or the GPU process — and say which of your steps changes the security model and therefore cannot be used to draw final conclusions.

  10. The browser process is a single point of failure and a single point of trust. Design a hypothetical Chromium in which it is decomposed further. What breaks, what improves, and why do you think the actual architecture has not gone that way?

References — bi-02-architecture-process-model

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

In-tree, read in your own checkout

  • docs/process_model_and_site_isolation.md
  • docs/security/compromised-renderers.md — read this one twice
  • docs/mojo_and_services.md, docs/mojo_ipc_conversion.md
  • docs/threading_and_tasks.md (+ _faq.md) — sequences vs threads
  • third_party/blink/renderer/README.md §"Threading model", §"Dependencies"
  • content/README.md and the DEPS files around //content/public

External

  • Life of a Pixel — the standard rendering-pipeline orientation.
  • The Chromium security team's site-isolation writeups (and the Spectre-era rationale).
  • W3C/WHATWG: HTML origin and site definitions — confirm for yourself that "site" is scheme + eTLD+1, because everyone misquotes it.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-02 — Analysis

The generating invariant

A renderer process is assumed to be compromised.

Nearly every structural decision in Chromium is downstream of this one sentence. When a design looks paranoid, check it against this before concluding it is over-engineered.

Required invariants

  1. Same principal + same browsing context group ⇒ same process. Not a performance heuristic: such documents have synchronous access to each other (document.domain, SharedArrayBuffer), and splitting them would produce data races across two heaps.
  2. The browser process validates everything a renderer says. A renderer can lie about its origin; ProcessLock is what makes the lie useless.
  3. Ordering is guaranteed per Mojo pipe and not across pipes. Code assuming cross-interface ordering is assuming something no layer promises.
  4. Privileged capability is delegated as an endpoint, not granted by a check. The browser hands out a scoped ability rather than answering "may I?" repeatedly.
  5. Rule of Two: untrustworthy input + unsafe language + no sandbox — at most two.

Failure modes this architecture is designed against

ThreatDefenceWhat it costs
Renderer exploit reads another site's datasite isolation; data never enters the processmemory, ~1 process per site
Speculative-execution side channelsame — "never delivered" beats "checked"the entire CORB/ORB layer
Compromised renderer lies about identityProcessLock enforcementvalidation code in every browser-side handler
Malicious framingframe-ancestors/XFO enforced browser-sidecannot be done in the framed renderer
Driver bug or GPU exploitseparate GPU processan extra process, an extra hop

The trade nobody can escape

Process-per-site costs real memory, which is why it shipped on desktop first and why Android uses reuse heuristics. An argument for "isolate everything, always" is an argument for a browser that loses on low-end devices — which is most devices. The interesting question is never "is isolation good" but "what is the memory budget on the worst device we support."

What would falsify the model

If a same-site cross-origin pair could be safely split across processes, the principal could be an origin and the model would simplify enormously. It cannot, because of document.domain and shared memory — which is exactly why Origin-Agent-Cluster exists: opt out of the synchronous access, and origin-keyed isolation becomes available. The escape hatch proves the rule.

Execution — bi-02-architecture-process-model

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


Lab — the process/thread lab, process and thread archaeology

See bi-02-architecture-process-model/docs/execution.md.


Debugging Exercise

On your local content_shell build:

  1. Launch it, then enumerate the actual processes and their types on your machine. Which process types appear for a trivial page? Which appear only after you load something that paints, or something cross-origin?
  2. Attach lldb to a renderer and run thread backtrace all. Identify main, compositor, raster, and IO threads by name. Write the names down — you will grep for them in traces for the rest of the track.
  3. Load a page with a cross-site iframe. Confirm from process inspection that a second renderer exists. Then confirm the same-site iframe case does not create one.
  4. Block the main thread with a long synchronous loop, and scroll. Explain what still works and why, in terms of threads rather than in terms of "the compositor is fast."

Observation — bi-02-architecture-process-model

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-02-architecture-process-model

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Process census for a trivial page vs a painting, cross-origin page
  • thread backtrace all run; main/compositor/raster/IO thread names written down
  • Cross-site iframe creates a second renderer; same-site does not — verified
  • Blocked main thread: explained what still works, in terms of threads
  • Found a browser-side Mojo validation test and named the invariant it encodes

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-02 — Broader Ideas

The same problem in your systems

"Assume the client is compromised" is the browser's version of a rule every backend engineer knows and many frontend engineers do not apply consistently:

BrowserYour system
Renderer is untrustedthe browser client is untrusted
ProcessLock enforces identityserver-side authorisation, not client-side checks
frame-ancestors enforced browser-sideauthorisation decided server-side, never by the caller
Capability handed as a Mojo endpointscoped tokens rather than repeated permission checks
Rule of Twountrusted input parsed in a memory-safe language or a sandbox

Capability-passing is the transferable design. Instead of "may I do X?" answered repeatedly, hand out a scoped, revocable ability. That is what a signed URL, a scoped OAuth token, and a Mojo endpoint all are.

Where the cost model shows up in product decisions

Site isolation's memory cost is why it shipped on desktop before mobile. The equivalent decision in your product: what is the resource budget on the worst device you support, and what security or correctness property are you trading against it? Teams that cannot answer that ship features that work in the office and fail in the field.

Ordering hazards, generalised

Mojo's "ordering per pipe, not across pipes" is the same hazard as:

  • two HTTP requests to different endpoints,
  • two Kafka topics,
  • two WebSocket channels,
  • two postMessage targets.

Associated interfaces are the general fix: share a channel when you need ordering. If you find yourself adding sequence numbers to recover ordering across channels, ask whether one channel was the right design.

Next

bi-10 shows the process boundaries as observable flow arrows in a trace; bi-11 shows how work is prioritised inside one. fw-04's tearing problem is the same consistency question one layer up.

bi-03 — HTML Parsing

Phase 1 · Spec areas §6 (parser internals), §7 (mini parser). Prerequisites: bi-01, bi-02. the parsing lab is the entry point and should be done before reading this module.

Cross-track hook: frontend-principal-engineering.md §3 (HTML platform). This module explains why the platform's error recovery is not "the browser being forgiving."


Why a Principal Engineer needs this

1. HTML parsing is the only major web algorithm that is fully specified down to error recovery, and that fact is load-bearing. Before HTML5, every browser guessed differently at malformed markup and the guesses were the compatibility problem. The spec's insistence on defining what happens for invalid input — not merely valid input — is the single largest interop win in the platform's history. Understanding this changes how you evaluate every future proposal that says "undefined behaviour is fine, nobody does that."

2. It is the best available example of "production complexity you would never have designed." Insertion modes, foster parenting, the adoption agency algorithm, and the <input type=hidden> exception all exist because of content that already shipped. This module is where §46 becomes a real skill rather than a worksheet.

3. Parser behaviour determines what your framework can assume. Every templating system, every SSR pipeline, every sanitiser and every innerHTML call inherits these rules. Bugs where "the same HTML produces a different DOM in SSR vs client" are almost always someone meeting fragment parsing without knowing it exists.

4. It is where blocking behaviour originates. Parser-blocking scripts, preload scanning and script streaming are the mechanics behind most "why is first paint slow" answers.


Mental Model

Two machines, not one

bytes → decoder → TOKENIZER ──tokens──► TREE BUILDER → DOM
                  (state machine)       (insertion modes + stack of open elements)

They are separate, they have separate specs, and — critically — they are coupled in both directions. The tree builder can change the tokenizer's state. <script>, <textarea>, <title>, and <plaintext> all switch the tokenizer into a different content model depending on which element the tree builder just opened. So you cannot tokenize HTML correctly without partially parsing it. That is the first thing that makes HTML different from every parser you have written.

The tree builder is a pushdown automaton with exceptions

Three pieces of state carry everything:

  1. Insertion mode — "in body", "in table", "in head", "after body", … The mode decides how a token is interpreted.
  2. Stack of open elements — the currently-open ancestry.
  3. List of active formatting elements — the machinery that makes <b>a<p>b</b>c produce sensible output. This is the adoption agency algorithm, and it is the most notorious part of the spec.

And one rule that is easy to miss and explains most surprises:

Where a node is inserted is decided separately from which token is being processed. The spec calls this the "appropriate place for inserting a node." Foster parenting is a property of the insertion location, not of the insertion mode.

the parsing lab Case A turns on exactly this: the insertion mode is still "in table" when world is inserted, but the current node is a div, so the redirect does not apply.

Error recovery is the specification, not a fallback

There is no parse-failure path. Every byte sequence yields a DOM. "Parse error" in the spec is a diagnostic annotation, not a control-flow branch — the algorithm continues, and what it does next is precisely defined. This is why:

  • you cannot make HTML stricter without breaking the web,
  • two conforming browsers must produce the same DOM for broken markup,
  • and "just use a real XML parser" was tried, as XHTML, and lost.

Fragment parsing is a different algorithm

element.innerHTML = "..." does not run the document parsing algorithm. It runs the fragment parsing algorithm with a context element, which seeds the insertion mode based on that element. Consequences that bite in practice:

  • table.innerHTML = "hello" and a document containing <table>hello</table> can differ.
  • SSR-then-hydrate mismatches sometimes originate here rather than in the framework.
  • Blink has a dedicated fast path for this case — TryParsingHTMLFragment in html_document_parser_fastpath.h — which handles common simple fragments without the full state machine and bails out to the general algorithm on anything unsupported. The header's own signature exposes the bail-out (failed_because_unsupported_tag), and the behaviour flags there (kStripInitialWhitespaceForBody for DOMParser, kIncludeShadowRoots) are a compact catalogue of the special cases fragment parsing has accumulated.

That fast path is a good early lesson: production parsers contain a fast path for the common case and a slow, fully-correct path underneath. Your mini parser will only have the second, and that is the right choice for learning — but knowing the shape of the real thing tells you what "optimise later" actually looks like.

Scripts, and why the parser is not free to run ahead

A classic <script> blocks parsing: the parser stops, the script runs, and the script may call document.write, which injects into the input stream at the current position. This is why the parser cannot simply be moved off the main thread wholesale.

Blink mitigates rather than removes this, and the two mitigations are different and frequently conflated:

HTMLPreloadScannerBackgroundHTMLScanner
Threadmainworker
Purposediscover subresources to preloadfind inline scripts to stream-compile
Coveragefirst chunk of body only, unless the parser is pausedall body data

The second exists because it does not tie up the main thread, so it can afford to scan everything. Both are speculative: they read ahead without building a DOM, and are allowed to be wrong (a speculative preload that turns out unnecessary costs bandwidth, not correctness). Speculation that cannot affect correctness is the general pattern for making a blocking algorithm faster without changing its semantics — you will meet it again in style invalidation and in the compositor.


Under the Hood

Do not take these as paths to memorise — they are the answers you should be able to re-derive with bi-01 Technique 1 (spec-phrase search). The parser directory has ~95 files; you need about six of them.

ConcernWhere to look
Driving the parse, chunking, yieldingthe document-parser class
Tokenizer state machinethe tokenizer + its .json5 name table
Token representationtoken / atomic-token headers
Insertion modesthe tree builder
Stack of open elements, insertion location, foster parentingthe construction site + element stack
Formatting elements / adoption agencythe formatting element list (its test file is named after the algorithm)
Speculationpreload scanner (main) and background scanner (worker)

The single most useful structural observation: the tree builder does not mutate the DOM directly. It queues tasks on the construction site, which are flushed in batches. Ask why before reading on — the answer involves custom element reactions, mutation observers, and script re-entrancy, and deriving it yourself is worth more than being told.

Connecting to specification

Blink's parser quotes the HTML Standard heavily, which makes the spec a search index (bi-01 Technique 1). Practise the round trip in both directions:

  • spec algorithm name → distinctive phrase → Blink implementation,
  • Blink function name → the spec step it implements → the WPT that tests it.

If you can do both directions fluently for the parser, you can do them for any spec-defined subsystem, which is most of core/.


Deep dive: how big is this state machine, actually?

Measured in the checkout (2026-08-10):

html_tokenizer.h    :  76 tokenizer states
html_tree_builder.h :  21 insertion modes

Those two numbers are the module in miniature. Seventy-six states to turn text into tokens. Any parser you have written by hand probably had five. The gap is entirely legacy and content models, and enumerating why is the best §46 exercise available.

Why 76 states

The tokenizer states fall into families, and each family is a reason:

FamilyExamplesWhy it exists
CorekDataState, kTagOpenState, kTagNameState, kEndTagOpenStatethe actual parsing job
Attributeattribute name / before-value / quoted / unquoted value statesHTML permits unquoted and single-quoted values
Content modelskRCDATAState, kRAWTEXTState, kScriptDataState, kPLAINTEXTStateinside <title>, <textarea>, <style>, <script> the rules change
Content-model exitskRCDATALessThanSignState, kRAWTEXTEndTagNameState, …you must recognise only the matching end tag
Script escapingkScriptDataEscapeStartState, …EscapedState, …DoubleEscaped…<!-- inside <script>, a 1990s idiom
Character referenceskCharacterReferenceInDataState, …InRCDATAStateentities, per content model
Markup declarationcomment, DOCTYPE, CDATA states<!-- -->, <!DOCTYPE>, <![CDATA[

The script-escaping family is pure archaeology. Old pages wrapped inline script in HTML comments so that browsers without script support would not render the source:

<script><!--
  document.write("hi");
// --></script>

Supporting that required the tokenizer to track escaped and double-escaped script data, which is where kScriptDataDoubleEscaped* comes from. Nobody would design this. It is in every conforming browser forever because pages that rely on it still exist.

The lesson to carry: a large fraction of production complexity in long-lived systems is not bad design — it is compatibility with things that already shipped. Learning to distinguish "this is complex because the problem is hard" from "this is complex because 1997 happened" is a Principal-level reading skill, and HTML parsing is the best training ground for it.

The 21 insertion modes, and what each is for

kInitialMode              kBeforeHTMLMode           kBeforeHeadMode
kInHeadMode               kInHeadNoscriptMode       kAfterHeadMode
kTemplateContentsMode     kInBodyMode               kTextMode
kInTableMode              kInTableTextMode          kInCaptionMode
kInColumnGroupMode        kInTableBodyMode          kInRowMode
kInCellMode               kAfterBodyMode            kInFramesetMode
kAfterFramesetMode        kAfterAfterBodyMode       kAfterAfterFramesetMode

Grouped by why they exist:

  • Document skeleton (Initial, BeforeHTML, BeforeHead, InHead, AfterHead, AfterBody, AfterAfterBody) — enforcing that a document has the right shape even when the markup does not say so. This is why <p>hi produces a full html/head/body tree.
  • Tables (InTable, InTableText, InCaption, InColumnGroup, InTableBody, InRow, InCell) — seven of twenty-one modes are tables. That ratio is the honest measure of how much table markup cost the platform.
  • Framesets (InFrameset, AfterFrameset, AfterAfterFrameset) — a deprecated feature that still owns three modes.
  • Special content (Text, TemplateContents, InHeadNoscript).

kAfterAfterBodyMode is worth a moment: it is the state after </html>. It exists because content can legally appear there and must go somewhere sensible — which is why whitespace after </html> still lands inside body (case C of the lab).


Deep dive: the adoption agency algorithm

The most notorious part of the spec, and the reason the "list of active formatting elements" exists at all. The problem:

<b>bold <p>both</b> italic-ish</p>

Formatting elements (<b>, <i>, <em>, <strong>, <a>, <font>…) can be left open across block boundaries, producing markup whose intended nesting is impossible as a tree. The DOM has no way to represent "this <b> overlaps that <p>."

The algorithm's job is to reconstruct formatting so the visual result matches author intent, by cloning formatting elements into the new block. That is why you get:

<b>bold </b><p><b>both</b> italic-ish</p>

— the <b> was duplicated, because overlapping ranges must become nested trees.

Things worth knowing:

  • The list of active formatting elements is separate from the stack of open elements, and the "Noah's Ark clause" limits it to three identical entries so that pathological markup cannot blow up.
  • Blink has a test file named after the algorithm (html_tree_builder_adoption_agency_test.cc). A dedicated test file named after one algorithm is a signal: it means the algorithm is both intricate and repeatedly gotten wrong.
  • It is the standard example of "the spec is complex because the DOM is a tree and markup is not."

Do not implement this in your mini parser. Read it, understand why it exists, and record it in the complexity notebook.


Deep dive: speculation, and what it may not do

Two scanners, already tabulated in §2. The deeper point is the discipline they follow:

A speculative pass may be wrong, but it may never be observable.

The preload scanner may request a resource that turns out unnecessary — that costs bandwidth, not correctness. The background scanner may stream-compile a script that never runs — that costs CPU. Neither may create a DOM node, run script, or change parser state.

This is the general shape of every safe speculation in the browser, and once you see it you will recognise it everywhere:

SpeculationCan be wrong aboutNever affects
Preload scannerwhich resources are neededthe DOM
Background scannerwhich scripts will runparser state
Compositor scroll (bi-10)whether JS wanted to preventDefault...until it must ask
Style sharing (bi-07)nothing — it is exact
Branch prediction (CPU)the brancharchitectural state

Design principle worth stealing: to speed up a blocking algorithm you cannot restructure, add a side-effect-free pass that runs ahead and is permitted to be wrong.


Deep dive: scripts, and the four ways they block

The parser's relationship with <script> is the reason it cannot move off the main thread.

FormParser behaviourExecutes
<script> (classic, inline or external)blocks parsing; external also blocks on fetchimmediately, in order
<script defer>does not blockafter parsing, before DOMContentLoaded, in order
<script async>does not blockas soon as fetched — order not guaranteed
<script type="module">deferred by defaultafter parsing, in dependency order

Two consequences that show up in real products:

  • async scripts are an ordering hazard, not just a loading strategy. Two async scripts where one depends on the other is a race that passes locally and fails on a slow network.
  • A blocking script in <head> stalls tree construction, which is why "put scripts at the end of body" was the advice before defer existed, and why defer is now the better answer (it preserves order and does not block).

document.write, the reason all this is stuck

document.write inserts text into the input stream at the current position. The parser must therefore be able to be interrupted, have its input mutated, and resume. That single API forecloses a fully off-main-thread parser, and it is why Chromium chose speculation over relocation.

It is also actively hostile to performance on slow connections — a document.write of a <script src> in the middle of a page can serialise loading — which is why browsers have shipped interventions that ignore it in some circumstances. That is a rare and instructive event: the platform breaking spec-defined behaviour deliberately, because the data said users were better off.


Deep dive: encoding, and the parse you cannot undo

Before tokenizing you must know the encoding, and you learn it from the bytes you are decoding.

Order of authority, roughly: BOM → HTTP Content-Type charset → <meta charset> in the first chunk → heuristics/locale default.

The awkward case is <meta charset> appearing after you have already begun decoding. The parser scans a prefix looking for it; if a declaration is found late and contradicts the current assumption, the parse must be restarted. That is why <meta charset> is specified to appear early (within the first 1024 bytes) and why it is worth putting first in <head>.

Note what class of problem this is: an input whose interpretation depends on its own content. It shows up again in text shaping, in MIME sniffing, and in bundler module-format detection.


Deep dive: <template>, and why it is a separate mode

kTemplateContentsMode exists because <template> content must be parsed but inert: no scripts run, no images load, no custom elements upgrade. Its children live in a separate DocumentFragment (.content), not in the document tree.

Consequences you can observe:

  • document.querySelector('template img') finds nothing — the img is not in the document.
  • Template content survives table parsing rules that would otherwise foster-parent it, because the tree builder tracks templates separately. FindFosterSite checks for a topmost template before it checks for a table — which is exactly the "field you did not derive" the lab's answer key points at.

That ordering is not arbitrary: if a template is open inside a table, content belongs to the template, not foster-parented out of the table. Reading that one if in the right order tells you the whole design.


Deep dive: what the parser hands downstream

Parsing does not end at "a DOM exists." As nodes are created the parser also drives:

  • custom element reactions — queued, not immediate (bi-04), so author code never observes a half-built tree;
  • style recalculation scheduling — new elements are dirty;
  • resource loading<img>, <link>, <script> discovery;
  • DOMContentLoaded — fired when parsing finishes and deferred scripts have run;
  • incremental rendering — the parser yields so the page can paint before the document is complete. This is why a long document renders progressively rather than appearing at once, and it is a scheduling decision (bi-11) as much as a parsing one.

That last point is the one to hold: the parser deliberately stops parsing to let the page paint. A parser optimised purely for throughput would produce a worse product.


Anti-Patterns

Assuming innerHTML round-trips. el.innerHTML = el.innerHTML can change the DOM. Serialisation and parsing are not inverses.

Reasoning about <table> markup from intuition. Table parsing is the densest special- case area in the spec. Check, don't reason.

Treating "parse error" as "the browser rejected it." Nothing is rejected. Ever.

Concluding a behaviour is a bug because it is ugly. the parsing lab Case B is legal, specified, and intentional. The bug hypothesis should come after the spec check.

Writing a sanitiser on top of a regex or your own parser. The mismatch between your parser and the browser's is the vulnerability class — mutation XSS is exactly "the sanitiser and the parser disagreed about what this string means." This is the strongest practical argument for why exact parser semantics matter to application engineers.


Trade-offs

Spec fidelity vs speed. Blink keeps a fully-correct implementation and adds fast paths that bail out. The alternative — a fast parser that is subtly wrong — was the pre-HTML5 world.

Main-thread parsing vs correctness. A fully off-main-thread parser is impossible while document.write exists. Chromium chose speculation (side-effect-free, discardable) over relocation. Note the general principle: when you cannot move blocking work, move the predictable part of it and keep the ability to be wrong.

Streaming vs buffering. The parser consumes bytes as they arrive so paint can start early, which is why the tokenizer is resumable mid-token and the input stream is a segmented structure rather than a String. Buffering would be far simpler and would delay first paint.


Lab

the parsing lab — foster parenting (bi-03-html-parsing/docs/execution.md) — prerequisite.

mini-browser M2–M3 — extend foster.js into a real tokenizer + tree builder:

  1. Tokenizer as an explicit state machine: data, tag open, tag name, attribute name, attribute value (quoted/unquoted), comment, DOCTYPE. Named character references may be stubbed.
  2. Tree builder with a stack of open elements and at least: initial, before html, before head, in head, after head, in body, in table, in table text.
  3. Error recovery for: unclosed <p>, unclosed <li>, stray </div>, text in tables.
  4. Tokenizer/tree-builder coupling — implement <title>/<textarea> switching the tokenizer's content model. This is the stage that teaches the real lesson.
  5. A conceptual <script> pause: stop the parse, run a callback, resume. You do not need a JS engine — you need the control flow.

Then compare to Blink: for each of your states, find its counterpart; for each of Blink's that you omitted, say what markup needs it.


Failure Lab

  1. Remove foster parenting. Which real-world markup breaks? Predict, then test against a corpus of your own saved pages.
  2. Remove the in-table-text buffering and insert characters immediately. Explain why the batching is load-bearing rather than an optimisation. (This is the deepest question in the parsing lab Step 7.)
  3. Make the tokenizer independent of the tree builder — no content-model switching. Find markup that now parses catastrophically wrong. This one is a proof that the two machines cannot be decoupled.
  4. Mutation XSS: write a naive sanitiser that strips <script> from a string, then find an input where your sanitiser's parse and the browser's parse disagree. Do this defensively, on your own machine, against your own page — the point is to internalise why parser-aware sanitisation (or Trusted Types) is the only sound approach.

Debugging Exercise

With the local content_shell build:

  1. Breakpoint in the tree builder's in-table start-tag handler. Load the parsing lab's a.html. Capture the call stack — how did you get here from the network?
  2. Set a conditional breakpoint on the foster-parenting predicate, condition it on the current node being a table, and confirm the parsing lab Case A step 5 by observation rather than by reading.
  3. Trace a real page load with the blink category. Find the parser's trace events, and identify: how many chunks the parse was split into, and where scripts blocked it.
  4. Find where the construction site's queued tasks are flushed. Set a breakpoint and answer from the stack: what triggers a flush?

Testing & QA Considerations

  • Find the WPT directory for HTML parsing and identify the html5lib-style data-driven tests. What format are they in, and why is a data-driven format the right choice here?
  • Find a parser-related entry in TestExpectations. What behaviour does Chromium currently get wrong, and is it a known interop gap or a deliberate deviation?
  • Write a test in the html5lib format for the parsing lab Case B. Run it against Blink.

Further Reading (primary sources first)

  • WHATWG HTML Standard §13 Parsing HTML documents — tokenization and tree construction. Read the "in table" and "in table text" modes in full; they are shorter than they look.
  • WHATWG HTML Standard — fragment parsing algorithm, and the "appropriate place for inserting a node" algorithm.
  • third_party/blink/renderer/core/html/parser/ — start from html_document_parser.h, and read html_document_parser_fastpath.h for the fragment fast path.
  • html5lib test data format (used by WPT and by most non-browser HTML parsers).
  • Blink's SpecMapping.md for the spec→directory mapping.

Principal Engineer Review

  1. Why does the HTML spec define error recovery at all, given that it makes the parser far more complex? What would the web look like if it had not, and what is the modern equivalent decision you would apply this lesson to?

  2. element.innerHTML = element.innerHTML can change the DOM. Explain the mechanism, and give a realistic production bug this causes.

  3. Your team's SSR output differs from the client-side DOM for the same component. List the parser-level causes, ranked by likelihood, and the fastest check for each.

  4. Argue that the tokenizer and tree builder should be fully decoupled for maintainability. Then defeat your own argument with a concrete markup example.

  5. document.write is the main obstacle to off-main-thread parsing. Design a deprecation path for it. What breaks, who complains, and how would you actually measure whether the removal is safe?

  6. Blink ships a fragment-parsing fast path that bails out to the general algorithm. What invariant must hold for such a fast path to be safe, and how would you test that the two paths agree? What is the failure mode if they silently diverge?

  7. The preload scanner runs on the main thread and only scans the first chunk; the background scanner runs on a worker and scans everything. Reconstruct why these are two mechanisms rather than one. What would it take to merge them?

  8. A security engineer proposes sanitising HTML with a regex "as a defence in depth layer." Explain precisely why this can make things worse rather than merely being insufficient.

  9. Foster parenting exists to handle markup nobody writes deliberately. Make the case for removing it from the platform, then estimate what evidence you would need to justify the attempt. Who would you have to convince, and what would the rollout look like?

  10. You find a Blink parser behaviour that contradicts the spec. Walk through your next steps in order, including how you decide whether the spec is what should change.

References — bi-03-html-parsing

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • WHATWG HTML Standard §13 Parsing HTML documents — tokenization and tree construction. Read the "in table" and "in table text" modes in full; they are shorter than they look.
  • WHATWG HTML Standard — fragment parsing algorithm, and the "appropriate place for inserting a node" algorithm.
  • third_party/blink/renderer/core/html/parser/ — start from html_document_parser.h, and read html_document_parser_fastpath.h for the fragment fast path.
  • html5lib test data format (used by WPT and by most non-browser HTML parsers).
  • Blink's SpecMapping.md for the spec→directory mapping.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-03 — Analysis

Required invariants

  1. Every byte sequence produces a DOM. There is no parse-failure path. "Parse error" is a diagnostic annotation, not control flow.
  2. Two conforming implementations produce the same DOM for broken markup. This is the entire reason error recovery is specified, and the largest interop win in the platform's history.
  3. Insertion location is decided separately from insertion mode. Foster parenting is a property of where, not of which mode — the lab's Case A turns on exactly this.
  4. The tokenizer's state depends on the tree builder. Content models (RCDATA, RAWTEXT, script data) mean the two machines cannot be decoupled.
  5. Speculation may be wrong; it may never be observable. Preload and background scanners create no nodes, run no script, and change no parser state.
  6. Author code never observes a half-built tree. The construction site queues tasks rather than mutating directly.

The measurable shape of the problem

QuantityValueWhat it tells you
Tokenizer states76content models and legacy escaping, not core parsing
Insertion modes217 are tables; 3 are framesets
Parser directory files~95you need about six of them

Seven of twenty-one insertion modes are table-related. That ratio is the honest measure of what table markup cost the platform, and it is a better argument than any anecdote.

Failure modes

BreakConsequenceFirst test to fail
Remove foster parentinglegacy table markup renders inside-outhtml5lib table cases
Remove character batchingwhitespace handling in tables becomes wrongin-table-text cases
Decouple tokenizer from tree builder<title>/<textarea> content parsed as markupRCDATA cases
Remove the <input type=hidden> exceptionhidden form fields leave their <form> on submitcompat, not conformance
Sanitiser disagrees with the parsermutation XSSnone — this is a security bug, not a test failure

Classification exercise

For each of the following, decide: essential architecture, production hardening, or accretion?

  • insertion modes — essential (the tree shape must be recovered from arbitrary input)
  • kScriptDataDoubleEscaped*accretion (1990s comment-wrapped scripts)
  • the fragment-parsing fast path — hardening (it bails out to the general algorithm; that is the tell)
  • foster parenting — essential, but only given the compatibility requirement
  • <input type=hidden> in tables — accretion, and the answer key argues the case

Getting these right, with evidence, is the skill fw-11 formalises.

Execution — HTML Parser: Foster Parenting

The first lab of the track. No Chromium build required.

Track §6 (HTML Parser Internals), §7 (mini parser), §44 (Reading Ladder), §46 (complexity notebook). Phase 0. No Chromium build required — this lab runs entirely on Code Search, a spec, and any Chrome.

Time budget: ~3 hours. If step 4 exceeds 60 minutes, stop and ask — the navigation technique is the deliverable, not the answer.


Why this lab is first

It is the smallest task in the entire track that exercises the complete loop — predict, verify, spec, source, test, break, explain — and it needs zero setup. It also lands on the single best example in the web platform of "production complexity caused by a constraint you would never have guessed," which is the §46 skill the whole track is built around.


Rules

  1. Write every prediction down before running anything. Predictions made after observation are worthless. Put them in docs/observation.md before you open a browser.
  2. Do not use innerHTML to verify. It invokes the fragment parsing algorithm with a different context element and can produce a different tree. Load real documents.
  3. No source paths will be given to you. Record the search that worked, not the path.
  4. Do not read §9 (Answer key) until §8 is written.

Step 1 — Predict

For each case, write out the resulting DOM tree of document.body — every element, every text node, with exact text contents. Also answer, per case: what is table.parentNode? What is table.childNodes.length?

Case A — canonical

<!DOCTYPE html><html><body><table>hello<div>world</div></table></body></html>

Case B — the special case

<!DOCTYPE html><html><body><table><input type="hidden" name="a"><input type="text" name="b"></table></body></html>

Case C — the track's example, verbatim (whitespace matters)

<html>
  <body>
    <table>
      hello
      <div>world</div>
    </table>
  </body>
</html>

Additional predictions for Case C:

  • How many text nodes exist in total, and what are their exact contents?
  • Does <table> end up with any children at all?

Case D — stretch

<!DOCTYPE html><html><body><table><tr><td>a</td></tr></table></body></html>

Which element in the result was never written in the source, and which spec rule creates it?


Step 2 — Verify

Write the three files and open them from file:// (not via innerHTML, not via DOMParser — a real navigation).

# from labs/
mkdir -p lab-01 && cd lab-01
printf '%s' '<!DOCTYPE html><html><body><table>hello<div>world</div></table></body></html>' > a.html
printf '%s' '<!DOCTYPE html><html><body><table><input type="hidden" name="a"><input type="text" name="b"></table></body></html>' > b.html
printf '<html>\n  <body>\n    <table>\n      hello\n      <div>world</div>\n    </table>\n  </body>\n</html>\n' > c.html
printf '%s' '<!DOCTYPE html><html><body><table><tr><td>a</td></tr></table></body></html>' > d.html

In the console, dump the tree with whitespace made visible:

const show = (n, d = 0) => {
  const pad = '  '.repeat(d);
  if (n.nodeType === 3) return pad + '#text ' + JSON.stringify(n.data) + '\n';
  if (n.nodeType === 8) return pad + '#comment ' + JSON.stringify(n.data) + '\n';
  const attrs = n.attributes?.length
    ? ' [' + [...n.attributes].map(a => `${a.name}=${JSON.stringify(a.value)}`).join(' ') + ']'
    : '';
  let s = pad + n.nodeName.toLowerCase() + attrs + '\n';
  for (const c of n.childNodes) s += show(c, d + 1);
  return s;
};
console.log(show(document.body));

Score each prediction: exactly right / structurally right, details wrong / wrong. Record the score honestly in PROGRESS.md §7.


Step 3 — Spec

Find, in the HTML Standard (the parsing section, "tree construction"), the rules that produce what you observed. You are looking for:

  • the insertion mode that is active when the parser is inside <table>
  • the sub-mode that handles character tokens there, and why one exists separately
  • the algorithm that decides where a node actually gets inserted, which is not simply "into the current node"
  • the specific exception in Case B

Deliverable: for each case, quote the spec sentence that causes the observed behavior, and name the algorithm that relocates the node.

Do not skip this. The whole point of §23/WPT later is that spec text is the shared contract between browsers; Blink comments quote it, which is what makes step 4 tractable.


Tool: Chromium Code Search — https://source.chromium.org/chromium/chromium/src

Strategies, in the order to try them:

  1. Spec-phrase search. Blink's parser mirrors the spec closely and quotes step text in comments. Take the most distinctive phrase from the algorithm you found in step 3 — a two-or-three-word term of art, not a common word — and search it in quotes. This is the highest-yield technique in the entire track for spec-defined subsystems.
  2. Symbol search. Once you have a plausible name, symbol:Name finds the declaration rather than every mention.
  3. Cross-references. Click the symbol → the references panel gives you callers and callees. This answers gate questions 3 and 4 mechanically; do not guess them.
  4. Scoping. file:third_party/blink/renderer/core/html/parser/ narrows noise. case:yes matters for C++ identifiers.
  5. History. git log-equivalent blame in Code Search: find the CL that introduced the Case B exception. Its commit message is the answer to "why does this exist."

Deliverables:

  • the function that performs the relocation, and the class that owns it
  • the call site(s) that decide relocation is needed
  • the exact predicate that implements the Case B exception
  • the search query that found each one, written down

Step 5 — Find the tests

Locate at least one of each:

  • a C++ unit test in the parser directory (file:_test.cc)
  • a Blink web test exercising this behavior (search under third_party/blink/web_tests/)
  • a Web Platform Test (third_party/blink/web_tests/external/wpt/) — note that WPT is the cross-browser contract, and finding it here is your §23 warm-up

Deliverable: file paths, plus one sentence on what each test would catch that the others would not.


Step 6 — Build a small version

Write foster.js — under ~80 lines, no dependencies. It does not need to be a real tokenizer. Take a pre-tokenized array of tokens as input:

const tokens = [
  { type: 'startTag', name: 'table' },
  { type: 'character', data: 'hello' },
  { type: 'startTag', name: 'div' },
  { type: 'character', data: 'world' },
  { type: 'endTag', name: 'div' },
  { type: 'endTag', name: 'table' },
];

Implement only:

  • a stack of open elements
  • two insertion modes: inBody, inTable
  • an appropriateInsertionPlace(fosterParenting) function
  • the Case B exception

It must produce the correct tree for cases A, B and D. Case C (whitespace batching) is the stretch goal, and if you attempt it you will discover by yourself why the spec needs a separate character sub-mode — that discovery is the real prize here.

This file is milestone M2 of mini-browser/. Keep it.


Step 7 — Break it

Analytically (no local build yet — you will do this for real in Phase 6):

  1. Delete the relocation step: every node goes into the current node. Which of your four cases changes? What class of real-world page breaks?
  2. Delete the Case B exception. What breaks, and for whom?
  3. Delete the character sub-mode and insert characters immediately. What changes about Case C, and why is the batching load-bearing rather than an optimization?
  4. For each: name the specific test from step 5 that would fail first.

Also break your own foster.js the same three ways and observe the diffs. Your toy is the control group.


Step 8 — The gate (§44)

Write answers for the relocation function. All eight, in prose, in docs/observation.md:

  1. Why does this code exist?
  2. What invariant does it maintain?
  3. Who calls it?
  4. What does it call?
  5. Which process and thread executes it? (Be precise — and check whether the answer is always the same one. The parser directory contains a hint that it may not be.)
  6. What happens if it is removed?
  7. How is it tested?
  8. What simpler design would fail, and why?

Then open complexity-notebook entry #1 (HTML parser insertion modes) in PROGRESS.md §5:

Observed complexity:
My simpler design:
What requirement breaks my design:
Production constraint:
Resulting architecture:
Essential architecture, or accreted complexity? (defend it)

Case B is the sharpest possible input to that entry. Ask specifically: is this exception principled, or is it a fossil? Defend the answer with evidence from the CL history.


Step 9 — Answer key

The key is on disk at answer-key.md, behind a stop banner. It is not locked. Nobody is checking. Open it after §8 and not before — the value of this lab is entirely in the gap between your prediction and reality.


Done when

  • Predictions written before observation, and scored
  • Spec sentences quoted per case
  • Implementation located, with the working search queries recorded
  • Three kinds of test located
  • foster.js passes cases A, B, D
  • Three analytical breakages reasoned through, with the first-failing test named
  • Eight gate questions answered
  • Complexity notebook entry #1 written
  • PROGRESS.md lab log updated

Observation — Foster Parenting (fill this in; do NOT open docs/answer-key.md until §8 is done)

Started: Finished:


Step 1 — Predictions (write BEFORE running anything)

Case A — <table>hello<div>world</div></table>

body
  ...

table.parentNode = table.childNodes.length =

Case B — <table><input type="hidden" name="a"><input type="text" name="b"></table>

body
  ...

table.parentNode = table.childNodes.length =

Case C — multiline (exact whitespace)

body
  ...

Total text nodes in the body subtree: Does <table> have any children?

Case D — <table><tr><td>a</td></tr></table>

body
  ...

Element present in the DOM that was never written in the source: Spec rule that creates it:


Step 2 — Observed

CasePredictionVerdict (exact / structural / wrong)What I got wrong
A
B
C
D

Step 3 — Spec

Insertion mode active inside <table>: Character sub-mode, and why it exists separately: Algorithm that relocates the node: Case B exception — quote the sentence:

Per-case spec sentences:

  • A:
  • B:
  • C:
  • D:

WhatName foundSearch query that found it
Relocation function
Owning class
Predicate deciding relocation
Insertion-site locator
Case B predicate
Start-tag handler for in-table

Callers (from the xref panel): Callees:

CL that introduced the Case B exception (link + one-line summary of its message):


Step 5 — Tests

KindPathWhat it catches that the others do not
C++ unit test
Blink web test
WPT

Step 6 — foster.js

Passes A / B / D: Attempted C: What writing it taught me that reading could not:


Step 7 — Breakages

  1. Remove relocation entirely → First test to fail:
  2. Remove the Case B exception → First test to fail:
  3. Remove the character sub-mode (insert chars immediately) → First test to fail: Why is the batching load-bearing rather than an optimization?

Diffs observed in my own foster.js under the same three mutations:


Step 8 — The gate (§44)

  1. Why does this code exist?

  2. What invariant does it maintain?

  3. Who calls it?

  4. What does it call?

  5. Which process and thread executes it? (is the answer always the same one?)

  6. What happens if it is removed?

  7. How is it tested?

  8. What simpler design would fail, and why?


Complexity notebook entry #1 — HTML parser insertion modes

Observed complexity:

My simpler design:

What requirement breaks my design:

Production constraint:

Resulting architecture:

Essential architecture, or accreted complexity? (defend it)

Verification — bi-03-html-parsing

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Predictions written before observation, and scored
  • Spec sentences quoted per case
  • Implementation located; the working search queries recorded
  • Three kinds of test located (unit, web test, WPT)
  • foster.js passes cases A, B, D
  • Three analytical breakages reasoned through, first-failing test named
  • Eight gate questions answered
  • Complexity notebook entry #1 written
  • mini-browser M2–M3 complete: tokenizer/tree-builder coupling implemented

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

Answer Key — Foster Parenting

⛔ STOP

Do not read this until docs/observation.md §8 is written.

The value of this lab is entirely in the gap between your prediction and reality. Reading first converts a 3-hour skill-building exercise into 10 minutes of trivia you will forget. Nobody is checking. That is the point.
































Case A — <table>hello<div>world</div></table>

body
  #text "hello"
  div
    #text "world"
  table

table.parentNode = body · table.childNodes.length = 0

Trace:

  1. <table> inserted in in body; insertion mode → in table.
  2. "hello" character token, current node is table → pending table character tokens cleared, original insertion mode saved, mode → in table text.
  3. Characters accumulate. The <div> start tag ends the run. Because the pending buffer contains a non-whitespace character, it is a parse error and the whole buffer is reprocessed through in table's "anything else" — i.e. foster parented before the table. Mode reverts and <div> is reprocessed.
  4. <div> in in table → "anything else" → foster parenting enabled → processed with in body rules → inserted before the table, pushed onto the stack of open elements. The insertion mode stays in table.
  5. "world": mode is still in table, but the current node is now div, not a table-ish element — so the foster-parenting redirect does not apply (it only fires when the target is table/tbody/tfoot/thead/tr). The text lands inside the div.

Step 5 is the one almost everyone gets wrong. Foster parenting is a property of the insertion location, not of the insertion mode.


Case B — <table><input type="hidden"><input type="text"></table>

body
  input [type="text" name="b"]
  table
    input [type="hidden" name="a"]

table.childNodes.length = 1

The hidden input is the only element in this lab that stays inside the table. in table has an explicit rule for <input>: if it has a type attribute case-insensitively equal to hidden, parse error, insert the element, pop it. Otherwise fall through to "anything else" and get foster parented.

This is the §46 payload. There is no structural or rendering reason a hidden input is safer inside a table than a text input — it renders nothing either way. The rule exists because a large amount of legacy server-generated HTML emitted hidden form fields directly inside <table> markup, and relocating them out of the table moved them out of the enclosing <form>, silently dropping fields on submit. The spec encoded the compatibility requirement. This is a fossil, not a principle — and your notebook entry should say so, with the CL/spec history as evidence.


Case C — the multiline example

body
  #text "\n    \n      hello\n      "
  div
    #text "world"
  table
    #text "\n    "
  #text "\n  \n\n"

Text nodes in the body subtree: 4 · table has 1 child.

Three things worth having missed:

  • The first text node is a merge. "\n " (between <body> and <table>) was already in body when the foster-parented "\n hello\n " arrived. Inserting a character appends to the Text node immediately before the insertion location if one exists, so they coalesce into a single node rather than becoming two siblings.
  • The table is not empty. The whitespace between </div> and </table> is buffered by in table text, and because that run is entirely whitespace it is inserted normally — into the table. Whitespace stays; non-whitespace gets evicted. That asymmetry is the whole reason the sub-mode exists.
  • The trailing text node keeps growing after </body>. </body> switches the mode to after body but does not pop body off the stack, so whitespace after </body> and after </html> still appends to body's last Text node.

If your browser observation disagrees with this derivation, trust the observation — and then treat the disagreement as a genuine finding worth chasing to the source. That is a better outcome than agreeing with me.


Case D — <table><tr><td>a</td></tr></table>

body
  table
    tbody
      tr
        td
          #text "a"

tbody was never written. in table handling a td/th/tr start tag inserts a tbody element for a synthesized start tag with no attributes, switches to in table body, and reprocesses the token.


Verified against main on 2026-08-10. Everything below lives in third_party/blink/renderer/core/html/parser/.

RoleNameFile
Performs relocationHTMLConstructionSite::FosterParent(Node*)html_construction_site.cc
Locates the siteHTMLConstructionSite::FindFosterSite(HTMLConstructionSiteTask&)html_construction_site.cc
Decides relocationHTMLConstructionSite::ShouldFosterParent()html_construction_site.cc
Per-element predicateHTMLStackItem::CausesFosterParenting()html_stack_item.h
In-table start tagsHTMLTreeBuilder::ProcessStartTagForInTable(AtomicHTMLToken*)html_tree_builder.cc
Flushes buffered charsHTMLTreeBuilder::DefaultForInTableText()html_tree_builder.cc
Bufferpending_table_characters_html_tree_builder.cc
bool HTMLConstructionSite::ShouldFosterParent() const {
  return redirect_attach_to_foster_parent_ &&
      CurrentStackItem()->IsElementNode() &&
      CurrentStackItem()->CausesFosterParenting();
}

Three conditions, and they map exactly onto the spec: redirect_attach_to_foster_parent_ is the "foster parenting enabled" flag the tree builder toggles around the "anything else" path; CausesFosterParenting() is the table-ish check. This is why Case A step 5 behaves as it does — the flag is on, but the current node is a div, so the predicate is false.

FindFosterSite checks open_elements_.Topmost(kTemplate) before Topmost(kTable). That template check is not in the naive reading of the algorithm and is worth a second look — it is the answer to gate question 8.

The Case B predicate:

case HTMLTag::kInput: {
    Attribute* type_attribute = token->GetAttributeItem(html_names::kTypeAttr);
    if (type_attribute &&
        EqualIgnoringAsciiCase(type_attribute->Value(), "hidden")) {
        ParseError(token);
        tree_.InsertSelfClosingHTMLElementDestroyingToken(token);
        return;
    }
    // break to hit "anything else" case.
    break;
}

Note FosterParent does not mutate the DOM directly — it builds an HTMLConstructionSiteTask and calls QueueTask(task, true). Tree construction is deferred and batched, not immediate. If you predicted a direct appendChild-style call, that gap is your next question: why does the construction site queue instead of mutate? (Mutation observers, custom element reactions, and script reentrancy all live in that answer.)

Search strategies that work here

  • Spec-phrase search: "foster parent" in Code Search lands directly on the implementation, because Blink names its parser methods after spec terms of art.
  • symbol:ShouldFosterParent → declaration, then the references panel gives callers and callees mechanically. Never guess gate questions 3 and 4.
  • The // break to hit "anything else" case. comment is a good example of Blink annotating a spec fallthrough — grepping for anything else finds many of these.

Gate question 5 — process and thread

Renderer process, main thread — for tree construction. The nuance the lab hints at is that this is not true of the whole parser: the directory contains background_html_scanner.cc, and tokenization can run off the main thread to drive preload scanning. There is no background_html_parser.cc — the historical fully-threaded HTML parser is gone. Tree building stays on the main thread because it must interleave with synchronous script execution and observable DOM state.

Verify this yourself rather than taking it from here: check what BackgroundHTMLScanner actually produces and who consumes it. That is a good five-minute Code Search exercise and a direct rehearsal for §18.


What to do if you scored badly

Nothing. A first-lab prediction that is structurally right and detail-wrong is the expected result, and Case C is genuinely hard. What matters is whether §4's search queries are recorded — those transfer to every remaining lab in the track. The DOM trees do not.

bi-03 — Broader Ideas

Error recovery as a design decision

HTML specifies what happens for invalid input, and that decision — not any feature — is the largest interop win in the platform's history. XHTML tried the alternative and lost.

The generalisation: when many independent implementations must agree, specifying failure is more valuable than specifying success. Apply it to your own formats, protocols, and config files. If two implementations of your schema disagree about a malformed document, you have shipped a compatibility problem you will pay for later.

Speculation, everywhere

"May be wrong, may never be observable" is a reusable technique for making blocking work faster without changing semantics:

SystemSpeculative passPermitted to be wrong about
HTML parserpreload / background scannerswhich resources are needed
Compositorscroll before the main thread answerswhether JS wanted preventDefault
CPUbranch predictionthe branch
Databasesprefetch, read-aheadwhich pages are needed
Your appprefetch on hover, speculative renderwhat the user will do

The discipline is identical: the speculative path must not mutate state anything else can observe.

Mutation XSS is a parser problem

The gap between a sanitiser's parse and the browser's parse is the vulnerability. This is why parser-aware sanitisation and Trusted Types are the only sound approaches, and why "we strip <script> with a regex" is not defence in depth — it is a second, disagreeing parser.

If your product accepts HTML from users, this module is directly load-bearing on your threat model.

Next

  • mini-browser M2–M3 turns this into code.
  • bi-04 picks up where the parser hands off: what happens to a node after it is created.
  • fw-06 reuses the tokenizer skill for template compilation — you will write a state machine over markup a second time and it will be much faster.

src — foster parenting lab

a.html b.html c.html d.html   fixtures (exact whitespace)
show.js                       browser-side tree dumper
serialize.mjs                 shared serializer — same format as show.js
tokens.mjs                    pre-tokenized input for the tree builder
run.mjs                       print your tree for one case
check.mjs                     diff your tree against the browser
observed/                     where you save the browser's output
foster.js                     ← YOU WRITE THIS

Everything here except foster.js is plumbing. foster.js is the lab.

What foster.js must export

export function buildTree(tokens) {
  // returns the body root:
  // { type: 'element', name: 'body', attrs: {}, children: [...] }
}

Node shapes:

{ type: 'element', name: 'div', attrs: { id: 'x' }, children: [] }
{ type: 'text', data: 'hello' }

Under ~80 lines, no dependencies. Implement only: a stack of open elements, two insertion modes (inBody, inTable), an appropriateInsertionPlace(fosterParenting) function, and the <input type=hidden> exception.

Workflow

node run.mjs a       # see your output
node check.mjs a     # diff against the browser
node check.mjs       # all cases with an observed/ file

Cases a, b, d must pass exactly. Case c is the stretch goal — attempt it only after the others, and read the diff rather than fixing it blindly.

This file becomes mini-browser milestone M2. Keep it.

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

References — bi-04-dom-internals

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

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


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-04 — Analysis

Required invariants

  1. Wrapper identity is stable. document.body === document.body, and expandos survive a round trip through C++. This single requirement forces the unified heap.
  2. Wrappers are created lazily. A node has no JS object until script observes it — which is why a gratuitous querySelectorAll('*') costs more than the query.
  3. Mutation records, it does not recompute. A thousand DOM writes are not a thousand layouts; one interleaved geometry read makes them so.
  4. Author code never observes an inconsistent tree. Custom element reactions are queued; MutationObserver delivers on a microtask; synchronous mutation events were removed for exactly this reason.
  5. Style and layout use the flat tree. Not the node tree. Slot assignment does not change parentNode.
  6. Reachability is not sufficient for liveness. ActiveScriptWrappable keeps objects with pending activity alive; the ExecutionContext teardown clause stops that becoming a leak.

The lifetime failure modes

Leak shapeMechanismHow you find it
Detached subtreea JS reference holds a removed node and everything its listeners close overheap snapshot, retainer chain
Never-disconnected observerobserver holds target, target holds document scopeaudit disconnect() in cleanup
Long-lived Persistent<T>a root from non-GC code that is never droppedread the Trace() methods
Listener on a shared objectclosure retains the componentremove in cleanup — fw-02's lab

What the heap partitioning discloses

Node, CSSValue, and LayoutObject each get a dedicated typed space in Oilpan. That is a performance disclosure: those are the highest-volume allocations in a renderer. Your stylesheet allocates tooCSSValue earning its own space is the evidence, and it corrects the common model in which "the DOM" is the only thing that costs memory.

The coupling nobody documents

Oilpan schedules GCs through the message loop, at points where no objects are referenced from the native stack, so collection can be precise rather than conservative. The scheduler is therefore also a GC safepoint mechanism — a cross-subsystem constraint that appears in no document about either scheduling or garbage collection alone. Finding couplings like this is what reading two subsystems together buys you.

Execution — bi-04-dom-internals

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


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.

Observation — bi-04-dom-internals

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-04-dom-internals

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • mini-browser M3: DOM with correct appendChild ordering and hierarchy checks
  • Dirty-marking implemented twice (naive vs scoped); ops/sec table for a 10k-node tree
  • Forced-synchronous-layout loop measured, then batched; complexity class named
  • Detached-subtree leak built and found via retainer chain in a heap snapshot
  • Trace() method read; what an Element keeps alive written down

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-04 — Broader Ideas

The cost model every framework is betting on

React's virtual DOM, Vue's dependency tracking, and signals are three different answers to "DOM mutation is expensive." This module is where you learn the actual cost structure, which is what lets you evaluate those bets rather than adopt them.

The correction most engineers need: DOM writes are cheap — they mark dirty. What is expensive is forcing synchronous recomputation, and building enormous trees. A model that says "the DOM is slow" cannot predict which of two loops is 100× worse.

Queued notification is a universal API design problem

Custom element reactions, MutationObserver, and the parser's construction site all queue rather than notify synchronously, for one reason: author code must never observe a half-built state.

You will design an API that notifies on change. The questions this module hands you:

  • Can a callback observe an intermediate state? (If yes, you have a bug class.)
  • Can a callback mutate the thing being iterated? (See fw-01's listener snapshot.)
  • Is the delivery batched, and on what boundary?
  • Is there a bounded-loop case like ResizeObserver, and what is the bound?

Lifetime beyond reachability

ActiveScriptWrappable — "keep me alive while I have pending activity" — appears in every system with in-flight work: connection pools, job queues, subscription managers, async iterators. And so does its guard: a teardown clause that releases everything when the owning context dies. A keep-alive without a matching teardown is a leak with extra steps.

Next

  • bi-05 explains why the wrapper exists and what crossing it costs.
  • bi-07 picks up invalidation, which is what a mutation actually schedules.
  • fw-02's DOM-operation counter measures the model this module describes.

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.

References — bi-05-bindings-v8

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

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


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-05 — Analysis

Required invariants

  1. One wrapper per object per world. Identity and expando survival depend on it.
  2. Cross-origin access is allow-listed, not filtered. Only [CrossOrigin] members pass; the default is deny.
  3. [Unforgeable] properties cannot be shadowed by page script. This is what makes reading window.location for a trust decision sound.
  4. Type coercion follows Web IDL exactly. The surprising coercions in DOM APIs are conformance, not sloppiness.
  5. Worlds are isolated. An extension content script's expandos are invisible to the page and vice versa — a security boundary, not a convention.

The four costs of crossing

Naming them separately is what makes "DOM access is slow" into a usable model:

CostWhen it dominates
Crossing — not inlineable into optimised JStight loops over many small accesses
Conversion — JS ↔ WTF representationsanything string-heavy (className, textContent)
Checks — IDL coercion + securitycross-realm access
Side effects — some getters flush style/layoutoffsetWidth, getComputedStyle, getBoundingClientRect

Cost 4 produces order-of-magnitude surprises and is invisible in a JS-only mental model. It is the reason bi-08's forced-synchronous-layout trace is the highest-value trace in the set.

Why generation rather than hand-writing

generated_in_core.gni and generated_in_modules.gni total roughly half a megabyte of filenames. The argument for generation is uniformity: thousands of interfaces each needing an unwrap, a security check, IDL-conformant coercion, and correct exception translation. No human process gets that right thousands of times.

The cost is navigability, and it is real — bi-01 Technique 4 exists because of it. This is one of the clearest "complexity that is obviously worth it" cases in the tree, and it is worth contrasting with the two-GC split, which is much less obviously worth it and exists partly for organisational reasons.

What would falsify the design

If wrappers could be recreated per access, most of this layer would disappear. They cannot, because expandos and === must survive — a requirement imposed by JavaScript semantics, not by Blink. The complexity is inherited from the language, not invented by the engine.

Execution — bi-05-bindings-v8

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


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.

Observation — bi-05-bindings-v8

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-05-bindings-v8

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Toy binding layer with wrapper map, type coercion, two worlds
  • Wrapper identity broken deliberately: expando loss and === failure both shown
  • Property access through wrapper vs plain JS measured over 1e6 iterations
  • One .idl traced to its generated C++ and to the Blink method
  • One [Unforgeable] and one [RuntimeEnabled] member found; the latter verified via flag

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-05 — Broader Ideas

Boundaries cost, everywhere

The four costs of crossing the JS↔C++ boundary — crossing, conversion, checks, side effects — generalise to every boundary in your systems:

BoundaryConversion costHidden side effect
JS ↔ DOMstring representationsforced layout
App ↔ databaseserialisation, type mappinga query, a lock
Service ↔ serviceJSON encode/decodea network round trip
Main thread ↔ workerstructured clonenone, but the clone dominates small jobs
Native ↔ WASMmemory copies at the boundarynone

Profile the boundary, not just the function. A JS flame chart cannot show you conversion cost or a style flush triggered by a getter — which is exactly the failure mode this module corrects.

Generated code as a correctness strategy

Thousands of interfaces each needing an unwrap, a security check, spec-conformant coercion, and correct exception translation. No human process gets that right thousands of times, so it is generated.

The pattern to take: when a rule must hold across hundreds of instances, generate or lint it rather than documenting it. Your equivalents: API handler boilerplate, authorisation checks, telemetry instrumentation, serialisation. If you find yourself writing "remember to always…", you have identified a codegen or lint target.

The cost is navigability, and it is real — budget onboarding for it.

Isolated worlds as an isolation primitive

Same objects, different views, no shared expandos. The same idea appears in: database schemas per tenant, namespaced caches, JS realms, container namespaces. Isolation by separate view over shared storage is cheaper than duplicating the storage and stronger than a convention.

Next

bi-11 explains why a microtask-resolved promise does not yield to rendering; fw-11 sends you back into React's source knowing why Fiber objects initialise every field in the constructor.

bi-06 — Chromium C++ for Frontend Engineers

Phase 2, just-in-time · Spec area §4. Prerequisites: bi-01. Use alongside bi-03–bi-09.

Read this module in fragments, on demand. It is a reference, not a course. The correct way to use it is: hit an idiom you cannot read → look it up here → find a real example in the tree → carry on. Front-loading a C++ course before reading Blink is the single most common way people spend three weeks and learn nothing about browsers.

Cross-track hook: none.


Why a Principal Engineer needs this

You need to read Chromium C++ fluently and write a small, idiomatic amount of it. Those are different bars, and conflating them is why people over-prepare.

  • Reading requires: ownership idioms, callbacks, threading annotations, Oilpan handles, and the ability to ignore templates you do not need.
  • Writing (a §24 rung-2/3 contribution) additionally requires: matching local style, correct handle types, and not introducing lifetime bugs. Reviewers catch the rest.

You do not need: template metaprogramming, the standard-library algorithm catalogue, move-semantics edge cases, or exception handling — Chromium builds without exceptions, which removes a large chunk of normal C++ complexity from consideration.


The reading subset

Ownership: the four pointers

This is 80 % of reading comprehension. Every pointer in Chromium answers "who owns this and how long does it live."

TypeMeaningWhere
std::unique_ptr<T>sole ownership; moved, never copiedeverywhere outside Blink's GC heap
scoped_refptr<T>shared ownership, refcounted (RefCounted<T>)//base, //cc, task runners
raw_ptr<T>non-owning pointer, hardened against use-after-freemember fields in non-GC code
T*non-owning, no guaranteeslocals, parameters
T&non-owning, non-nullparameters that must exist

raw_ptr<T> surprises people coming from older C++ or older Chromium: raw pointer members are progressively being replaced by it because it converts a large class of use-after-free vulnerabilities into crashes. When you see it, read it as "non-owning member, and someone thought about lifetime here."

In Blink's GC heap the vocabulary is different — that is bi-04's table (Member, Persistent, WeakMember, …). Mixing up the two vocabularies is the most common newcomer error. Rule of thumb: inside blink:: classes that are GarbageCollected, use Oilpan handles; everywhere else, the table above.

RAII and Scoped*

A ScopedFoo does something in its constructor and undoes it in its destructor. When reading a function, Scoped* locals are the "and afterwards, this is restored" markers — they often encode the invariant more clearly than the surrounding code. Blink's parser, style engine and compositor all use them for state that must not leak across a scope.

Callbacks

Chromium's callbacks are base::OnceCallback / base::RepeatingCallback, created with base::BindOnce / base::BindRepeating. Documented at length in docs/callback.md.

What you must be able to read:

  • BindOnce(&Class::Method, receiver, args...) — the first bound argument is the receiver.
  • OnceCallback runs once and must be moved, not copied. std::move(callback) at a call site is why.
  • base::Unretained(this) is an explicit assertion: "I promise this outlives the callback." Treat every Unretained as a lifetime claim to verify — it is where use-after-free lives.
  • WeakPtr<T> + WeakPtrFactory<T> — the callback silently does nothing if the object died. The safe default in UI code.

Reading skill: when you see a callback, ask what keeps the receiver alive. The answer is one of: ownership, scoped_refptr, WeakPtr (may not run), or Unretained (a promise).

Threading annotations

SEQUENCE_CHECKER(sequence_checker_);
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
scoped_refptr<base::SequencedTaskRunner> task_runner_;
task_runner_->PostTask(FROM_HERE, base::BindOnce(&Foo::Bar, ...));

SEQUENCE_CHECKER is executable documentation of "this object is only touched from one sequence" (bi-02). Read it as a hard constraint; violating it is exactly the bug class Blink's message-passing rule exists to avoid.

Strings

Blink uses WTF::String, AtomicString, StringView; Chromium proper uses std::string, std::u16string, std::string_view. AtomicString is interned — pointer comparison is identity comparison, which is why tag and attribute names are atoms. Conversions across the boundary allocate, and that is a real cost in the bindings layer (bi-05).

Containers and small types

  • WTF::Vector, WTF::HashMap, WTF::HashSet in Blink; base::flat_map, base::span, std::optional elsewhere.
  • base::span<T> — pointer+length view. Chromium is actively migrating raw pointer/length pairs to spans for memory safety; expect it in new code, not old.
  • std::optional<T> — "may be absent," instead of sentinel values.

Enums and the NOTREACHED() idiom

enum class everywhere. A switch over an enum ending in NOTREACHED() is a contract that the enum is exhaustive. Add an enumerator and the compiler finds the switches; NOTREACHED() catches the ones it cannot.

Generated code shapes

You will constantly read code whose definition is not in the tree (bi-01 Technique 4). Recognise the shapes:

  • mojom::FooPtr, mojo::Remote<mojom::Foo>, mojo::Receiver<mojom::Foo> — IPC endpoints.
  • V8Foo::… — generated bindings.
  • CSSPropertyID::kFoo, html_names::kDivTag — generated from .json5.

What to ignore

Template metaprogramming, constexpr machinery, SFINAE, allocator plumbing, and most of //base's internals. If a header looks like a type-level puzzle, it is almost certainly not what your question is about. Skipping confidently is a skill; bi-01's deferred-list discipline is how you do it without losing the thread.


The writing subset

For a first contribution you additionally need:

  1. Match the file you are editing. Style is enforced by git cl format — run it and stop thinking about formatting.
  2. Correct handle type. In Blink GC classes, Member<T> for owned graph edges. Getting this wrong is the most likely substantive review comment.
  3. DCHECK your assumptions. Adding a DCHECK stating your new invariant is usually welcomed, and it is how you document intent in a codebase that discourages comments restating code.
  4. Tests in the right style. Blink foo_test.cc, Chromium foo_unittest.cc. Copy the nearest existing test's structure exactly.
  5. No exceptions, no RTTI. If your instinct reaches for either, the design is off.

Deep dive: the style documents that actually govern

styleguide/c++/ is where the rules live. Sizes are informative:

DocumentSizeWhat it governs
c++-features.md~78 KBwhich C++ language/library features are allowed, banned, or under discussion
c++.md~20 KBthe general Chromium C++ style
c++-dos-and-donts.md~18 KBaccumulated guidance and anti-patterns
blink-c++.md~8 KBBlink-specific rules on top
const.md, checks.mdconst correctness; CHECK/DCHECK policy

Read c++-features.md before using any modern C++ feature. Chromium gates language features explicitly — a feature being in the standard does not mean it is permitted in the tree. Seventy-eight kilobytes of "allowed / banned / under discussion" is a governance system, not a style file, and proposing banned constructs in a CL is a fast way to burn reviewer patience.

blink-c++.md is short and its section headings are the whole message:

  • Prefer WTF types over STL and base types
  • Do not use new and delete
  • Don't mix Create() factory methods and public constructors in one class
  • Naming
  • Prefer enums or StrongAliases to bare bools for function parameters

That last one is worth adopting in your own work regardless of language. DoThing(true, false) is unreadable at the call site; DoThing(kAnimate, kDontNotify) is self-documenting. Chromium enforces at the type level what most style guides only suggest.

"Do not use new and delete" is the visible consequence of the ownership vocabulary: everything is MakeGarbageCollected<T>, std::make_unique<T>, or base::MakeRefCounted<T>. If you find yourself reaching for raw new, you have not decided who owns the object.


Deep dive: CHECK vs DCHECK, as a design decision

The distinction is not "expensive vs cheap." It is a statement about what kind of failure this is.

MacroRetained in release?Means
DCHECK(x)no"this should be true; if not, we have a bug"
CHECK(x)yes"if this is false, continuing is unsafe — crash instead"
NOTREACHED()yes"this state is impossible by construction"
DUMP_WILL_BE_CHECKstageda DCHECK being promoted to CHECK, with data collection first

The rule of thumb Chromium applies: if the invariant failing would be a security or memory-safety problem, it is a CHECK. Crashing is preferable to continuing with a violated invariant an attacker might exploit. If it would merely be a wrong pixel, it is a DCHECK.

Two things follow for you as a reader:

  1. A CHECK is a load-bearing invariant. When you find one, you have found something the authors decided was worth an outage to protect. Read it carefully before changing nearby code.
  2. DUMP_WILL_BE_CHECK is a migration in progress — someone wants this to be a CHECK but is collecting field data first to make sure it will not crash real users. It marks an invariant believed true but not yet trusted, which is genuinely useful context.

This is a pattern worth stealing: stage your assertions. Collect data that an invariant holds before you make violating it fatal.


Deep dive: the memory-safety programme, and why the code looks like it does

A great deal of modern Chromium C++ is shaped by an ongoing memory-safety effort. Recognising the pieces stops them looking like arbitrary style.

MechanismWhat it does
raw_ptr<T> (MiraclePtr)hardened non-owning member pointers; turns some UAF into a crash
base::span<T>replaces pointer+length pairs; bounds are carried with the data
PartitionAllocthe allocator, with partitioning that makes some exploitation harder
Rust interopnew, isolated, untrusted-input parsers written in a safe language
The Rule of Two (bi-02)the architectural constraint that drives sandboxing decisions
Clang pluginsmechanical enforcement of Blink/Chromium-specific rules

The span migration is the one you will notice most while reading: new code takes base::span<const uint8_t> where old code took const uint8_t*, size_t. When you see both styles in one file, you are looking at a partially-migrated area — which is also a hint that the file is actively maintained.

The reading skill: distinguishing house style from migration in progress. If you copy the pattern next to your change and it happens to be the old one, a reviewer will ask you to use the new one. Look for the newest code in the file, not the nearest.


Deep dive: reading a Mojo-generated interface without the generated code

You will constantly read code that calls into generated Mojo bindings. The shapes:

mojo::Remote<mojom::blink::FooService> remote_;      // I call the other side
mojo::Receiver<mojom::blink::FooService> receiver_;  // I implement it
mojo::PendingRemote<...> / mojo::PendingReceiver<...>  // an endpoint in transit
mojo::AssociatedRemote<...> / AssociatedReceiver<...>   // shares a pipe: ORDERING preserved

The Associated* variants matter more than their obscurity suggests. Ordinary interfaces get their own message pipes, so messages on two different interfaces have no ordering relationship (bi-02). Associated interfaces share a pipe with a parent interface, which restores ordering.

When you read AssociatedRemote, read it as: "someone was bitten by an ordering bug here."

Note also the mojom::blink:: namespace: Blink gets its own generated variant using WTF types, while the browser side uses mojom:: with STL types. The same .mojom file generates two different C++ APIs, which is why you sometimes find two types with the same name and different string types. Landing on the wrong one is a classic wasted half-hour (bi-01 Technique 3).


Deep dive: a reading checklist for an unfamiliar class

Apply in order; it takes about ten minutes and answers most of the §44 gate.

  1. Is it GarbageCollected? That decides the entire ownership vocabulary.
  2. Read Trace() first — the authoritative list of what it keeps alive.
  3. Scan the member types: Member (graph edge), raw_ptr (non-owning), unique_ptr (owned), scoped_refptr (shared), WeakPtr (may vanish).
  4. Look for SEQUENCE_CHECKER / THREAD_CHECKER — which thread does this belong to?
  5. Read the DCHECKs and CHECKs — the invariants, stated executably.
  6. Find the Create() factory or MakeGarbageCollected call sites — who constructs it, and who therefore owns its lifetime?
  7. Open the _test.cc — the enumerated edge cases.
  8. Only then read method bodies, entering from your actual question.

Steps 2 and 5 answer "what invariant does it maintain" better than any prose you would write, and they are two minutes of work.


Lab

Deliberately small — this module is not where the learning is.

  1. Pick five functions you already read in bi-03/bi-04. For each, write one sentence naming every ownership decision it makes (who owns what, what may die).
  2. Find one base::Unretained in the tree. Determine what guarantees the receiver outlives the callback. If you cannot in ten minutes, note that — say why it was hard.
  3. Find a class with SEQUENCE_CHECKER. Name the sequence it belongs to, and how you know.
  4. Find one raw_ptr<T> member and one Member<T> member. Explain why each is right in its context and what breaks if swapped.
  5. Read one generated binding end to end (bi-05). List every idiom you could not name, then look each up here.

Deliverable: a personal one-page cheat sheet of the idioms you got stuck on. That page is worth more than this module.


Further Reading (primary sources first)

  • docs/callback.md — definitive; read "Introduction" and "Quick reference for basic stuff."
  • docs/threading_and_tasks.md and _faq.md.
  • base/memory/raw_ptr.h — read the header comment for the rationale.
  • third_party/blink/renderer/platform/heap/BlinkGCAPIReference.md — Oilpan handles.
  • styleguide/c++/ in-tree — the Chromium C++ style guide and the allowed-features list. Check it before using any modern C++ feature; Chromium gates them deliberately.
  • third_party/blink/renderer/README.md §"Type dependencies".

Principal Engineer Review

  1. You see base::Unretained(this) in a callback posted to another sequence. What must be true for this to be correct, how would you verify it, and what would you propose instead?

  2. raw_ptr<T> turns some use-after-free bugs into crashes. Argue this is a security improvement; then argue a crash in production is its own outage. How does Chromium's threat model settle it?

  3. Chromium builds without exceptions. What does this simplify, what does it make more verbose, and how are error paths expressed instead?

  4. Blink has two ownership vocabularies (Oilpan and //base). Argue for unifying them. What is the actual obstacle?

  5. AtomicString makes name comparison a pointer compare. What does interning cost, and when is it the wrong choice?

  6. You are reviewing a first-time contributor's CL that adds a raw T* member to a GarbageCollected class. Write the review comment: correct, specific, not discouraging.

  7. Chromium encodes invariants in DCHECK, SEQUENCE_CHECKER, clang plugins and presubmits rather than in comments. Make the case this is better documentation than prose — and name where it fails.

  8. Which parts of modern C++ would you deliberately keep out of a large codebase you owned, and what does your list say about what you optimise for?

References — bi-06-chromium-cpp

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • docs/callback.md — definitive; read "Introduction" and "Quick reference for basic stuff."
  • docs/threading_and_tasks.md and _faq.md.
  • base/memory/raw_ptr.h — read the header comment for the rationale.
  • third_party/blink/renderer/platform/heap/BlinkGCAPIReference.md — Oilpan handles.
  • styleguide/c++/ in-tree — the Chromium C++ style guide and the allowed-features list. Check it before using any modern C++ feature; Chromium gates them deliberately.
  • third_party/blink/renderer/README.md §"Type dependencies".


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-06 — Analysis

The two bars, kept separate

BarRequires
Reading Chromium C++ownership idioms, callbacks, threading annotations, Oilpan handles, the ability to skip
Writing a landable CLthe above, plus local style, correct handle types, no lifetime bugs

Conflating them is why people over-prepare. You do not need template metaprogramming, the algorithm catalogue, or exception handling — Chromium builds without exceptions, which removes a large region of normal C++ from consideration.

Required invariants when you write

  1. Every pointer answers "who owns this." unique_ptr sole, scoped_refptr shared, raw_ptr non-owning member, Member<T> graph edge inside the GC heap.
  2. Every callback answers "what keeps the receiver alive." Ownership, scoped_refptr, WeakPtr (may not run), or Unretained (a promise you must verify).
  3. Every object with a SEQUENCE_CHECKER belongs to one sequence. Touching it elsewhere is a bug the checker will catch in a dcheck_always_on build.
  4. CHECK vs DCHECK classifies the failure. Security or memory-safety ⇒ CHECK; a wrong pixel ⇒ DCHECK.

How the codebase enforces its rules

Blink's type conventions are enforced three ways at once: DEPS, audit_non_blink_usage.py, and a clang plugin. Three-mechanism enforcement is a signal about how often people got it wrong, and about how seriously reviewers will treat it.

That is worth generalising: when you find yourself writing a convention document, ask what would make it mechanically checkable. A rule nobody can violate accidentally is worth ten rules everyone agrees with.

The reading skill that matters most

Distinguishing house style from migration in progress. raw_ptr and base::span are partially rolled out; copying the pattern next to your change may copy the old one, and a reviewer will ask you to change it. Look for the newest code in the file, not the nearest.

Execution — bi-06-chromium-cpp

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


Lab

Deliberately small — this module is not where the learning is.

  1. Pick five functions you already read in bi-03/bi-04. For each, write one sentence naming every ownership decision it makes (who owns what, what may die).
  2. Find one base::Unretained in the tree. Determine what guarantees the receiver outlives the callback. If you cannot in ten minutes, note that — say why it was hard.
  3. Find a class with SEQUENCE_CHECKER. Name the sequence it belongs to, and how you know.
  4. Find one raw_ptr<T> member and one Member<T> member. Explain why each is right in its context and what breaks if swapped.
  5. Read one generated binding end to end (bi-05). List every idiom you could not name, then look each up here.

Deliverable: a personal one-page cheat sheet of the idioms you got stuck on. That page is worth more than this module.


Observation — bi-06-chromium-cpp

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-06-chromium-cpp

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Five previously-read functions annotated with their ownership decisions
  • One base::Unretained found and its lifetime guarantee established (or the difficulty noted)
  • One raw_ptr<T> and one Member<T> explained, incl. what breaks if swapped
  • One generated binding read end to end
  • Personal one-page cheat sheet of the idioms you got stuck on

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-06 — Broader Ideas

Mechanical enforcement over documented convention

Blink enforces its type rules three ways at once: DEPS, a presubmit script, and a clang plugin. The lesson is not about C++.

A rule nobody can violate accidentally is worth ten rules everyone agrees with.

Your equivalents: lint rules over style guides, types over comments, a CI check over a checklist item, a builder API that makes invalid states unrepresentable. When you next write a convention document, ask what would make it mechanically checkable — and if the answer is "nothing," ask whether the convention is real.

Staged assertions

DUMP_WILL_BE_CHECK marks an invariant believed true but not yet trusted: collect field data first, promote to fatal later. That is a deployment pattern, not a C++ one.

Apply it to any invariant you want to enforce in a running system: log-only, measure, then enforce. Turning an assertion fatal without data is how you cause the outage you were trying to prevent.

Ownership as documentation

unique_ptr, scoped_refptr, raw_ptr, Member<T> — the type answers "who owns this and how long does it live." In languages without that vocabulary you are answering the same question in comments, which rot.

This is worth carrying into TypeScript: a type that encodes ownership or lifecycle (Owned<T>, Borrowed<T>, a disposable) makes a class of bug unrepresentable.

Next

Use this module as a reference while reading bi-04, bi-07, and bi-08 — not before them. Front-loading a C++ course is three weeks that teach nothing about browsers.

Concepts — The CSS Engine: Matching, Cascade, and Invalidation

Phase 2 · Spec areas §10 (CSS engine internals), §11 (mini CSS engine). Prerequisites: bi-01, bi-03, bi-04. Build not required.

Cross-track hook: the sibling track's CSS module covers cascade semantics; this one covers the machinery — and specifically why a classList.add does not recompute the page.


1. Why a Principal Engineer needs this

1. "Why is style recalculation 80 ms?" is a question about invalidation, not about selectors. The folk answer — "your selectors are too complex" — is usually wrong. The real answer is almost always scope: how many elements got marked dirty, and why. Without the invalidation model you cannot tell a selector problem from a scope problem, and the fixes are completely different.

2. It is the clearest example in the browser of an index built to avoid work. Blink does not re-match every rule against every element on every change. It precomputes, from the stylesheets, a set of features that could possibly invalidate an element — then uses a DOM change to look up a small candidate set. Understanding this one mechanism transfers directly to every reactive framework you will evaluate, because they are all solving the same problem with the same shape of solution.

3. CSS-in-JS, utility CSS, and design-system architecture all have measurable consequences here. Whether a change adds a stylesheet, mutates a class, or writes an inline style determines which invalidation path runs. Teams argue about these on aesthetics; you should be able to argue from mechanism.


2. Mental Model

2.1 The pipeline

CSS text
  -> tokenizer -> parser -> CSSStyleSheet (rules, selectors, declarations)
  -> indexed into RuleSet / RuleFeatureSet     <- the "compile" step
  -> for each element: collect matching rules  <- SelectorChecker
  -> cascade + inheritance -> ComputedStyle
  -> ComputedStyle feeds layout

Two things to notice immediately, because both contradict the naive model:

  • Stylesheets are compiled and indexed, not scanned. Rules are bucketed by their rightmost simple selector (id, class, tag, attribute), so matching an element consults only the buckets that could apply, not every rule.
  • Selectors are matched right-to-left. .a .b .c starts at .c and walks up. This is why the rightmost (key) selector dominates cost, and why "deep descendant selectors are slow" is a half-truth: the depth only costs when the key selector already matched.

2.2 Computed style, and why it is shared

ComputedStyle is the fully-resolved property set for an element. It is large. Blink therefore shares immutable ComputedStyle objects between elements that resolve identically, and this sharing is why a page with 10,000 similar rows does not carry 10,000 distinct style objects. Style sharing is an optimisation with observable consequences: it is defeated by things that make elements distinguishable (inline styles, sibling-position-dependent selectors, unique ids), which is one mechanism by which "harmless" markup changes regress memory and recalc time.

2.3 Invalidation is the whole subject

The question the engine must answer on every DOM change is:

Which elements' computed styles could possibly have changed?

Answering "all of them" is correct and unusably slow. Blink's answer, per its in-tree style-invalidation.md, has three parts:

  1. RuleFeatureSet — built when stylesheets are processed. For each feature that appears in a selector (a class, an id, an attribute, a pseudo-class), it records what would need invalidating if that feature changed on an element. These are invalidation sets.
  2. DOM change → pending invalidations. A mutation (say, adding class foo) looks up foo in the feature set and produces invalidation sets, stored in a PendingInvalidationsMap — deliberately not applied yet.
  3. Pushing pending invalidations. Later, the pending sets are pushed down the tree, marking the specific elements that need recalculation.

The essential design idea:

Turn "re-match everything" into "look up what this change could affect." The stylesheet is preprocessed into an index keyed by the things that can change.

Invalidation sets come in flavours — descendant (this change affects descendants matching X) and sibling (this change affects following siblings) — and there is always a fallback to whole-subtree invalidation when the analysis cannot be precise. Finding what triggers that fallback is the single most practically valuable thing in this module: those are the selectors that quietly make your page expensive.

2.4 Why classList.add is cheap and why sometimes it isn't

el.classList.add('active');
  • Look up active in the RuleFeatureSet.
  • If no rule mentions .active, nothing is invalidated. This is the common case and it is essentially free.
  • If rules mention it, schedule the corresponding invalidation sets.
  • Nothing is recomputed now. Recalculation happens at the next rendering opportunity — unless something forces a synchronous flush first (bi-04, and vertical trace #2).

The pathological cases are the ones where the analysis degrades to subtree invalidation. Selectors involving sibling combinators, :has(), and certain attribute patterns are the usual suspects. Predict which, then verify — that prediction is the lab.

2.5 Custom properties and container queries

Two features that complicate the model in instructive ways:

  • Custom properties inherit, so a change at the root can affect an arbitrary subtree. The engine needs machinery to avoid making every --foo change a full-page recalc.
  • Container queries make an element's style depend on an ancestor's layout. That is a dependency from style on layout, in a pipeline that otherwise runs style→layout. Chasing how that cycle is resolved is the single best "why is production complex?" exercise in this module — the naive design (just run style, then layout) provably cannot work.

3. Under the Hood

Derive, do not memorise. Blink's own docs are unusually good here and are the right entry point: core/css/README.md, style-calculation.md, style-invalidation.md.

ConcernWhat to look for
ParsingCSS parser + tokenizer in core/css/
Property definitionscss_properties.json5declared, not written (bi-01 Technique 4)
Keywordscss_value_keywords.json5
Rule indexingRuleSet, RuleFeatureSet
Selector matchingSelectorChecker::MatchSelector
Computed style productionElement::StyleForLayoutObject
Pending invalidation statePendingInvalidationsMap
Style storage fieldscomputed_style_extra_fields.json5 and friends

Note how much of this is generated from .json5. If you grep for a property name and find only tests, you have met bi-01 Technique 4 in its natural habitat.


3.5 Deep dive: the property table already knows what invalidates

Before you write a single line of invalidation analysis, know this: Blink records, declaratively and per property, which pipeline stage a change dirties. It is in core/css/css_properties.json5, in the invalidate: field.

{ name: "contain", ..., invalidate: ["layout"], is_animation_affecting: true }

Measured across the file (2026-08-10): 822 property entries, 311 of which declare invalidate:. The distribution of the most common declarations:

invalidate: valuePropertiesReading
["layout", "paint"]95geometry and appearance — the expensive class
["paint"]50appearance only — no geometry work
["layout"]34geometry only
["layout", "scroll-anchor"]13also disturbs scroll anchoring
["color"]10a dedicated colour-only path
["text-decoration"]8narrower than "paint"
["border-radius", "paint"]8
["reshape"]7text shaping must be redone
["transform-data", "transform-other"]5property-tree data, not paint
["compositing"]4compositor-only

Why this matters more than it looks

The folk model of web performance has three buckets — layout, paint, composite — and advises you to "prefer composite-only properties." The real vocabulary is far more granular: scroll-anchor, reshape, transform-data, has-transform, border-visual, ax-style, box-paint-property, border-outline-visited-color.

Three things follow.

1. "Does this property cause layout?" is a grep, not a debate. You can settle a design argument in thirty seconds with the tree.

2. The buckets are not equally coarse. ["text-decoration"] and ["color"] exist because repainting everything for a colour change was worth avoiding. Someone measured that. Fine-grained invalidation categories are the accumulated record of optimisations that paid off.

3. ["ax-style", ...] means accessibility has its own invalidation. The accessibility tree is a real consumer of style, maintained incrementally like everything else — not a debug view generated on demand. That single field is the best argument in the tree against treating accessibility as an afterthought: it is in the pipeline.

The exercise

Take the ten CSS properties you use most. Predict each one's invalidate: value. Then grep. Your error rate on this is a direct measurement of how good your performance intuition actually is — and most engineers who consider themselves strong on CSS performance get 3–4 wrong.

Pay attention to any property where you predicted ["paint"] and the answer includes "layout". Those are the ones costing you frames today.


3.6 Deep dive: the invalidation machinery, named

Blink's core/css/style-invalidation.md describes the mechanism in three stages. The class names are the vocabulary you need to read the code.

Stage 1 — building the index (RuleFeatureSet)

When stylesheets are processed, Blink extracts, for every feature appearing in a selector — a class, an id, an attribute, a pseudo-class — a description of what would need invalidating if that feature changed on an element. These descriptions are invalidation sets.

Think of it as inverting the stylesheet: instead of "selector → elements it matches," you build "feature → what to invalidate when it changes."

Stage 2 — DOM change → pending invalidations (PendingInvalidationsMap)

A mutation looks up its feature in the RuleFeatureSet and produces invalidation sets, stored as pending. Deliberately not applied yet — batching is what makes a thousand DOM writes cost one recalculation.

Stage 3 — pushing pending invalidations

Later, sets are pushed down the tree, marking the specific elements needing recalculation.

The flavours, and the cliff

Invalidation sets are not one kind:

  • Descendant — "if this feature changes, descendants matching X need recalc."
  • Sibling — "following siblings need recalc" (the + and ~ combinators).
  • Nth:nth-child() and friends, where a structural change shifts everyone's index.
  • Part / slotted — crossing shadow boundaries.

And there is always a fallback to whole-subtree invalidation when the analysis cannot be precise. Finding what triggers the fallback is the most practically valuable thing in this module: those selectors are the ones quietly making your page expensive.

The precision of the analysis is bounded by the expressiveness of the selector language. Every selector feature added to CSS is a new case the invalidator must either analyse precisely or give up on. :has() is the sharpest example: it inverts the matching direction, so a change to a descendant can affect an ancestor's match — the exact direction the tree walk was designed around.

Matching: SelectorChecker::MatchSelector

Selectors are matched right to left, starting from the key (rightmost) simple selector. Rules are bucketed by that key selector in the RuleSet, so matching an element consults only the buckets that could apply — by id, by class, by tag, by attribute — never the whole stylesheet.

This is why "avoid deep descendant selectors" is a half-truth: .a .b .c only walks ancestors after .c has already matched. If .c is rare, the selector is cheap regardless of depth. If the key selector is div, you have a problem no amount of shortening fixes.

Producing the value: Element::StyleForLayoutObject

Collect matched rules → cascade → resolve → ComputedStyle. The output is shared between elements that resolve identically (see below).


3.7 Deep dive: style sharing, and how you break it

ComputedStyle objects are large and immutable, and Blink shares them between elements whose style resolves identically. On a 10,000-row table this is the difference between 10,000 style objects and a handful.

Things that make elements distinguishable and therefore defeat sharing:

  • inline style attributes (each is unique),
  • unique ids referenced by a rule,
  • sibling-position-dependent selectors (:nth-child, +, ~),
  • :hover/:focus state differing per element,
  • different attribute values that some selector reads.

The design consequence. "Just use inline styles, it skips selector matching" is true and usually a net loss on a large list: you skipped matching and destroyed sharing. This is a real architectural argument in the CSS-in-JS discussion — one that is almost always conducted on ergonomics instead.

The failure mode is memory and time, and it appears as a cliff rather than a slope, which makes it hard to catch in small tests. Measure at realistic list sizes.


3.8 Deep dive: values, and the three that get confused

The cascade produces several distinct notions of "value," and mixing them up causes real bugs.

ValueWhat it isExample
Specifiedwhat the cascade selected, after inherit/initialwidth: 50%
Computedresolved as far as possible without layout50% stays 50%; empx
Usedafter layout, when geometry is known50%320px
Resolvedwhat getComputedStyle() returns — used value for some properties, computed for othersvaries per property

That last row is the trap. getComputedStyle() returns the resolved value, and whether that is computed or used depends on the property. For layout-dependent properties it is the used value — which is precisely why reading it can force layout (bi-08).

Custom properties complicate this further. They inherit, are substituted at computed-value time, and are (mostly) untyped unless registered via @property. A change to --x at the root can therefore affect an arbitrary subtree, and registration with @property is what lets the engine know a custom property's type — which is what makes it animatable and more precisely invalidatable.


3.9 Deep dive: the cycle problem — container queries

Container queries let an element's style depend on an ancestor's layout. In a pipeline that runs style → layout, that is a dependency pointing the wrong way, and it is genuinely circular: the container's size can depend on its contents, whose style depends on the container's size.

The platform breaks the cycle with containment requirements: a query container must establish containment on the queried axis (container-type: inline-size implies inline-size containment). Containment means the container's size in that axis does not depend on its contents — which severs the loop by construction rather than by iteration limit.

This is a beautiful piece of spec design and the best §46 entry in this module: the feature is only implementable because a matching restriction was added at the same time. The restriction is not a wart; it is what makes the feature possible.

Compare content-visibility: auto and contain-intrinsic-size, which apply the same idea to skip work for offscreen content: the author promises something about the subtree, and the engine uses the promise to avoid work. Same shape as {passive: true} (bi-10) and key (fw-02) — an author-supplied guarantee unlocking an optimisation the runtime could not derive.


4. Anti-Patterns

"Selector complexity is the problem." Usually it is scope. Measure how many elements were recalculated before optimising how a rule is written.

Reading getComputedStyle in a loop that also mutates. Forces a style/layout flush per iteration. Same shape as the geometry-read bug from bi-04.

Assuming inline styles are fast because they skip matching. They skip matching and defeat style sharing. On a large list this can be a net loss.

Believing every !important or specificity trick is free. They are cheap at match time; their real cost is that they make the cascade unpredictable to humans, which produces the selector sprawl that does cost.

Treating :has() as a normal selector. It inverts the matching direction and can force much broader invalidation. Use it deliberately, and measure.


5. Trade-offs

Precision of invalidation vs cost of computing it. More precise analysis means less recalculation but more work per mutation and more complexity in the feature set. Blink's fallback-to-subtree behaviour is the deliberate cutoff — worth finding and defending.

Style sharing vs simplicity. Sharing saves substantial memory and time, at the cost of a non-obvious performance cliff whenever something defeats it.

Declarative property definitions (.json5) vs hand-written code. Generation guarantees that every property gets consistent parsing, inheritance and animation handling — but makes the subsystem opaque to newcomers, and means adding a property is a build-system change.


6. Lab — mini-browser M4–M6

Build a CSS engine supporting div {}, .foo {}, #header {}, .parent .child {}.

  1. Tokenizer + parser producing a rule list.
  2. Selector representation and right-to-left matching.
  3. Specificity, cascade, inheritance, computed style.
  4. Deliberately naive first: on any DOM change, recompute every element's style. Measure on a 10,000-node tree.
  5. Then build an index. Bucket rules by key selector. Measure again.
  6. Then build invalidation sets. On classList.add(x), consult a precomputed map from class → affected-descendant descriptors. Measure again.
  7. Add a selector your analysis cannot handle precisely (a sibling combinator). Implement the subtree fallback. Measure the cliff.

Deliverable: a table of recalculated-element counts and wall time for stages 4, 5, 6 and 7, plus a written explanation of where each speedup came from. Stage 7 is the point of the lab — you must be able to state exactly which selector shapes cost you precision.


7. Failure Lab

  1. Invalidate too little. Deliberately omit sibling invalidation. Construct markup that renders incorrectly. This is the bug class the complexity exists to prevent.
  2. Invalidate too much. Fall back to whole-document invalidation always. Show it is correct and unusable.
  3. Style-sharing defeat. Add a unique inline style to every row of a 10,000-row list. Measure memory and recalc time against the shared version.
  4. The container-query cycle. Construct a case where an element's style depends on a container's size, which depends on that element's size. Predict the outcome, then check what real browsers do and find the rule that breaks the cycle.

8. Debugging Exercise

  1. DevTools → Performance: capture a classList.add that triggers recalc. Find the number of elements affected. Find where DevTools reports it.
  2. Construct two changes with identical visual effect but 100× different recalc scope. Explain the difference by selector shape alone.
  3. Trace with the blink category and locate style-recalc trace events. Correlate one back to source by grepping its literal event name (bi-01, rung 3 → rung 1).
  4. Find, in the local checkout, the code path that decides to fall back to subtree invalidation. Write down the exact condition.

9. References

  • CSS Cascading and Inheritance; CSS Selectors; CSS Containment; CSS Container Queries specs.
  • third_party/blink/renderer/core/css/README.md
  • third_party/blink/renderer/core/css/style-calculation.md
  • third_party/blink/renderer/core/css/style-invalidation.md — read this in full; it is short and it is the authoritative description of the mechanism above.
  • css_properties.json5 — read a dozen entries to learn what a property declaration contains.

10. Principal Engineer Review

  1. A team reports style recalc regressed 5×. Give three mechanisms, and the single cheapest observation that discriminates them.

  2. Explain right-to-left selector matching to a senior engineer, then explain precisely why "avoid deep selectors" is a half-truth.

  3. Invalidation sets are an index built from the stylesheets. What is the equivalent structure in a reactive UI framework, and where does the analogy break?

  4. Argue that whole-subtree invalidation fallback is a bug. Then argue it is the correct engineering decision. What evidence would settle it?

  5. Your design system is choosing between utility classes, CSS-in-JS with generated class names, and inline styles. Argue each from invalidation and style-sharing mechanics, not from developer experience.

  6. :has() was added despite known performance risk. Reconstruct the argument for shipping it. What would you have required before enabling it by default?

  7. Container queries create a style→layout→style dependency. Design a rule that makes this terminate. What does your rule forbid, and would authors notice?

  8. Custom properties inherit, so a root change can affect everything. Design the optimisation that avoids full-page recalc, and name the case where it must give up.

  9. An engineer proposes banning descendant selectors org-wide via lint. Strongest case for, strongest case against, and what you actually do.

  10. You must explain to a product manager why adding one CSS rule made the page 30 % slower to update. Five sentences, no jargon.

References — bi-07-css-engine

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • CSS Cascading and Inheritance; CSS Selectors; CSS Containment; CSS Container Queries specs.
  • third_party/blink/renderer/core/css/README.md
  • third_party/blink/renderer/core/css/style-calculation.md
  • third_party/blink/renderer/core/css/style-invalidation.md — read this in full; it is short and it is the authoritative description of the mechanism above.
  • css_properties.json5 — read a dozen entries to learn what a property declaration contains.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-07 — Analysis

Required invariants

  1. A mutation invalidates a superset of what changed, never a subset. Under-invalidation is a correctness bug (stale rendering); over-invalidation is only a performance bug. The engine is therefore permitted to fall back to whole-subtree invalidation, and never permitted to guess low.
  2. The stylesheet is compiled into an index before any element is matched. RuleFeatureSet maps feature → invalidation sets; RuleSet buckets rules by key selector.
  3. Selectors match right-to-left, from the key selector upward.
  4. Identical computed styles are shared. Anything that distinguishes elements defeats it.
  5. Nothing recomputes at mutation time. Recalculation happens at the next rendering opportunity unless something forces a synchronous flush.

The data that replaces folklore

css_properties.json5 declares invalidate: per property. Measured 2026-08-11: 822 property entries, 311 declaring invalidation.

DeclarationCount
["layout", "paint"]95
["paint"]50
["layout"]34
["layout", "scroll-anchor"]13
["compositing"]4

Roughly one property in six triggers layout. The advice "avoid changing CSS in animations" is far too coarse; the table says exactly which.

Note also ["ax-style", ...]: accessibility has its own invalidation category, meaning the accessibility tree is maintained incrementally as a first-class pipeline consumer, not generated on demand for a debugger.

Failure modes

BreakConsequence
Omit sibling invalidation+/~ selectors render stale — the bug the complexity prevents
Always fall back to subtreecorrect and unusable
Defeat style sharing (inline styles on 10k rows)memory and recalc cliff, not slope
:has() used casuallyinverts matching direction; can widen invalidation dramatically
getComputedStyle in a write loopforced synchronous flush per iteration

The cycle that had to be broken

Container queries make style depend on an ancestor's layout, in a pipeline that runs style → layout. The platform resolves it by requiring containment on the queried axis: a query container's size in that axis must not depend on its contents.

That is the pattern to name: when a declarative system risks circularity, the specification adds a restriction that makes the cycle impossible, rather than an iteration limit that makes it terminate. Restrictions keep a system analysable; limits merely keep it from hanging.

Execution — bi-07-css-engine

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


6. Lab — mini-browser M4–M6

Build a CSS engine supporting div {}, .foo {}, #header {}, .parent .child {}.

  1. Tokenizer + parser producing a rule list.
  2. Selector representation and right-to-left matching.
  3. Specificity, cascade, inheritance, computed style.
  4. Deliberately naive first: on any DOM change, recompute every element's style. Measure on a 10,000-node tree.
  5. Then build an index. Bucket rules by key selector. Measure again.
  6. Then build invalidation sets. On classList.add(x), consult a precomputed map from class → affected-descendant descriptors. Measure again.
  7. Add a selector your analysis cannot handle precisely (a sibling combinator). Implement the subtree fallback. Measure the cliff.

Deliverable: a table of recalculated-element counts and wall time for stages 4, 5, 6 and 7, plus a written explanation of where each speedup came from. Stage 7 is the point of the lab — you must be able to state exactly which selector shapes cost you precision.


7. Failure Lab

  1. Invalidate too little. Deliberately omit sibling invalidation. Construct markup that renders incorrectly. This is the bug class the complexity exists to prevent.
  2. Invalidate too much. Fall back to whole-document invalidation always. Show it is correct and unusable.
  3. Style-sharing defeat. Add a unique inline style to every row of a 10,000-row list. Measure memory and recalc time against the shared version.
  4. The container-query cycle. Construct a case where an element's style depends on a container's size, which depends on that element's size. Predict the outcome, then check what real browsers do and find the rule that breaks the cycle.

8. Debugging Exercise

  1. DevTools → Performance: capture a classList.add that triggers recalc. Find the number of elements affected. Find where DevTools reports it.
  2. Construct two changes with identical visual effect but 100× different recalc scope. Explain the difference by selector shape alone.
  3. Trace with the blink category and locate style-recalc trace events. Correlate one back to source by grepping its literal event name (bi-01, rung 3 → rung 1).
  4. Find, in the local checkout, the code path that decides to fall back to subtree invalidation. Write down the exact condition.

Observation — bi-07-css-engine

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-07-css-engine

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • mini-browser M4–M6: parser, matching, specificity, cascade, inheritance
  • Recalculated-element counts + wall time for stages 4, 5, 6 and 7
  • Subtree-fallback cliff measured; the selector shapes that cost precision named
  • Two visually-identical changes with 100x different recalc scope constructed
  • Blink's fallback condition located and written in one English sentence

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-07 — Broader Ideas

Invalidation is the general problem

RuleFeatureSet inverts the stylesheet: instead of "selector → matching elements," it precomputes "feature → what to invalidate." That is the same structure as:

SystemIndexInvalidated by
Blink styleRuleFeatureSeta class/id/attribute change
Reactive runtime (fw-03)dependency setsa signal write
Build system (fw-07)dependency grapha file change
Query cache (fw-09)key → subscribersa mutation
Databasesecondary indexa row write
CDNcache tagsa purge

Every one has the same failure modes: invalidate too little and you serve stale results; invalidate too much and you have no cache. And every one has a fallback for when precise analysis is impossible — Blink's whole-subtree invalidation is a cache purge.

Having built one, you can evaluate all of them.

Author-supplied guarantees, again

contain and content-visibility are the author telling the engine "nothing inside affects anything outside," which unlocks skipping work the engine could not otherwise skip. Container queries then require containment, because the restriction is what makes the feature implementable at all.

That is a design lesson worth stating: when a feature risks circularity, add a restriction that makes the cycle impossible rather than a limit that makes it terminate. Restrictions keep systems analysable.

Take this to your design system

Utility classes vs CSS-in-JS vs inline styles is usually argued on ergonomics. This module gives you the mechanical arguments: match cost, invalidation scope, and style sharing, which inline styles defeat. On a 10,000-row table that is a memory and recalc cliff, not a slope.

Next

bi-08 is what style feeds; bi-09 shows which property changes avoid it entirely. fw-03 builds the same invalidation idea in 200 lines of JavaScript, which is the cheapest way to understand it.

Concepts — Layout

Phase 3 · Spec areas §12 (layout internals), §13 (mini layout engine). Prerequisites: bi-04, bi-07.


1. Why a Principal Engineer needs this

1. Layout is the most expensive stage you can accidentally trigger, and the easiest to trigger accidentally. A single geometry read in the wrong place converts a linear loop into a quadratic one. Recognising that pattern in a code review is a concrete, repeated, high-value act.

2. It is where "the CSS spec says X" meets "the engine has to compute X for a million boxes." Intrinsic sizing, percentage resolution, and fragmentation are all places where the declarative model is genuinely hard to implement, and understanding why makes you far better at predicting which CSS is expensive.

3. Modern layout is a constraint-and-result architecture, and that shape is the reusable lesson. Blink's layout takes an immutable input (a constraint space), produces an immutable output (a fragment tree), and caches results keyed by the input. That is the same idea as memoisation in a UI framework — and seeing it in a C++ rendering engine makes the general principle stick.


2. Mental Model

2.1 Three trees, not one

DOM tree            (bi-04)          nodes as authored
   |
   v  (flat tree, after slotting)
ComputedStyle       (bi-07)          resolved properties per element
   |
   v
LayoutObject tree                    boxes that participate in layout
   |                                 (display:none produces none; anonymous boxes appear)
   v
Fragment tree                        immutable geometry results

The LayoutObject tree is not the DOM tree. Two differences carry most of the confusion:

  • display: none produces no layout object at all. This is why it is cheaper than visibility: hidden, and why measuring a hidden element returns zeros.
  • Anonymous boxes are synthesised to maintain the box model's invariants — for example when a block-level child appears inside an inline context. Nothing in the DOM corresponds to them. Any mental model that assumes "one element, one box" is wrong for real pages.

2.2 Constraint in, fragment out

The modern architecture:

ConstraintSpace  ──►  LayoutAlgorithm  ──►  LayoutResult { PhysicalFragment, ... }
(available size,                            (immutable geometry)
 fragmentation state,
 writing mode, ...)

Three properties, and each buys something specific:

  • Inputs are explicit. An algorithm's result depends only on its node, its style, and its constraint space — not on ambient mutable state.
  • Outputs are immutable fragments. A fragment is a value; it can be cached, reused, and referenced from paint without fear of mutation.
  • Therefore results are cacheable. If the constraint space is equivalent to last time, the cached result is reused and the subtree is not laid out again.

The reusable lesson: making inputs explicit and outputs immutable is what makes caching possible at all. The old architecture mutated boxes in place, which made "can I skip this?" unanswerable. Note that this is exactly the argument for immutability in application state management — and it is why fw-01 (mini-redux) and this module rhyme.

Historical note: these classes were once prefixed ng_ (NGConstraintSpace, NGBlockNode). The prefix was removed. Any source, tutorial, or model output using ng_ is stale — a live demonstration of why bi-01 insists on navigation over memorised paths.

2.3 What actually makes layout hard

  • Intrinsic sizing. width: max-content, flex items, and grid tracks require asking a subtree "how big would you like to be?" before deciding how big it gets. That is a second pass over the subtree, which is why intrinsic sizing can be much more expensive than a fixed size.
  • Percentage resolution. A percentage height resolves against a containing block whose height may itself be content-dependent. The spec's resolution rules exist to break this circularity, and their edge cases are the source of most "why doesn't my height work" questions.
  • Fragmentation. Pagination, multicol, and printing break a box across fragments. This is why the output is a fragment tree rather than one box per element, and why the constraint space carries fragmentation state.
  • Writing modes and direction. Everything must work in vertical writing modes and RTL, which is why the code speaks in logical terms (inline/block, start/end) rather than physical (width/height, left/right). Reading layout code without internalising the logical↔physical distinction is the main reason people bounce off it.
  • Scroll geometry. Scroll origin vs offset vs position is a genuine three-way distinction that the in-tree README.md devotes a section to, because it is repeatedly gotten wrong.

2.4 Dirty bits and forced synchronous layout

Layout, like style, is deferred. Mutations mark boxes as needing layout; the work happens at the next rendering opportunity. Reading a geometry property forces it to happen now.

for (const el of els) {
  el.style.width = el.offsetWidth + 1 + 'px';   // write, then read, then write, ...
}

Each read must flush pending style and layout, so the loop becomes O(n²)-ish in practice. The fix is batching (read all, then write all). The general principle — interleaving reads and writes of a lazily-computed value defeats the laziness — is the single most transferable idea in this module, and it is Vertical Trace #2.


3. Under the Hood

ConcernWhat to look for
Box tree nodesLayoutObject, LayoutBox, LayoutBlock
Layout entry pointthe node type that drives an algorithm (block_node.*)
Inputsconstraint_space.h (very large — read the comments, not the whole file)
Algorithmslayout_algorithm.h and the per-formatting-context algorithms
Outputsfragment builders and physical fragments
Per-mode subdirectoriesflex/, grid/, inline/, table/, svg/, mathml/
Docscore/layout/README.md, block_layout.md, block_fragmentation_tutorial.md, layout_ng.md

core/layout/README.md is one of the better subsystem documents in the tree: box model, coordinate spaces, scroll geometry, containing block vs container, and a glossary. Read it before reading any code.


3.5 Deep dive: the four coordinate spaces

core/layout/README.md names four coordinate spaces (really two, with two variants). Failing to distinguish them is the single most common reason layout code is unreadable to newcomers.

SpaceUsed byNamed with
Physicalpaint, and anything display-facingtop, right, bottom, left
Logicallayout, generalised over writing mode and directionbefore, after, start, end
Logical without inline flipping ("logical block")layout internalsLogicalLeft, LogicalRight
(+ the physical/logical variants above)

The rule: layout thinks logically, paint thinks physically. A layout algorithm written in width/height terms is broken in writing-mode: vertical-rl before anyone tests it. That is why the code says inline-size and block-size, and why reading it feels alien at first.

The README's own example is worth reproducing mentally: with writing-mode: vertical-rl; direction: ltr, the block-flow direction runs right to left, so "logical top" is on the right-hand side of the screen. Every += width you would have written is wrong.

The transferable point: this is what it costs to internationalise a geometry system properly. Not a translation layer bolted on at the edge — a different vocabulary all the way through the core. When your product says "we might need RTL later," this is the size of "later."

The box model, with the detail everyone forgets

From outside in: margin box → border box → padding box (a.k.a. client box) → content box.

The border box is "the main coordinate space of a LayoutBox" — that is the origin most layout math is relative to.

And the part that surprises people: when scrollbars are not overlay scrollbars, they are inserted between the inner border edge and the outer padding edge. So a classic scrollbar consumes space inside the border box, which is why:

  • adding content that triggers a scrollbar can reflow the whole page,
  • clientWidth and offsetWidth differ by border and scrollbar,
  • macOS (overlay scrollbars) and Windows (classic) genuinely lay out differently, which is why "it looks right on my Mac" is not evidence.

scrollbar-gutter exists precisely to let authors opt out of that instability.


3.6 Deep dive: block formatting contexts and margin collapsing

Two concepts that produce more "CSS is broken" complaints than anything else, and both are layout-engine facts rather than quirks.

Block formatting contexts (BFC)

A BFC is an independent layout region. Inside it, block boxes stack vertically and floats are contained. A new BFC is established by, among others: the root element, floats, absolutely positioned elements, display: flow-root, overflow other than visible, flex/grid items, and contain: layout.

Three classic behaviours, all one fact:

  • A float escapes its parent unless the parent establishes a BFC. overflow: hidden "fixing" it is not a hack that happens to work — it is establishing a BFC. display: flow-root is the same thing said intentionally.
  • Margins do not collapse across a BFC boundary.
  • A BFC does not overlap floats, which is the two-column float layout of the 2000s.

display: flow-root was added specifically so authors could say "make a BFC" without a side effect. A CSS feature whose entire purpose is to make an existing side effect explicit is a strong signal that the side effect was being relied upon.

Margin collapsing, in three rules

  1. Adjacent siblings — bottom margin of one collapses with top margin of the next.
  2. Parent and first/last child — collapse through if no border, padding, inline content, or BFC separates them.
  3. Empty blocks — own top and bottom margins collapse together.

Result: the largest margin wins (and negative margins subtract). This is why margin-top on a child sometimes moves the parent, which looks like a bug and is specification.

Modern layout modes — flex and grid — do not collapse margins at all. That is a deliberate break, and it is one of the strongest practical arguments for using them: you trade a subtle implicit rule for an explicit one (gap).


3.7 Deep dive: intrinsic sizing, and why it costs a pass

max-content, min-content, fit-content, flex items with flex-basis: auto, and grid tracks sized auto/min-content/max-content all require the same thing: ask a subtree how big it would like to be, before deciding how big it gets.

min-content : the smallest without overflowing (longest unbreakable word)
max-content : the size with no wrapping at all
fit-content : clamp(min-content, available, max-content)

That is a second traversal of the subtree, and it is why intrinsic sizing can be dramatically more expensive than a fixed size. Blink caches intrinsic sizes, and the cache is keyed on inputs that must be complete — the same lesson as layout-result caching.

Practical consequences:

  • A deeply nested width: max-content chain can multiply passes.
  • Tables are intrinsic-sizing-heavy by nature (column widths depend on all cells), which is a real part of why large tables are slow — not merely "many DOM nodes."
  • contain: inline-size and content-visibility help precisely because they let the engine skip the pre-pass.

Percentage resolution and the circularity it dodges

A percentage height resolves against the containing block's height. If that height is content-dependent, you have a cycle. CSS breaks it by rule: a percentage height against an auto-height containing block is treated as auto (with exceptions for flex/grid and absolutely positioned boxes).

That single rule is the answer to "why doesn't height: 100% work," which every web developer meets and few can explain. It is not arbitrary — it is a cycle-breaking rule, exactly like container queries requiring containment (bi-07 §3.9).

Notice the pattern across two modules: when a declarative system risks a circular dependency, the specification adds a restriction that makes the cycle impossible rather than an iteration limit that makes it terminate. Restrictions are how you keep a system analysable.


3.8 Deep dive: fragmentation, and why the output is a tree

Pagination, multicol, and printing break a box across fragments. That is why layout's output is a fragment tree rather than one box per element, and why ConstraintSpace carries fragmentation state (where the next break is, how much room remains).

Fragmentation forces properties on the architecture that look like over-engineering until you need them:

  • a layout algorithm must be able to stop partway and report "I got this far,"
  • it must be resumable with the remaining space,
  • geometry must be per-fragment, not per-element — hence getClientRects() returning multiple rectangles for an inline split across lines.

Most web apps never paginate. But the architecture pays for it everywhere, and this is a genuine §46 case to argue both ways: is fragmentation essential architecture, or a large permanent tax for a feature few use? (Consider that multicol and print are the same mechanism, and that "print this page correctly" is a real requirement in a great many enterprise products.)


3.9 Deep dive: scroll geometry — origin, offset, position

core/layout/README.md devotes a section to distinguishing scroll origin vs offset vs position, which tells you people get it wrong.

The short version: in left-to-right writing modes the maximum scroll position and the scroll offset coincide, so the distinction never bites. In RTL and vertical writing modes they diverge, because the scroll origin is not at the top-left of the overflow area.

This is why cross-browser RTL scroll code was historically a nightmare (browsers disagreed on whether scrollLeft was negative, zero, or positive at the start position) and why scrollIntoView and scroll restoration have subtle behaviour there.

If your product has RTL users, this section of the README is worth reading in full — it is one of the few places where the engine's internal vocabulary directly predicts a class of user-visible bug.

Scroll anchoring

When content above the viewport changes size, the browser adjusts the scroll offset to keep the visually-anchored element stable. Recall from bi-07 that 13 CSS properties declare invalidate: ["layout", "scroll-anchor"] — scroll anchoring is a first-class invalidation consumer, not a heuristic bolted on.

This is the built-in version of what fw-10's virtualized list must implement by hand, and comparing the two is the point of that module.


3.10 Numbers and anchors

FactValueConsequence
Coordinate spaces in layout/paint4why the code reads oddly
Layout subdirectories by formatting contextflex/, grid/, inline/, table/, svg/, mathml/, …each is a distinct algorithm
constraint_space.h~69 KBthe inputs alone are that complex
layout_box.cc~165 KBthe box is the workhorse
Properties invalidating layout (bi-07)34 layout-only + 95 layout+paint129 of 822

That last row is the one to quote in a design review: roughly one CSS property in six triggers layout. Most do not. The folk advice "avoid changing CSS in animations" is far too coarse — the table tells you exactly which ones.


4. Anti-Patterns

Reading geometry inside a write loop. The canonical bug.

Assuming one element = one box. Anonymous boxes, fragments, and display:none all break it.

Using offsetWidth when you wanted layout-independent information. If you only need to know whether an element is visible, there are cheaper answers.

Animating layout-affecting properties. width, top, margin force layout every frame. transform and opacity do not (bi-10).

Reasoning in physical terms in a global product. If your mental model is left/right and width/height, you will write code that breaks in RTL and vertical writing modes — the same mistake the engine deliberately designs against.


5. Trade-offs

Cacheable, immutable results vs memory. Fragments are allocated rather than mutated in place. The payoff is skippable subtrees; the cost is allocation and retention.

Generality vs speed. Supporting fragmentation, writing modes, and every formatting context in one architecture means the fast common case pays some tax. Find where fast paths exist and ask what they assume.

Spec fidelity vs predictability. The spec's percentage and intrinsic-sizing rules are complex because they resolve circular dependencies. A simpler rule would be easier to teach and would break real layouts.


6. Lab — mini-browser M7–M9

Input: DOM + computed style (from bi-07). Output: layout tree + geometry.

  1. Layout tree construction: skip display:none, synthesise an anonymous box for at least one case.
  2. Block layout: width from containing block, height from content, margins/padding/border.
  3. Nested boxes and margin behaviour. Implement margin collapsing, then write down the three rules you had to encode.
  4. Inline layout: line boxes, text measurement, line breaking. Use a fixed-width font metric so it is deterministic.
  5. Basic flex: flex-direction: row, flex-grow, flex-basis.
  6. Intrinsic sizing: implement max-content for a subtree. Observe that you now need a second pass.
  7. Dirty-layout tracking + result caching. Give each box a constraint-space-equivalent key; skip relayout when the key is unchanged. Measure the hit rate on a resize.

Deliverable: a measurement of stage 7's cache hit rate under (a) a window resize, (b) a single deep text change, and a written explanation of why the two differ so much.


7. Failure Lab

  1. Forced synchronous layout. Build the read/write loop. Measure at n = 100/1000/5000 and plot. Then batch and re-measure. Name the complexity class of each.
  2. Break the cache key. Make your constraint-space key omit one input (say, available inline size). Find markup that now renders wrong. This demonstrates precisely why caching requires complete inputs.
  3. Mutate a fragment after publishing it. Show a downstream consumer reading stale or inconsistent geometry. This is the bug immutability prevents.
  4. Physical-thinking bug. Hard-code left/right somewhere, then run your engine in RTL.

8. Debugging Exercise

  1. In DevTools, find layout events for a forced synchronous layout; confirm the count matches your loop's iterations.
  2. Construct two DOM changes with the same visual result, one triggering layout and one not. Explain by property.
  3. In the checkout, find where a cached layout result is reused, and the exact condition under which it is not. Write the condition in one English sentence.
  4. Find an anonymous box being created. What invariant made it necessary?

9. References

  • CSS Display, Box Model, Sizing, Flexbox, Grid, Writing Modes, Fragmentation specs.
  • third_party/blink/renderer/core/layout/README.md — box model, coordinate spaces, scroll geometry, glossary. Read first.
  • core/layout/block_layout.md, block_fragmentation_tutorial.md, layout_ng.md.
  • Life of a Pixel for the pipeline context.

10. Principal Engineer Review

  1. Explain forced synchronous layout to a senior engineer without using the word "reflow", and give the code smell that predicts it.

  2. Blink's layout takes immutable inputs and produces immutable outputs. Name the specific capability this buys, and the equivalent decision in application state management.

  3. display:none vs visibility:hidden vs content-visibility — compare by which pipeline stages each skips, and give a case where the cheapest one is the wrong choice.

  4. Intrinsic sizing requires a pre-pass. Design an API that would let authors opt out. What would break?

  5. Layout code speaks in logical rather than physical terms. Argue this was worth the readability cost. What would you do in a codebase you own that has no i18n requirement — and how confident are you that it never will?

  6. A team wants to animate a list reorder. Compare a layout-driven implementation with a transform-driven one, in terms of pipeline stages per frame, and say when the expensive one is nonetheless correct.

  7. Fragmentation exists for pagination and multicol — features few sites use. Argue for removing support; then argue that the architecture is better for having it.

  8. You must decide whether a content-visibility: auto rollout is safe across a large app. What do you measure, and what would make you stop?

  9. Two engineers disagree: one says the grid layout is slow because of selector complexity, the other because of intrinsic sizing. Design the experiment that settles it in an hour.

  10. Layout results are cached by their inputs. Describe a bug where the cache key is incomplete, how it would present to a user, and why it would be hard to reproduce.

References — bi-08-layout

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • CSS Display, Box Model, Sizing, Flexbox, Grid, Writing Modes, Fragmentation specs.
  • third_party/blink/renderer/core/layout/README.md — box model, coordinate spaces, scroll geometry, glossary. Read first.
  • core/layout/block_layout.md, block_fragmentation_tutorial.md, layout_ng.md.
  • Life of a Pixel for the pipeline context.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-08 — Analysis

Required invariants

  1. A layout result depends only on its node, its style, and its constraint space. No ambient mutable state — which is what makes results cacheable at all.
  2. Fragments are immutable. They can be referenced from paint without defensive copying.
  3. The cache key must be complete. An omitted input produces a wrong render with no error.
  4. Layout is expressed logically. inline/block, start/end — physical thinking breaks in RTL and vertical writing modes.
  5. Geometry is per-fragment. One element may produce several; getClientRects() returns more than one rectangle for a split inline.

Why caching is the whole architecture

The old architecture mutated boxes in place, which made "can I skip this subtree?" unanswerable. Explicit inputs plus immutable outputs turn that into a key comparison.

This is the same enabling condition as immutable state in a store (fw-01) and immutable display items (bi-09): you cannot cache what can change underneath you.

Failure modes

BreakConsequenceWhere it shows
Incomplete constraint-space keystale geometry, no errorresize, container queries
Read geometry inside a write loopO(n²) forced synchronous layoutthe classic
Assume one element = one boxwrong for anonymous boxes, fragments, display:nonetables, multicol
Physical coordinatesbreaks in RTL / verticali18n rollout
Animate width/toplayout every frameany animation

The costs that are structural, not accidental

  • Intrinsic sizing needs a pre-pass. max-content, flex bases, and auto grid tracks all require asking a subtree its preferred size before deciding its actual one. Tables are intrinsic-sizing heavy by nature, which is a real part of why large tables are slow — not merely node count.
  • Percentage resolution against an auto-height containing block resolves to auto. This is a cycle-breaking rule, and it is the answer to "why doesn't height: 100% work" — the single most asked CSS question with the least satisfying folk answer.
  • Fragmentation forces resumable algorithms. Most apps never paginate, and the architecture pays for it everywhere. A genuine §46 case to argue both ways.

Scrollbars are a layout input

With non-overlay scrollbars, the scrollbar sits between the inner border edge and the outer padding edge — inside the border box. So triggering a scrollbar reflows content, and macOS (overlay) and Windows (classic) genuinely lay out differently. "It looks right on my Mac" is not evidence, and scrollbar-gutter exists to buy back the stability.

Execution — bi-08-layout

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


6. Lab — mini-browser M7–M9

Input: DOM + computed style (from bi-07). Output: layout tree + geometry.

  1. Layout tree construction: skip display:none, synthesise an anonymous box for at least one case.
  2. Block layout: width from containing block, height from content, margins/padding/border.
  3. Nested boxes and margin behaviour. Implement margin collapsing, then write down the three rules you had to encode.
  4. Inline layout: line boxes, text measurement, line breaking. Use a fixed-width font metric so it is deterministic.
  5. Basic flex: flex-direction: row, flex-grow, flex-basis.
  6. Intrinsic sizing: implement max-content for a subtree. Observe that you now need a second pass.
  7. Dirty-layout tracking + result caching. Give each box a constraint-space-equivalent key; skip relayout when the key is unchanged. Measure the hit rate on a resize.

Deliverable: a measurement of stage 7's cache hit rate under (a) a window resize, (b) a single deep text change, and a written explanation of why the two differ so much.


7. Failure Lab

  1. Forced synchronous layout. Build the read/write loop. Measure at n = 100/1000/5000 and plot. Then batch and re-measure. Name the complexity class of each.
  2. Break the cache key. Make your constraint-space key omit one input (say, available inline size). Find markup that now renders wrong. This demonstrates precisely why caching requires complete inputs.
  3. Mutate a fragment after publishing it. Show a downstream consumer reading stale or inconsistent geometry. This is the bug immutability prevents.
  4. Physical-thinking bug. Hard-code left/right somewhere, then run your engine in RTL.

8. Debugging Exercise

  1. In DevTools, find layout events for a forced synchronous layout; confirm the count matches your loop's iterations.
  2. Construct two DOM changes with the same visual result, one triggering layout and one not. Explain by property.
  3. In the checkout, find where a cached layout result is reused, and the exact condition under which it is not. Write the condition in one English sentence.
  4. Find an anonymous box being created. What invariant made it necessary?

Observation — bi-08-layout

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-08-layout

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • mini-browser M7–M9: layout tree, block layout, inline text, basic flex
  • Margin collapsing implemented; the three rules written down
  • Intrinsic sizing implemented; the second pass observed
  • Result caching implemented; hit rate measured for resize vs deep text change
  • Incomplete cache key produces a wrong render — demonstrated
  • Forced sync layout measured at n=100/1000/5000 and plotted

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-08 — Broader Ideas

Explicit inputs make caching possible

Layout's architecture — immutable inputs, immutable outputs, results keyed on inputs — is the same move as pure functions plus memoisation, and the same move as immutable state plus reference equality (fw-01).

The generalisation: you cannot cache what can change underneath you, and you cannot key a cache on inputs you have not enumerated. Every incomplete-key bug in this curriculum has the same signature: stale output, no error.

Internationalisation is an architecture decision, not a feature

Layout speaks logically (inline/block, start/end) all the way through the core, because retrofitting RTL and vertical writing modes into a physically-expressed engine is not possible.

When your product says "we might need RTL later," this is the size of later. The cheap version of the lesson: use logical CSS properties now (margin-inline-start, not margin-left). It costs nothing today and is the difference between a week and a quarter when the requirement arrives.

Cycle-breaking by rule

"A percentage height against an auto-height containing block resolves to auto" is the answer to the most-asked CSS question, and it is a cycle-breaking rule rather than an arbitrary one. Container queries break their cycle with containment. ResizeObserver bounds its loop.

Three different resolutions to circular dependency, in one platform: restrict the input, restrict the dependency, or bound the iteration. Worth having all three in mind when you design a system where A depends on B depends on A.

Next

bi-09 shows what layout hands to paint and which changes skip layout entirely. fw-10 makes you implement scroll anchoring by hand, which is the fastest way to appreciate that the browser does it for you.

Concepts — Paint, Display Items, and Property Trees

Phase 3 · Spec area §14. Prerequisites: bi-07, bi-08.


1. Why a Principal Engineer needs this

1. "Paint" is where the folk model is most wrong. Most engineers believe paint means "drawing pixels." It does not: Blink's paint stage produces a display list — a recording of drawing commands — and rasterisation into pixels happens later, elsewhere, possibly on another thread or process. Once you know this, "why did my change cause repaint but not relayout, and why was it still cheap?" becomes answerable.

2. Property trees are the data structure that makes compositing tractable, and they are the bridge between this module and bi-10. Understanding them is what lets you predict whether an effect can be handled by the compositor alone.

3. Hit testing lives here. Every "why did my click land on the wrong element" question is a paint-order and hit-test question, and paint order is not DOM order.


2. Mental Model

2.1 Paint produces a recording, not pixels

Layout (fragments + geometry)
   |
   v  PRE-PAINT: build/update property trees, compute paint invalidation
   |
   v  PAINT: walk in paint order, emit display items
   |
   v  Display list, grouped into paint chunks
   |
   v  COMMIT to the compositor            <-- bi-10 takes over here
   |
   v  RASTER (tiles) -> GPU -> pixels

Paint is cheap relative to raster because it is recording, not drawing. A display item is "draw this rect with this paint," not a bitmap.

2.2 Paint order is not DOM order

Paint order is defined by the CSS painting model: stacking contexts, z-index, positioning, and the specified ordering of backgrounds, floats, inlines, and positioned descendants. A stacking context is an atomic unit — its descendants cannot be interleaved with content outside it, which is exactly what makes z-index inside a stacking context local.

Properties that create stacking contexts (opacity < 1, transform, filter, will-change, isolation, and others) therefore change structure, not just appearance. This is why adding opacity: 0.999 "fixes" a z-index bug — and why doing so is a smell rather than a fix.

2.3 Property trees

Rather than baking transforms, clips and effects into each display item, Blink maintains separate trees:

  • Transform tree — the transform hierarchy.
  • Clip tree — clipping regions.
  • Effect tree — opacity, filters, masks, blend modes.
  • Scroll tree — scrollable regions and their offsets.

Each paint chunk references nodes in these trees. The payoff is decisive:

If a change only alters a property-tree node, the display list does not need to be re-recorded, and rasterised tiles do not need to be redrawn. The compositor can apply the new value to already-rastered content.

That is the entire mechanical basis for "animate transform and opacity, not top and width." It is not folklore and it is not about GPUs being fast — it is that those two properties are representable as property-tree changes, so the pipeline can skip paint and raster.

2.4 Paint invalidation

Like style and layout, paint is invalidated rather than recomputed eagerly. Pre-paint walks the tree, updates property trees, and determines what must be repainted. The interesting question — and the lab — is what scope a given change invalidates, and which changes invalidate nothing at all.

2.5 Hit testing

Hit testing walks the painted representation in reverse paint order to find the topmost element at a point. Consequences: pointer-events, overlapping stacking contexts, and transforms all affect hit testing, and the answer can differ from what the DOM tree suggests. Compositor-side hit testing also matters for scroll (bi-10), because the compositor must decide without the main thread whether it can handle an input.


3. Under the Hood

ConcernWhat to look for
Paint entry, painters per box typecore/paint/
Display items and the display listdisplay item + list types in platform/graphics/ and core/paint/
Paint chunkschunk types alongside the display list
Property treesproperty-tree node types; pre-paint tree builder
Paint invalidationthe paint invalidator
Hit testinghit-test request/result types

core/paint/README.md (~30 KB) is the authoritative description; read it rather than inferring the architecture from code.


3.5 Deep dive: PrePaint does two jobs

core/paint/README.md names the phase precisely. PrePaintTreeWalk walks the whole layout tree, from the root FrameView, across frame boundaries, in-order — and the README explains why in-order matters: it lets the walk efficiently compute DOM-order hierarchy such as the parent containing block.

It has exactly two goals:

  1. Paint invalidation — mark what must be painted differently from the cached painting.
  2. Building paint property trees — transform, clip, effect, scroll.

Paint invalidation, mechanically

Before PrePaint, objects are marked as needing invalidation checking by style change, layout change, compositing change, and so on. PrePaint then traverses marked subtrees in pre-order and invalidates the display item clients that would generate different display items.

The machinery: a root PaintInvalidatorContext is created for the LayoutView; each visited object gets one derived from its parent's, tracking the painting layer that will initiate its painting. PaintInvalidator initialises the context and calls LayoutObject::InvalidatePaint(), which dispatches to a type-specific invalidator such as BoxPaintInvalidator.

Notice the shape. Mark-then-walk-then-invalidate is the same two-phase structure as style invalidation (bi-07: features → pending invalidations → push) and dirty-layout tracking (bi-08). Three subsystems, one pattern: record cheaply during mutation, resolve precisely once per frame. If you take a single architectural idea from the rendering pipeline, take that one — it is what makes the whole thing incremental.


3.6 Deep dive: display items, chunks, and the PaintController

Paint walks the PhysicalFragment tree (bi-08's output) in paint order and produces display items via static painter classes such as BoxFragmentPainter, appending to a PaintController.

Two facts from the README that carry a lot of weight:

  • There is only one PaintController for the entire LocalFrameView. Painting is not per-element bookkeeping; it is one list for the frame.
  • The controller segments the display item list into PaintChunks: sequential display items that share a common property tree state.

That second definition is the one to memorise, because it explains why property trees exist at all:

display item, display item, display item   ← same transform/clip/effect  ─┐
display item, display item                 ← different clip              ─┤ chunks
display item                               ← different transform         ─┘

A chunk is precisely "a run of drawing that shares the same answers to where, clipped by what, and with what effect." Change a transform node and you change a chunk's property state, not its contents — so nothing has to be re-recorded and nothing re-rastered. That is the entire transform/opacity fast path, stated in terms of the data structure.

Two layers of paint caching

The README describes both, and they operate at different granularities:

LayerMechanismSkips
Display item cachingif a painter would create a DrawingDisplayItem identical to last time, reuse itone item
Subsequence cachingSubsequenceRecorder in PaintLayerPainter::PaintContents() records all items in a scope; if the layer would produce identical items, reuse the whole runan entire layer

Subsequence caching is the interesting one: it is a memoisation of a subtree's paint output, keyed on "nothing that affects this layer changed." Same idea as layout-result caching (bi-08) and computed in a reactivity system (fw-03) — and the same failure mode if the key is incomplete.

You have now seen result-caching-keyed-on-complete-inputs in three engine subsystems. When fw-03 asks you to reason about computed invalidation, that is not an analogy; it is the same problem at a different scale.


3.7 Deep dive: what creates a stacking context

Paint order is not DOM order, and the list of things that create a stacking context is longer than most engineers expect. A non-exhaustive but practical list:

  • position other than static with a z-index other than auto
  • position: fixed or sticky (always)
  • opacity less than 1
  • transform, scale, rotate, translate, perspective other than none
  • filter, backdrop-filter, mask, clip-path other than none
  • mix-blend-mode other than normal
  • isolation: isolate
  • will-change naming any property that would create one
  • contain: paint, contain: layout, content-visibility other than visible
  • flex/grid items with z-index other than auto
  • view-transition-name other than none

The practical consequence: many properties applied for purely visual reasons change paint structure. opacity: 0.99 "fixing" a z-index bug is the canonical example — it works because it creates a stacking context, which is a structural change disguised as an aesthetic one.

The debugging heuristic: when z-index "doesn't work," the element is almost always being compared against siblings inside a stacking context you did not know existed. Find the nearest ancestor with any property from that list.


3.8 Deep dive: pixel snapping and why edges look wrong

The paint README has a whole section on pixel snapping and bluriness, which tells you it is a recurring source of bugs.

Layout works in LayoutUnit, a fixed-point type with sub-pixel precision (1/64 px). Paint must eventually produce device pixels. The gap between them produces:

  • Blurry text or borders when a box lands on a fractional device pixel — most visible on non-integer devicePixelRatio (1.25, 1.5) which is extremely common on Windows laptops.
  • Off-by-one seams between adjacent boxes when each is snapped independently and they round in different directions.
  • A 1px line that renders as 2px of grey rather than 1px of black.

Why sub-pixel layout at all? Because integer layout accumulates error across many boxes: 100 boxes each rounded up by 0.4px is a 40px drift. Sub-pixel layout keeps the positions accurate and snaps only at paint time.

The practical guidance:

  • Snapping happens at paint, so investigating "why is this blurry" means looking at the paint-time transform, not the CSS.
  • A fractional transform: translate() on an ancestor moves everything below onto fractional positions — this is why an animation can make an entire subtree blurry mid-flight and crisp at rest.
  • will-change: transform promotes to a layer that may be rastered at a fixed scale, which is a separate cause of blurriness during scaling animations.

3.9 Deep dive: hit testing, and the compositor's copy

Hit testing walks the painted representation in reverse paint order to find the topmost element at a point. Everything that affects paint order affects hit testing, including transforms, which is why a visually-moved element is clickable in its new location.

The part that is easy to miss: the compositor needs to hit test too. When input arrives (bi-10), the compositor thread must decide without the main thread whether it can handle the event — for example, whether the point is inside a scroller with no blocking listeners. So paint produces hit test data for the compositor alongside display items.

Two consequences:

  • A non-passive listener on a large region degrades the compositor's ability to answer, which is the mechanism behind bi-10's scroll advice.
  • Elements with pointer-events: none are excluded from hit test regions, which is why it is an effective (if blunt) fix for an invisible overlay eating clicks.

3.10 The property trees, enumerated

Four trees, each answering one question about a paint chunk:

TreeQuestionChanged by
Transformwhere is it?transform, scroll offsets, device scale
Clipwhat is it clipped to?overflow, clip-path, border-radius clipping
Effecthow is it composited?opacity, filter, mask, mix-blend-mode
Scrollwhich scroller moves it?scroll containers

They are separate trees, not one tree of composed state, and that separation is the design. A scroll changes one node in the scroll tree; a fade changes one node in the effect tree. If these were baked into display items, every scroll would re-record the world.

Recall bi-07's data: only 4 properties declare invalidate: ["compositing"], and 5 declare transform-data/transform-other. The set of genuinely cheap-to-animate properties is small, specific, and enumerable — not a vibe.


4. Anti-Patterns

"Paint = pixels." It is a recording.

Animating top/left/width/height. Forces layout → paint → raster every frame.

will-change everywhere. It creates stacking contexts and compositing layers, which cost memory and can reduce performance. It is a hint with a real price.

Assuming z-index is global. It is scoped to the stacking context.

Debugging click-target bugs in the DOM inspector alone. The answer is usually in paint order or a transformed ancestor.


5. Trade-offs

Property trees vs baking properties into display items. Separate trees add indirection and a whole subsystem to maintain, and they are what makes cheap compositor-only updates possible. This is one of the clearest "complexity that pays for itself" cases in the renderer — and the right §46 entry for this module.

Record-then-raster vs draw directly. Recording allows the raster to happen off the main thread, at a different scale, and to be reused. It costs an intermediate representation and the machinery to invalidate it.

More compositing layers vs fewer. Each layer avoids repaint but costs memory and composition time. There is no universally right answer, which is why the browser uses heuristics — and why author hints like will-change can make things worse.


6. Lab — mini-browser M10–M11

  1. Display list. Walk your layout tree in paint order and emit display items ({type, rect, color, …}). Do not draw yet.
  2. Stacking contexts. Implement z-index and at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule.
  3. Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
  4. Raster. Draw the display list to a canvas.
  5. The payoff experiment. Animate a subtree two ways: (a) by changing layout position and re-recording, (b) by changing only a transform node. Measure both. Report how much of the pipeline each skips.
  6. Hit testing. Implement reverse-paint-order hit testing and find a case where it disagrees with DOM order.

Deliverable: the stage-5 measurement, plus a written statement of exactly which stages were skipped in case (b) and why they could be skipped.


7. Failure Lab

  1. Bake transforms into display items. Re-run the stage-5 experiment. Show that the cheap path is now impossible. This is the strongest possible argument for property trees.
  2. Break paint order. Paint in DOM order instead. Find markup that renders wrong.
  3. Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
  4. Layer explosion. Put will-change: transform on 5,000 elements. Measure memory. Explain why the "optimisation" lost.

8. Debugging Exercise

  1. DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
  2. Compare a top-animated element with a transform-animated one in a Perfetto trace. Name the stages present in one and absent in the other.
  3. Find a hit-test bug you can only explain via stacking contexts.
  4. In the checkout, find where a property-tree-only change avoids repaint. Quote the condition.

9. References

  • CSS 2 Appendix E (painting order), CSS Positioned Layout, CSS Transforms, CSS Filter Effects, CSS Compositing and Blending.
  • third_party/blink/renderer/core/paint/README.md — primary.
  • Life of a Pixel.

10. Principal Engineer Review

  1. Explain to a senior engineer why transform animations are cheap, without saying "GPU."

  2. Property trees add a whole subsystem. Reconstruct the argument that justified them, and name what would be impossible without them.

  3. will-change is a hint with a cost. Write the guidance you would give your organisation — specific enough to act on, without becoming a rule people cargo-cult.

  4. Paint produces a display list rather than pixels. Name three capabilities this enables.

  5. A designer wants a blur-heavy UI. Predict the pipeline consequences, and say what you would measure before agreeing.

  6. Stacking contexts make z-index local. Argue this is good design; then describe the most common way it confuses engineers and how you would teach around it.

  7. Hit testing walks paint order, not DOM order. Give a realistic accessibility or UX bug this causes, and how you would detect it systematically.

  8. The browser decides compositing layers heuristically. Argue for exposing full manual control to authors; then argue against. What do you actually want?

  9. Your app drops frames only while scrolling on low-end Android. Enumerate paint- and raster-side causes, and the order you would investigate them.

  10. You are asked whether a design system should ban opacity transitions on large surfaces. Answer with mechanism, and state what evidence would change your answer.

References — bi-09-paint

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • CSS 2 Appendix E (painting order), CSS Positioned Layout, CSS Transforms, CSS Filter Effects, CSS Compositing and Blending.
  • third_party/blink/renderer/core/paint/README.md — primary.
  • Life of a Pixel.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-09 — Analysis

Required invariants

  1. Paint produces a recording, not pixels. Display items are drawing commands; raster happens later, elsewhere, possibly on another thread or process.
  2. A paint chunk is a run of display items sharing one property tree state. That definition is what makes property-tree-only updates cheap.
  3. Paint order is not DOM order. Stacking contexts are atomic; z-index is scoped to them.
  4. Cached paint output is reused when nothing that affects it changed — at display-item and at subsequence granularity.
  5. Hit testing walks the painted representation in reverse paint order, and the compositor needs its own copy of hit-test data to answer input without the main thread.

The mechanism behind the advice

"Animate transform and opacity" is usually justified by "the GPU is fast." That explanation is wrong and it mispredicts.

The correct chain:

transform/opacity change  →  a property-tree node value changes
                          →  display list unchanged, so no re-record
                          →  tiles unchanged, so no re-raster
                          →  compositor produces a frame from existing tiles
                          →  the main thread is not involved

Stages were skipped; a processor was not made faster. This predicts the exceptions correctly: filter: blur() is also composited and still expensive, because the effect itself costs GPU work per frame. A hardware explanation cannot tell you that.

Failure modes

BreakConsequence
Bake transforms into display itemsthe cheap animation path becomes impossible
Paint in DOM orderoverlapping content renders wrong
Skip paint invalidation for a propertystale pixels
will-change on thousands of elementsmemory blow-up; the "optimisation" loses
Independent snapping of adjacent boxes1px seams, blurry borders

Sub-pixel layout, integer pixels

Layout uses LayoutUnit (fixed point, 1/64 px); paint must produce device pixels. Integer layout would accumulate error — 100 boxes each rounded up 0.4px is a 40px drift — so positions stay precise and snapping happens at paint time.

That is why "why is this blurry" is a paint-time question about the effective transform, not a CSS question, and why a fractional ancestor transform can make a whole subtree blurry mid-animation and crisp at rest.

The recurring structure

Mark-then-walk-then-invalidate appears here (PrePaintTreeWalk), in style (bi-07), and in layout (bi-08). Three subsystems, one pattern: record cheaply during mutation, resolve precisely once per frame. It is what makes the pipeline incremental, and it is why interleaving a read into a write loop is catastrophic rather than merely slow.

Execution — bi-09-paint

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


6. Lab — mini-browser M10–M11

  1. Display list. Walk your layout tree in paint order and emit display items ({type, rect, color, …}). Do not draw yet.
  2. Stacking contexts. Implement z-index and at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule.
  3. Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
  4. Raster. Draw the display list to a canvas.
  5. The payoff experiment. Animate a subtree two ways: (a) by changing layout position and re-recording, (b) by changing only a transform node. Measure both. Report how much of the pipeline each skips.
  6. Hit testing. Implement reverse-paint-order hit testing and find a case where it disagrees with DOM order.

Deliverable: the stage-5 measurement, plus a written statement of exactly which stages were skipped in case (b) and why they could be skipped.


7. Failure Lab

  1. Bake transforms into display items. Re-run the stage-5 experiment. Show that the cheap path is now impossible. This is the strongest possible argument for property trees.
  2. Break paint order. Paint in DOM order instead. Find markup that renders wrong.
  3. Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
  4. Layer explosion. Put will-change: transform on 5,000 elements. Measure memory. Explain why the "optimisation" lost.

8. Debugging Exercise

  1. DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
  2. Compare a top-animated element with a transform-animated one in a Perfetto trace. Name the stages present in one and absent in the other.
  3. Find a hit-test bug you can only explain via stacking contexts.
  4. In the checkout, find where a property-tree-only change avoids repaint. Quote the condition.

Observation — bi-09-paint

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-09-paint

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • mini-browser M10–M11: display list, stacking contexts, transform tree, raster
  • Stage-5 experiment: re-record vs property-tree-only change, measured
  • Baked-transform variant proves the cheap path impossible
  • Reverse-paint-order hit testing disagrees with DOM order in a constructed case
  • Layer explosion measured (5,000 x will-change)

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-09 — Broader Ideas

Record, then execute

Paint produces a display list rather than pixels. That indirection buys: rastering off the main thread, rastering at a different scale, reusing output, and applying property changes without re-recording.

The same pattern, elsewhere:

SystemRecordingExecutor
Blink paintdisplay listraster / GPU
CompositorCompositorFrame (quads)viz / DirectRenderer
Virtual DOMelement treereconciler
SQLquery planexecution engine
Graphics APIscommand bufferGPU

Separating description from execution is what makes optimisation, caching, and relocation possible. When you find yourself unable to move work off a thread or cache it, ask whether the work is expressed as instructions or as immediate effects.

Structure disguised as style

opacity: 0.99 creating a stacking context is the canonical example of a visual property with a structural consequence. The list of stacking-context triggers is long and includes properties people add for aesthetics.

The general caution: in declarative systems, some properties change the evaluation structure, not just the output. Knowing which is the difference between debugging z-index in ten seconds and in an afternoon.

Take this to your animation guidance

Replace "animate transform and opacity because the GPU is fast" with the pipeline explanation. It predicts the exceptions correctly — filter: blur() is composited and still expensive — and a hardware explanation cannot.

Next

bi-10 takes the display list and produces frames; fw-10 is where paint cost and DOM size meet in application code.

Concepts — Compositor and GPU

Phase 4 · Spec area §15. Prerequisites: bi-09.


1. Why a Principal Engineer needs this

1. This is the layer that decides whether your page feels smooth while the main thread is busy. Compositor-driven scrolling and compositor-driven animations are the reason a janky page still scrolls — and the reason a single non-passive listener can destroy that.

2. The frame budget is not yours. 16.7 ms at 60 Hz is the whole budget, shared between your JS, style, layout, paint, commit, raster, and the GPU's own work. Engineers who believe they have 16 ms of script time consistently ship jank.

3. It is where "it's fast on my machine" dies. Raster and GPU costs scale with pixels and device capability, not with your code's complexity. A retina laptop and a low-end phone differ by an order of magnitude in exactly this stage.


2. Mental Model

2.1 Two threads, two trees

RENDERER MAIN THREAD                 COMPOSITOR (impl) THREAD
  style, layout, paint      commit
  main-thread layer tree  ─────────►  pending tree
                                        |  raster tiles (worker pool / GPU process)
                                        v  activate
                                      active tree
                                        |
                                        v  produce a CompositorFrame
                                     GPU PROCESS (viz) -> display -> vsync

The essential asymmetry:

The compositor thread can produce frames from the active tree without the main thread.

That is why scrolling and transform/opacity animations survive a blocked main thread, and it is the single most important fact in this module.

2.2 Commit, activate, draw

  • Commit — main thread hands the compositor an updated tree. Main thread is blocked briefly.
  • Activate — the pending tree becomes active once its tiles are ready to draw.
  • Draw — the compositor produces a frame; the display compositor (viz, in the GPU process) composites all sources and presents at vsync.

The pending/active split exists so that half-rastered content is never shown. A simpler design — draw whatever is ready — would produce visible tearing and partially-updated frames. That is the §46 entry for this module.

2.3 Tiles and raster

Layers are divided into tiles, rastered independently and prioritised by proximity to the viewport. This is why:

  • scrolling fast can show checkerboarding — you outran raster;
  • a huge layer costs memory proportional to its area, not its visible part;
  • raster happens off the main thread, and may be GPU-accelerated.

2.4 Scrolling, precisely

Scrolling is compositor-driven unless something forces main-thread involvement. The usual causes:

  • a non-passive wheel/touchstart listener that might call preventDefault(),
  • certain position: fixed/sticky and background-attachment: fixed situations,
  • scroll-linked effects implemented in JS scroll handlers.

{ passive: true } is a promise that you will not call preventDefault(), which lets the compositor scroll without asking the main thread. This is the clearest example in the platform of an author-supplied guarantee unlocking a fast path — and the reason browsers made passive the default for some listeners.

2.5 Compositor-driven animations

An animation can run on the compositor if it only changes property-tree values (transform, opacity, and some filters). Anything that changes layout or requires re-recording the display list must involve the main thread every frame. This is bi-09's property-tree argument, cashed out.

2.6 The frame budget

60 Hz  -> 16.7 ms per frame
120 Hz ->  8.3 ms per frame

Within that: input processing, rAF callbacks, style, layout, pre-paint, paint, commit, raster, GPU draw, and presentation. Your script is one term. On a 120 Hz device the budget halves while raster costs do not — which is why high-refresh displays expose jank that 60 Hz hid.


3. Under the Hood

ConcernWhere
Compositor//cc — layer trees, tiling, raster scheduling, animation
Display compositor, command buffer//components/viz, //gpu
Blink side of compositingcore/paint/ + the compositing decisions in pre-paint
Scheduling framesthe compositor scheduler in //cc

Remember //cc runs in the renderer process across two threads, while viz/GPU work runs in the GPU process (bi-02). Conflating "compositor" with "GPU process" is the standard error here.


3.5 Deep dive: the cc vocabulary, from the source glossary

cc/README.md carries a glossary. These are the terms you need to read a trace or a bug report.

TermDefinition
Layera conceptual piece of content with a known position relative to the viewport. Main thread only.
LayerImplthe same thing, on the compositor thread
Active treethe layers + property trees used to submit a CompositorFrame. Composited effects — scrolling, pinch, animations — are done by modifying the active tree
CompositorFramea set of RenderPasses (each a list of DrawQuads) plus metadata — "the instructions for how to draw an entire scene presented in a surface"
DrawQuadone primitive draw instruction (a textured rect, a solid colour, …)
RenderPassa group of quads drawn to an intermediate target
ElementIDa stable identifier across updates, chosen by cc's clients; Blink uses it to identify the object responsible for a composited animation
DirectRendererabstraction for drawing an aggregated CompositorFrame to a physical output; backends are GL, Skia, or Software
CopyOutputRequesta request to copy part of the output; forces a separate RenderPass

Three observations that repay attention:

1. Layer vs LayerImpl is the thread boundary made into types. You can tell which thread a piece of cc code runs on from the type names alone. That is a design worth stealing: when a boundary matters, encode it in the type system rather than in a comment.

2. "Composited effects are done by modifying the active tree." That single sentence is the mechanism behind compositor-driven scroll and transform/opacity animation. The compositor is not re-deriving anything from Blink — it is mutating its own tree and re-submitting. This is why those effects survive a blocked main thread.

3. A CompositorFrame is instructions, not pixels. Quads with transforms and texture ids. The GPU process executes them. So "compositing" is closer to issuing a display list than to copying bitmaps — the same record-then-execute pattern as bi-09's display items, one layer down.

ElementID deserves a note of its own. It is how Blink says "this animation belongs to that thing" in a way that survives commits, layer reshuffling, and tree rebuilds. Every system that hands work to an asynchronous consumer needs a stable identity that outlives the structures on both sides — key in fw-02, ElementID here. Same problem, same shape of answer.


3.6 Deep dive: the frame pipeline, stage by stage

BeginFrame  ->  main thread: rAF, style, layout, prepaint, paint
            ->  COMMIT  (main thread blocked briefly; hands over the layer tree)
            ->  compositor: pending tree
            ->  RASTER (worker pool / GPU)
            ->  ACTIVATE (pending -> active, only when tiles are ready)
            ->  DRAW: submit CompositorFrame
            ->  viz (GPU process): aggregate frames from all sources
            ->  DirectRenderer draws to the physical output
            ->  PRESENT at vsync

Where things go wrong, per stage:

Stage overrunsSymptomUsual cause
main-thread framejank, input delaylong tasks, forced sync layout, heavy rAF
commitperiodic hitcheshuge layer trees, many property changes
rastercheckerboarding on scrollexpensive paint, large layers, many tiles
activatestale contenttiles not ready; raster outran
draw / aggregatedropped frames on a busy systemGPU contention, too many surfaces

Aggregation is the part application engineers never see. viz aggregates CompositorFrames from multiple sources — every renderer, plus browser UI — into one frame for the display. That is why an out-of-process iframe can composite independently, and why a misbehaving GPU process affects every tab at once.


3.7 Deep dive: tiles, and why scrolling can show nothing

Layers are divided into tiles rastered independently and prioritised by proximity to the viewport. There is a raster budget; tiles outside it wait.

This is why:

  • Checkerboarding (blank regions during fast scroll) is correct behaviour: the compositor drew a consistent frame using what was ready, rather than blocking. The alternative — stall until raster finishes — is worse, and the choice is deliberate.
  • A very large layer costs memory proportional to its area, not its visible portion — so will-change: transform on a 10,000px-tall element is expensive even though you can see 800px.
  • Raster happens off the main thread, and may be GPU-accelerated. GPU raster is usually faster; the CPU path exists because drivers vary enormously and some content rasters better on CPU.

Damage tracking

The compositor tracks a damage rect — the region that actually changed — so the GPU redraws only that. This is why a small blinking cursor does not cost a full-screen redraw. When you see a full-screen damage rect in a trace for a tiny change, something has invalidated a whole layer, and that is a bug worth chasing.


3.8 Deep dive: what actually forces scroll onto the main thread

Compositor scrolling is the default. It is lost when the compositor cannot answer "may I scroll this without asking?" — and bi-09's hit-test data is how it tries.

Causes, roughly in order of how often they appear in real apps:

  1. Non-passive wheel / touchstart / touchmove listeners — the compositor must wait to see whether you call preventDefault().
  2. scroll event handlers that write styles — the handler runs on the main thread, one frame after the scroll it reacts to. Structurally late; not fixable by optimising the handler.
  3. background-attachment: fixed and some position: fixed/sticky configurations.
  4. Scroll-linked effects in JS generally.

{ passive: true } is a promise that you will not call preventDefault(). It is the clearest example in the platform of an author-supplied guarantee unlocking a fast path — the same shape as key (fw-02), contain (bi-07), and sideEffects (fw-07). Browsers eventually made some listeners passive by default because the guarantee was almost always true and almost never declared.

The modern alternatives, which move the effect to the compositor instead of the main thread: IntersectionObserver for "is it visible", CSS scroll-driven animations (animation-timeline: scroll()) for scroll-linked motion, position: sticky for stickiness, and CSS scroll snap for snapping. Each replaces a scroll handler with something declarative that the compositor can evaluate itself.


3.9 Deep dive: the frame budget, itemised

60 Hz  -> 16.7 ms      120 Hz -> 8.3 ms      144 Hz -> 6.9 ms

Within one frame the main thread may need to do: input dispatch, rAF callbacks, ResizeObserver and IntersectionObserver delivery, style recalculation, layout, pre-paint, paint, and commit. The compositor then needs: raster (possibly), activate, draw, submit. The GPU process needs: aggregate and present.

Your JavaScript is one term in that sum. The common failure is a team optimising script time from 8 ms to 5 ms while style+layout costs 9 ms, and reporting no improvement.

Two consequences that are easy to state and hard to internalise:

  • High-refresh displays halve the budget but not the costs. Raster, paint, and GPU work do not get cheaper at 120 Hz. A page that is comfortable at 60 Hz can be visibly janky at 120 Hz, and the fix is usually reducing work rather than yielding differently.
  • Skipping a frame is not proportional degradation. Miss by 1 ms and you lose a whole frame — 16.7 ms of latency for 1 ms of overrun. Frame budgets are cliffs, not slopes, and this is why p95 matters far more than mean.

3.10 Deep dive: what "the GPU makes it fast" actually means

The folk explanation — "transform is GPU-accelerated, so it is fast" — is wrong in a way that produces bad decisions.

The truth is structural, not hardware:

  1. transform and opacity map to property-tree node changes (bi-09).
  2. A property-tree change does not require re-recording display items.
  3. Not re-recording means not re-rastering.
  4. The compositor can therefore produce a new frame from already-rastered tiles.
  5. That path does not need the main thread at all.

The GPU is involved, but the reason it is cheap is that stages were skipped, not that a processor is fast. This matters because it predicts the exceptions correctly: animating filter: blur() is also composited but is genuinely expensive, because the effect itself costs GPU work per frame. A purely hardware explanation cannot tell you that; a pipeline explanation can.


4. Anti-Patterns

Non-passive scroll listeners. Usually accidental; measurably destructive.

JS-driven scroll effects. scroll handlers that write styles run on the main thread, one frame late by construction. Prefer IntersectionObserver, scroll-driven animations, or CSS.

Giant composited layers. Memory scales with area.

will-change as a blanket fix (see bi-09).

Testing only on desktop at 60 Hz. Raster and GPU costs are where device differences live.


5. Trade-offs

Compositor-driven smoothness vs correctness. The compositor draws stale but consistent content while the main thread catches up. That is why scrolling can reveal not-yet-painted areas: the alternative is blocking on the main thread, which is worse.

Pending/active trees vs latency. Never showing partial content costs a frame of latency relative to drawing whatever is ready.

More layers vs fewer. Fewer repaints, more memory and composite cost.

Raster on GPU vs CPU. GPU raster is faster for many workloads and worse for some; drivers vary enormously, which is why there is a fallback path at all.


6. Lab

Your mini-browser will not have a real compositor; the lab is measurement and reasoning.

  1. Budget accounting. Build a page with a rAF animation and measure, from a trace, how much of each frame is script vs style/layout vs paint vs raster vs GPU. Produce a stacked chart.
  2. Passive vs non-passive. Same page, one non-passive touchstart/wheel listener. Measure scroll latency with and without. Explain the mechanism, then explain the number.
  3. Compositor vs main-thread animation. Animate the same visual effect via transform and via top. Block the main thread with a 500 ms task mid-animation. Record both. Describe what the user sees in each case.
  4. Tile/checkerboard. Make a very tall page with expensive content and fling-scroll it on a throttled profile. Capture checkerboarding. Explain it in terms of tiles and raster priority.
  5. 120 Hz thought experiment. Take your frame budget chart from (1) and recompute against 8.3 ms. Which term breaks first?

Deliverable: the four measurements plus one paragraph on which of them would change most on a low-end Android device, and why.


7. Failure Lab

  1. Add a non-passive wheel listener that does nothing. Show the cost.
  2. Animate box-shadow (paint-heavy) vs transform. Compare frames.
  3. Force a giant layer (will-change: transform on a very large element). Measure memory.
  4. Write a scroll handler that writes a style each event. Demonstrate the one-frame lag, then fix it with a compositor-friendly approach and prove the lag is gone.

8. Debugging Exercise

  1. Perfetto: capture a scroll. Identify the compositor thread, the raster workers, and the GPU process. Follow one frame's flow arrows end to end.
  2. Find a frame that was dropped. Determine which stage overran, from the trace alone.
  3. DevTools: use layer borders and the rendering panel to find an unexpected composited layer. Determine what created it.
  4. Determine whether a given animation is running on the compositor. State your evidence.

9. References

  • //cc documentation in-tree; //components/viz docs.
  • Life of a Pixel; the Chromium rendering team's compositor documentation.
  • CSS Transforms, Web Animations, Scroll-driven Animations specs.
  • EventTarget.addEventListener passive listeners (DOM spec) and the interventions that made some listeners passive by default.

10. Principal Engineer Review

  1. Explain why a page with a blocked main thread still scrolls, in four sentences, mechanically.

  2. {passive: true} is a promise from the author that unlocks a browser fast path. Name two other places in the web platform with the same shape, and what makes the pattern work.

  3. Your app is smooth at 60 Hz and janky at 120 Hz. Give the likeliest causes in order and how you would confirm each.

  4. The pending/active tree split costs a frame of latency to avoid showing partial content. Argue the opposite trade-off; what product would choose it?

  5. A team proposes implementing a parallax effect with a scroll listener. Give the mechanism- level objection and two alternatives, with their limitations.

  6. Compositing decisions are heuristic. Design the API you would give authors instead. What would go wrong when they use it?

  7. Checkerboarding is user-visible incorrectness that browsers ship deliberately. Justify it. Under what conditions would blocking be better?

  8. You have one week to improve scroll performance across a large app. What do you measure first, and what is the single most likely finding?

  9. Raster can happen on CPU or GPU, with a fallback path. Argue for removing the CPU path. What breaks, and for whom?

  10. A stakeholder asks for a "performance budget" for a new feature. Express it in frame-budget terms, and say which parts of the budget your team does not control.

References — bi-10-compositor-gpu

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • //cc documentation in-tree; //components/viz docs.
  • Life of a Pixel; the Chromium rendering team's compositor documentation.
  • CSS Transforms, Web Animations, Scroll-driven Animations specs.
  • EventTarget.addEventListener passive listeners (DOM spec) and the interventions that made some listeners passive by default.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-10 — Analysis

Required invariants

  1. The active tree can produce frames without the main thread. This is the property that makes scroll and compositor animations survive jank.
  2. Half-rastered content is never shown. The pending tree activates only when its tiles are ready.
  3. A CompositorFrame is instructions, not pixels — render passes of draw quads.
  4. The compositor must be able to decide input handling alone, using hit-test data produced at paint time. A non-passive listener removes that ability.
  5. Damage is tracked, so the GPU redraws only what changed.

Where a frame is lost

Stage overrunsSymptomUsual cause
main-thread framejank, input delaylong tasks, forced sync layout, heavy rAF
commitperiodic hitcheshuge layer trees, many property changes
rastercheckerboardingexpensive paint, large layers
activationstale contentraster outran
draw / aggregatedropped framesGPU contention, too many surfaces

The budget, stated honestly

60 Hz → 16.7 ms      120 Hz → 8.3 ms      144 Hz → 6.9 ms

Within that: input, rAF, observers, style, layout, pre-paint, paint, commit, raster, activate, draw, aggregate, present. Your JavaScript is one term. The common failure is optimising script from 8 ms to 5 ms while style+layout costs 9 ms and reporting no improvement.

Two properties that are easy to state and hard to internalise:

  • High refresh halves the budget, not the costs. Raster and GPU work do not get cheaper at 120 Hz. Content comfortable at 60 Hz can be visibly janky at 120.
  • Frame budgets are cliffs, not slopes. Overrun by 1 ms and you lose a whole frame. This is why p95 matters far more than mean, and why an average-based dashboard hides the problem.

Checkerboarding is correct behaviour

Blank regions during fast scroll mean the compositor drew a consistent frame using what was ready rather than blocking. The alternative — stall until raster completes — is worse. This is a deliberate choice of stale but consistent over correct but late, and it is the same trade the pending/active split makes one stage earlier.

The author-guarantee lever

{passive: true} is a promise not to call preventDefault(), which lets the compositor scroll without consulting the main thread. Browsers eventually made some listeners passive by default because the guarantee was almost always true and almost never declared.

The modern replacements move effects to where they can be evaluated without the main thread: IntersectionObserver, scroll-driven animations, position: sticky, CSS scroll snap. Each converts a scroll handler into something declarative.

Execution — bi-10-compositor-gpu

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


6. Lab

Your mini-browser will not have a real compositor; the lab is measurement and reasoning.

  1. Budget accounting. Build a page with a rAF animation and measure, from a trace, how much of each frame is script vs style/layout vs paint vs raster vs GPU. Produce a stacked chart.
  2. Passive vs non-passive. Same page, one non-passive touchstart/wheel listener. Measure scroll latency with and without. Explain the mechanism, then explain the number.
  3. Compositor vs main-thread animation. Animate the same visual effect via transform and via top. Block the main thread with a 500 ms task mid-animation. Record both. Describe what the user sees in each case.
  4. Tile/checkerboard. Make a very tall page with expensive content and fling-scroll it on a throttled profile. Capture checkerboarding. Explain it in terms of tiles and raster priority.
  5. 120 Hz thought experiment. Take your frame budget chart from (1) and recompute against 8.3 ms. Which term breaks first?

Deliverable: the four measurements plus one paragraph on which of them would change most on a low-end Android device, and why.


7. Failure Lab

  1. Add a non-passive wheel listener that does nothing. Show the cost.
  2. Animate box-shadow (paint-heavy) vs transform. Compare frames.
  3. Force a giant layer (will-change: transform on a very large element). Measure memory.
  4. Write a scroll handler that writes a style each event. Demonstrate the one-frame lag, then fix it with a compositor-friendly approach and prove the lag is gone.

8. Debugging Exercise

  1. Perfetto: capture a scroll. Identify the compositor thread, the raster workers, and the GPU process. Follow one frame's flow arrows end to end.
  2. Find a frame that was dropped. Determine which stage overran, from the trace alone.
  3. DevTools: use layer borders and the rendering panel to find an unexpected composited layer. Determine what created it.
  4. Determine whether a given animation is running on the compositor. State your evidence.

Observation — bi-10-compositor-gpu

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-10-compositor-gpu

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Frame budget decomposed from a trace into script/style/layout/paint/raster/GPU
  • Passive vs non-passive scroll latency measured; mechanism and number explained
  • Compositor vs main-thread animation compared under a 500 ms blocking task
  • Checkerboarding captured and explained via tiles and raster priority
  • Frame budget recomputed against 8.3 ms; the term that breaks first identified

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-10 — Broader Ideas

Stale but consistent beats correct but late

Checkerboarding and the pending/active tree split are both deliberate choices to show consistent content rather than block for complete content.

That trade appears everywhere:

  • Stale-while-revalidate in HTTP caching and in fw-09's query cache.
  • Optimistic UI — show the expected result, reconcile later.
  • Eventual consistency in distributed stores.
  • Progressive rendering — paint what you have.

The browser makes this choice at 60 Hz, thousands of times a session. It is a good argument to cite when someone insists a UI must never show anything but confirmed state.

Budget thinking

16.7 ms shared across a dozen stages, of which your script is one, and overrunning by 1 ms costs a whole frame. Cliffs, not slopes.

Generalise it: any system with a deadline — an SLA, a request timeout, a batch window — has the same property. Averages hide cliff behaviour, which is why p95/p99 is the right instrument and why "our mean response time improved" can accompany a worse user experience.

The author-guarantee lever, at its clearest

{passive: true} is a promise that unlocks a fast path. It became a default because the guarantee was almost always true and almost never declared.

That is a template for API evolution: ship the opt-in, measure how often the guarantee holds, then flip the default. If you own a library with a conservative default nobody needs, this is the path.

Next

bi-11 explains how work is chosen within a frame; bi-15's scroll and click traces make the process boundaries observable rather than diagrammatic.

Concepts — Blink Scheduling

Phase 4 · Spec area §16. Prerequisites: bi-02, bi-10. Hard prerequisite from the sibling track: fe-01 (execution model).

fe-01 teaches the JS-observable event loop — what the HTML spec guarantees. This module teaches the implementation: how Chromium chooses what to run. Read them in that order. Reading this first means memorising a scheduler you cannot observe.


1. Why a Principal Engineer needs this

1. "There is one task queue" is false, and everything downstream of that belief is wrong. The HTML spec says an event loop has multiple task queues and the implementation chooses. Chromium prioritises aggressively and changes policy dynamically. If your model is a single FIFO, you cannot explain why a timer registered before a click runs after it.

2. It explains why INP is not "make JS faster." Input delay is a scheduling term. The platform's mitigations — input prioritisation, anti-starvation, scheduler.postTask, scheduler.yield — are all interventions in this machinery.

3. It is where framework schedulers meet the platform. React's scheduler yields via MessageChannel for reasons that are only visible from this layer. Evaluating any framework's concurrency claims requires knowing what the browser actually promises.


2. Mental Model

2.1 Many queues, dynamic policy

Blink's main-thread scheduler maintains multiple prioritised task queues and selects among them. Priorities are not fixed — policy changes with page state (loading, gesture in progress, hidden, etc.). Roughly:

input  >  compositor  >  default  >  low / best-effort / idle

with anti-starvation guarantees so that a flood of high-priority work cannot indefinitely block lower-priority queues. Both halves matter: prioritisation without anti-starvation produces a browser where a busy animation permanently starves timers.

Per-frame scheduling exists too (frame_scheduler_impl), which is how a background iframe can be throttled independently of the foreground document. Throttling is per-frame, not per-page — a fact that explains a lot of third-party-iframe behaviour.

2.2 The rendering opportunity

Rendering is not a task you can post. The browser decides when a rendering opportunity occurs — normally aligned to vsync and to the compositor's BeginFrame. Within it:

run rAF callbacks -> ResizeObserver -> IntersectionObserver bookkeeping
-> style -> layout -> pre-paint -> paint -> commit

Two consequences:

  • rAF is not a timer. It is "just before the browser does its rendering work," which is why it is the right place to write animation state and the wrong place to do heavy computation.
  • A frame can be skipped entirely if nothing needs updating or if the compositor is not asking for one. setTimeout(f, 16) is not equivalent to rAF, and never will be.

2.3 Microtasks are not a yield point

A microtask checkpoint drains the whole queue, including microtasks enqueued during the drain. So await Promise.resolve() does not yield to rendering or to input. This is the single most consequential asymmetry in the platform (fe-01 covers it from the JS side); from the implementation side, the reason is simply that the checkpoint is inside the task, before the scheduler regains control.

2.4 Where the yielding primitives fit

PrimitiveYields to rendering?Yields to input?
queueMicrotask / awaitnono
setTimeout(f, 0)yesyes, but low priority and clamped
MessageChannel postMessageyesyes; historically the lowest-latency macrotask yield
scheduler.postTask({priority})yesyes, with explicit priority
scheduler.yield()yesyes, and resumes with continuation priority
requestIdleCallbackyesruns only in spare time

scheduler.yield()'s distinguishing property is that the continuation is scheduled ahead of newly-arrived same-priority work — which is precisely the problem MessageChannel yielding has: yield too often and your own continuation goes to the back of the queue behind unrelated tasks. Understanding that is what lets you evaluate a framework scheduler rather than trust its README.

2.5 Input, specifically

Input arrives on the compositor first. If the compositor can handle it (a scroll with no blocking listener), it never touches the main thread. Otherwise it is forwarded and queued at input priority. Input delay is time spent waiting for the main thread to finish something else — which is why a single long task, not slow JS in aggregate, is the usual culprit.


3. Under the Hood

ConcernWhere
Main-thread schedulerplatform/scheduler/main_thread/
Per-frame scheduling / throttlingframe_scheduler_impl.*
Agent group schedulingagent_group_scheduler_impl.*
Idle estimationidle_time_estimator.*
Task queue plumbingplatform/scheduler/base/
Docsplatform/scheduler/README.md, TaskSchedulingInBlink.md

TaskSchedulingInBlink.md is the right entry point — read it before code. Note the size of frame_scheduler_impl_unittest.cc relative to its implementation: when a unit test is more than twice the size of the code it tests, that is a subsystem where behaviour is subtle and regressions are expensive. Reading those tests is the fastest way to learn the intended policy.


3.5 Deep dive: the five scheduling policies, with their real numbers

platform/scheduler/TaskSchedulingInBlink.md names five distinct policies. Most engineers know "background tabs are throttled" and stop there; the reality is more differentiated, and the differences are observable.

1. Priorities

"The scheduler selects the next task to run based on the priority (modulo some starvation logic). The tasks with the same priority run in order."

The stated rules:

  • Input task runner has the highest priority.
  • Compositor task runner has high priority when user gestures are observed — not always, but during gestures.
  • Several ongoing experiments adjust priorities for individual frames.
  • Default priority is normal.

Two things to take from this. First, "modulo some starvation logic" is doing real work — strict priority without anti-starvation produces a browser where a busy animation permanently blocks timers. Second, the compositor's priority is conditional on gesture state, which is a dynamic policy: the same task is more urgent while the user's finger is down.

2. Pausing — ScopedPagePauser

During synchronous dialogs (alert(), print()) or inside V8 debugger breakpoints, the scheduler enters a nested run loop and stops running pauseable tasks. No JavaScript runs while a page is paused.

This is the mechanism behind a behaviour every web developer has met without understanding: alert() freezes everything, including timers and animations, in that page. It is also why debugging with breakpoints changes timing so drastically — you are not merely slow, you are in a different scheduling regime.

3. Deferring — the two-second window

"Scheduler defers tasks for two seconds after a user gesture, as it's very likely that another gesture will arrive soon. The majority of tasks can be deferred."

This is a genuinely surprising number and worth holding. After a tap or click, low-importance work is pushed out for two seconds on the bet that more interaction is coming. It is a latency optimisation that costs throughput, and it explains benchmark results that look wrong: work started right after an interaction may not run when you expect.

4. Freezing — five minutes on mobile

"On mobile all pages are frozen after five minutes in the background. On desktop only eligible pages are frozen, which is determined by heuristics based on the APIs the page is using."

Freezing stops task execution entirely, to save power and keep foreground tabs responsive. The platform-conditional behaviour is the important part: your background page behaves differently on Android than on desktop, by design, and "it works on my laptop" is not evidence about phones.

The API-usage heuristics on desktop are why a page holding a WebSocket or playing audio is treated differently from an idle one.

5. Throttling — narrower than you think

"At the moment only JavaScript timers (setTimeout/setInterval) are throttleable. While there is a general desire to expand this list, this is a low priority effort as we are focused on making freezing better instead."

This corrects a widespread misconception. Background throttling is not a general slowdown of everything — it is specifically timers. And the doc is candid that expanding it is deprioritised in favour of freezing, which is a strictly stronger tool.

Why quote the numbers. "Background tabs are throttled" is folklore. "Timers only; pages freeze after five minutes on mobile; unrelated work is deferred two seconds after a gesture" is a model you can predict with. The gap between those two states is exactly the gap this track exists to close.


3.6 Deep dive: task types, task sources, and why ordering surprises you

The HTML specification says an event loop has multiple task queues, and the implementation chooses which to service. Blink materialises this as task types mapped to task runners, so a timer callback, a DOM event, a network callback, and a postMessage are on different queues with potentially different priorities.

The practical consequence, restated precisely:

setTimeout(f, 0) registered before a click does not reliably run before the click handler, because they are in different task sources and input wins.

This is not a bug and it is not nondeterminism — it is a documented priority policy. Code that depends on cross-source ordering is depending on something no specification promises.

Per-frame scheduling (frame_scheduler_impl) means these policies apply per frame, not per page: a background cross-origin iframe can be throttled while the main document is not. That is why third-party iframe behaviour is often inexplicable from the top document's perspective.


3.7 Deep dive: the rendering opportunity, in order

Rendering is not a task you can post. The browser decides when a rendering opportunity occurs, normally aligned to vsync and the compositor's BeginFrame. Within it, roughly:

1. run requestAnimationFrame callbacks
2. run ResizeObserver callbacks   (may loop: mutations re-run layout, bounded by a depth limit)
3. IntersectionObserver bookkeeping
4. style recalculation
5. layout
6. pre-paint (invalidation + property trees)
7. paint
8. commit to the compositor

Consequences that explain real behaviour:

  • rAF runs before style and layout. That is why it is the right place to write animation state and the wrong place to do heavy work — anything expensive there directly delays the frame.
  • ResizeObserver runs after layout and may cause another layout. It is deliberately allowed to iterate to a fixed point, with a depth limit and a console error when exceeded.
  • A frame can be skipped entirely if nothing needs updating. setTimeout(f, 16) is not equivalent to rAF and never will be — one is a timer, the other is a position in the rendering steps.

3.8 Deep dive: the yielding primitives, compared honestly

PrimitiveYields to render?Yields to input?Continuation priorityNotes
queueMicrotask, awaitnonon/anot a yield at all
setTimeout(f, 0)yesyeslow; clamped4 ms clamping after nesting depth 5
MessageChannelyesyesback of the queuehistorically the lowest-latency macrotask yield
scheduler.postTask({priority})yesyesas specifiedexplicit user-blocking / user-visible / background
scheduler.yield()yesyescontinuation — ahead of newly-arrived same-priority workthe point of the API
requestIdleCallbackyesyesidle onlymay never run under sustained load

The distinguishing property of scheduler.yield() is worth stating plainly, because it is the whole reason the API exists:

With MessageChannel yielding, your continuation goes to the back of the queue, behind any unrelated task that arrived while you were working. Yield often enough and your own work starves. scheduler.yield() resumes with continuation priority, ahead of newly-arrived same-priority work.

This is why the naive advice "just yield more often" has a cost curve that turns upward, and why fw-04's lab measures it rather than asserting it.

The setTimeout clamp is the other detail worth knowing: after a nesting depth of five, timers are clamped to a minimum of 4 ms. Chunking with setTimeout(f, 0) therefore caps you at ~250 chunks/second regardless of how fast each chunk is.


3.9 Deep dive: input, INP, and where the latency actually is

Input arrives at the compositor first. If it can be handled there (a scroll with no blocking listener), the main thread is never involved. Otherwise it is forwarded and queued at input priority.

Interaction to Next Paint decomposes into three terms:

INP  =  input delay  +  processing time  +  presentation delay
        (waiting for      (your handler)    (style, layout, paint,
         the main thread)                    commit, raster, present)

Two of the three terms are not your handler. The most common real-world profile is a large input delay caused by one long task that was already running — so the fix is not optimising the handler at all, it is not having the long task.

That is why bi-11 and fe-01 insist on the same discipline from opposite directions: the sibling track teaches you to measure the decomposition, this module teaches you why the scheduler produces it.

Long Animation Frames (LoAF) is the API that attributes this: it reports frames that took too long along with blockingDuration and a scripts[] breakdown, which is how you find which script caused the long frame rather than knowing only that one existed.


3.10 Deep dive: off-main-thread scheduling

The doc's advice is blunt: if your task does not have to run on the main thread, do not put it there. Blink offers thread-pool task runners for exactly this.

The application-level analogue is the Web Worker decision, and the trade is the same in both places: you pay serialisation to buy parallelism. For a worker, postMessage performs a structured clone unless you transfer or share. So:

  • large data + trivial computation → the clone dominates; the worker loses,
  • modest data + heavy computation → the worker wins,
  • shared-memory approaches avoid the copy but require crossOriginIsolated (bi-02), which is a deployment decision, not just a code change.

Finding the crossover empirically is fe-01's lab; understanding why the crossover exists is this module.


4. Anti-Patterns

Chunking with microtasks. Does nothing for responsiveness; can freeze the tab.

setTimeout(f, 0) as a yield in a hot loop. Clamping and low priority make it slower than you expect; also it competes with unrelated tasks.

Doing heavy work in rAF. rAF runs before rendering — work there directly delays the frame.

Assuming timers fire on time in background tabs. Throttling is per-frame and aggressive.

Benchmarking scheduling on an unthrottled desktop. Priority effects appear under load.


5. Trade-offs

Prioritisation vs fairness. Aggressive input priority improves responsiveness and risks starvation; the anti-starvation machinery is the cost of that choice.

Dynamic policy vs predictability. Policy that changes with page state gives better real- world behaviour and makes reasoning (and reproducing bugs) harder.

Exposing scheduling to authors. scheduler.postTask gives authors real control and real ability to make things worse. Compare with the browser keeping it internal.


6. Lab

  1. Priority inversion experiment. Post work at several scheduler.postTask priorities plus setTimeout and MessageChannel. Register a click handler. Measure ordering and input delay under a synthetic main-thread load. Predict the ordering first.
  2. Yield-cost curve. Take a 200 ms computation. Chunk it yielding every 0.5/5/50 ms via MessageChannel, then via scheduler.yield(). Plot total time and p75 input delay. Find where each curve turns.
  3. rAF vs timer. Animate with both under load. Measure frame alignment and dropped frames.
  4. Continuation priority. Construct a case where MessageChannel yielding loses to unrelated tasks and scheduler.yield() does not. This is the experiment that proves why the API exists.
  5. Throttling. Measure timer behaviour in a hidden tab and a hidden cross-origin iframe.

Deliverable: the two plots from (2) and (4), plus a recommendation you would give a platform team about which yielding primitive to standardise on — including its failure mode.


7. Failure Lab

  1. Recursive queueMicrotask — freeze the tab. Confirm the debugger cannot break in cleanly, and explain why in terms of the checkpoint.
  2. Starve rendering with a chain of high-priority tasks. Find where anti-starvation kicks in.
  3. Write a long task that delays a click by >300 ms. Reduce it to <50 ms without making the total work faster. State exactly what you changed.

8. Debugging Exercise

  1. Perfetto with toplevel + blink + cc: identify task boundaries, the microtask checkpoint, and the rendering opportunity in one trace.
  2. Find a long task and attribute it to a task queue/source.
  3. Correlate a TRACE_EVENT name from the trace back to its source (bi-01, rung 3 → rung 1).
  4. Observe a frame that produced no rendering. Explain why not.

9. References

  • WHATWG HTML §Event loops, especially "update the rendering" and "perform a microtask checkpoint" — normative.
  • platform/scheduler/README.md, TaskSchedulingInBlink.md.
  • W3C Prioritized Task Scheduling (scheduler.postTask, scheduler.yield).
  • Long Animation Frames API; Event Timing.
  • React packages/scheduler/ — read after the lab, not before.

10. Principal Engineer Review

  1. A timer registered before a click handler runs after it. Explain, then say what this implies for code that assumes registration order.

  2. scheduler.yield() resumes with continuation priority. Reconstruct the problem this solves from first principles, and describe the bug you would see without it.

  3. React's scheduler prefers MessageChannel. Reconstruct that decision. What would break with microtasks, and with setTimeout?

  4. Argue that exposing task priorities to authors was a mistake. Then argue it was necessary. What governance would you put around it in a large org?

  5. Anti-starvation guarantees make the scheduler harder to reason about. Argue for a strict priority scheduler with no anti-starvation. What is the first thing that breaks?

  6. Your INP is 400 ms; total JS execution per interaction is 40 ms. Enumerate where the other 360 ms could be, and how to confirm each.

  7. Throttling is per-frame. What does this enable that per-page throttling would not, and what abuse does it invite?

  8. Design a scheduling API for a framework that must work in browsers with and without scheduler.yield(). What does your fallback give up, and how would you make that visible?

  9. When is a long task the right engineering decision? Give a concrete case and the invariant yielding would violate.

  10. You are asked to set an org-wide rule about yielding. Write it in three sentences, including the exception.

References — bi-11-scheduling

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • WHATWG HTML §Event loops, especially "update the rendering" and "perform a microtask checkpoint" — normative.
  • platform/scheduler/README.md, TaskSchedulingInBlink.md.
  • W3C Prioritized Task Scheduling (scheduler.postTask, scheduler.yield).
  • Long Animation Frames API; Event Timing.
  • React packages/scheduler/ — read after the lab, not before.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-11 — Analysis

Required invariants

  1. There are multiple task queues, and the implementation chooses. No global FIFO. Code that depends on cross-source ordering depends on something no specification promises.
  2. Input has the highest priority, so a flood of timers cannot starve a click.
  3. Anti-starvation bounds prioritisation. Strict priority without it produces a browser where a busy animation permanently blocks lower-priority work.
  4. A microtask checkpoint drains exhaustively, including microtasks enqueued during the drain — so await is not a yield.
  5. Rendering happens between tasks, never inside one. DOM mutations are invisible until the current task ends.
  6. Policies are per-frame, not per-page. A background iframe can be throttled independently.

The five policies, with their constants

PolicyMechanismConstant
Prioritiesinput highest; compositor high during gesturesdefault normal
PausingScopedPagePauser, nested run loopno JS during alert()/print()/breakpoints
Deferringafter a user gesture2 seconds
Freezingbackground pages5 minutes on mobile; heuristics on desktop
ThrottlingJS timers only at present

"Background tabs are throttled" is folklore. The table is a model you can predict with, and the gap between the two is the gap this module closes.

Failure modes

MistakeConsequence
Chunk with microtasksno responsiveness gain; a runaway chain freezes the tab
setTimeout(f, 0) in a hot loop4 ms clamp after nesting depth 5 → ~250 chunks/s ceiling
Heavy work in rAFruns before rendering, so it directly delays the frame
Yield too often via MessageChannelcontinuation goes behind unrelated tasks; your own work starves
Benchmark unthrottled on desktoppriority effects only appear under load

Why scheduler.yield() exists

With MessageChannel yielding, your continuation is appended to the back of the queue, behind anything that arrived while you worked. Yield often enough and you starve yourself. scheduler.yield() resumes with continuation priority, ahead of newly-arrived same-priority work. That is the whole reason for the API, and it is why the naive advice "yield more often" has a cost curve that turns upward.

INP decomposes into three terms, two of which are not you

INP = input delay + processing + presentation

The most common real profile is a large input delay caused by a long task that was already running. The fix is not optimising the handler — it is not having the long task. LoAF's scripts[] attribution is how you find which one.

Execution — bi-11-scheduling

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


6. Lab

  1. Priority inversion experiment. Post work at several scheduler.postTask priorities plus setTimeout and MessageChannel. Register a click handler. Measure ordering and input delay under a synthetic main-thread load. Predict the ordering first.
  2. Yield-cost curve. Take a 200 ms computation. Chunk it yielding every 0.5/5/50 ms via MessageChannel, then via scheduler.yield(). Plot total time and p75 input delay. Find where each curve turns.
  3. rAF vs timer. Animate with both under load. Measure frame alignment and dropped frames.
  4. Continuation priority. Construct a case where MessageChannel yielding loses to unrelated tasks and scheduler.yield() does not. This is the experiment that proves why the API exists.
  5. Throttling. Measure timer behaviour in a hidden tab and a hidden cross-origin iframe.

Deliverable: the two plots from (2) and (4), plus a recommendation you would give a platform team about which yielding primitive to standardise on — including its failure mode.


7. Failure Lab

  1. Recursive queueMicrotask — freeze the tab. Confirm the debugger cannot break in cleanly, and explain why in terms of the checkpoint.
  2. Starve rendering with a chain of high-priority tasks. Find where anti-starvation kicks in.
  3. Write a long task that delays a click by >300 ms. Reduce it to <50 ms without making the total work faster. State exactly what you changed.

8. Debugging Exercise

  1. Perfetto with toplevel + blink + cc: identify task boundaries, the microtask checkpoint, and the rendering opportunity in one trace.
  2. Find a long task and attribute it to a task queue/source.
  3. Correlate a TRACE_EVENT name from the trace back to its source (bi-01, rung 3 → rung 1).
  4. Observe a frame that produced no rendering. Explain why not.

Observation — bi-11-scheduling

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-11-scheduling

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • fe-01 completed first
  • Priority-inversion ordering predicted, then measured
  • Yield-cost curves plotted for MessageChannel and scheduler.yield()
  • A case constructed where continuation priority demonstrably matters
  • Long task reduced from >300 ms to <50 ms input delay without reducing total work
  • One trace event name correlated back to source

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-11 — Broader Ideas

Priority systems degrade without governance

Chromium prioritises input highest — and needs anti-starvation logic because strict priority alone produces starvation. The same is true of any priority system you build: queues, job schedulers, feature-flagged rollouts, on-call severities.

Priority systems drift to uniformity unless someone owns the policy. If everything can be marked user-blocking, everything will be.

When you expose a priority API — scheduler.postTask, a job queue, a startTransition equivalent — you are handing a lever to someone who cannot see the whole system. Plan the governance with the API.

Deadlines change design, not just tuning

The 2-second gesture deferral and the 5-minute mobile freeze are policies derived from user behaviour, not from engineering convenience. They are worth citing when someone proposes a background task with no consideration of foreground impact.

Your equivalents: batch windows, retry backoff, prefetch aggressiveness. Each is a bet about what the user is about to do — and each should be stated as such rather than tuned by feel.

Yielding has a cost curve

The naive advice "yield more often" turns upward, because a MessageChannel continuation goes behind unrelated work. scheduler.yield() exists specifically to fix that.

This generalises to every cooperative system: yielding is not free, and yielding to a queue you do not control can starve you. If you implement chunked processing anywhere — a worker loop, a stream transform, a migration script — measure the curve rather than picking an interval.

Next

fw-04 is where this becomes a framework: interruptible rendering is only meaningful because of the scheduling facts established here. fe-01 is the JS-observable half and should precede this module.

Concepts — Debugging and Tracing Chromium

Phase 4 · Spec areas §20 (debugging), §21 (tracing). Requires a working local build — see bi-00-roadmap/docs/chromium-build-debug-trace.md.


1. Why a Principal Engineer needs this

1. Source reading answers "what could happen." Debugging answers "what did happen." The gap between those two is where wrong conclusions live, and in a codebase with generated code, feature flags, and platform branches, the gap is wide.

2. Tracing is the highest-leverage skill in this entire track, because it works without a build, spans processes and threads, and correlates directly back to source via trace event names. It is bi-01's rung 3, and it is the tool most people learn last and should learn first.

3. Reproduce → isolate → fix → verify is the contribution workflow (bi-14). Without debugging fluency you can read Chromium but not change it.


2. Mental Model

2.1 Pick the right instrument

QuestionInstrument
Does this code run at all?tracing, or a log
On which thread/process?tracing (thread rows) or thread backtrace all
How often, and how long?tracing
How did I get here?debugger backtrace
What is this value?debugger, or a log
Which of two paths was taken?conditional breakpoint or a counter
Why is this frame late?tracing, always

Reach for tracing before the debugger when you have no hypothesis. A breakpoint requires you to already know where to put it; a trace tells you where to put it.

2.2 Trace events are greppable identifiers

Trace events come from TRACE_EVENT macros in the source with literal string names. So:

see an event in Perfetto  ->  grep its exact name  ->  land in the code that ran

This is the bridge between observation and source, and it is the single most useful technique in this module. It also works in reverse: add a TRACE_EVENT to code you are studying, rebuild that target, and watch your own event appear in the timeline.

2.3 Multiprocess debugging

A Blink breakpoint must be set in a renderer, not the browser process you launched. Chromium provides startup-dialog flags that pause a child process until you attach. Combine with --disable-hang-monitor so sitting at a breakpoint does not get your renderer killed.

The three reasons a breakpoint does not hit, in order of frequency:

  1. wrong process,
  2. the function was inlined (set it on the caller),
  3. the path is behind a runtime-enabled feature that is off.

2.4 DCHECKs are your friend, if you built with them

With dcheck_always_on = true, breaking an invariant produces a precise message naming the invariant instead of a confusing misrender ten stages later. For a learning checkout this is the highest-value single build flag.

2.5 Logging

LOG()/VLOG() with --enable-logging=stderr --v=1. Verbosity can be scoped per module, which matters in a codebase where global verbose logging is unreadable. Logging beats a debugger when the interesting behaviour is a sequence across many iterations.


3. Practice

Concrete procedures live in bi-00-roadmap/docs/chromium-build-debug-trace.md §4–§5: lldb setup and the in-tree pretty-printers, attaching to a paused renderer, conditional breakpoints, thread backtrace all, and the tracing categories worth recording.

Do thread backtrace all once, early. It converts bi-02's thread diagram from a claim into something you have seen.


3.5 Deep dive: trace categories are a catalogue, and it is in the tree

base/trace_event/builtin_categories.h (~27 KB, roughly 500 category strings) is the authoritative list. You do not have to guess which categories to enable — read the file.

Measured distribution of TRACE_EVENT categories inside core/ alone:

CategoryUses in core/
blink204
navigation41
loading20
ime19
input17
blink.worker17
devtools11
devtools.timeline4

Two things follow.

devtools.timeline is a trace category. The DevTools Performance panel is a view over the same tracing system you can drive yourself from Perfetto. It is curated and JS-centric; the underlying data is richer. Once you know this, "DevTools didn't show me anything" stops being a dead end — you are looking at a filtered view and can widen it.

The categories you actually want, by question:

QuestionCategories
Where did the frame go?blink, cc, viz, gpu, toplevel
Why is input slow?input, latency, benchmark, toplevel
Why is loading slow?loading, navigation, netlog-adjacent, blink
Which task ran?toplevel, sequence_manager, scheduler
Memorymemory-infra (a separate dump-based system)

Trace events are greppable identifiers — the bidirectional bridge

This is the single most useful technique in this module and it works both ways:

observe an event in Perfetto  →  grep its exact literal name  →  land in the code that ran
add a TRACE_EVENT to code     →  rebuild that target          →  watch your event appear

The first direction turns a timeline into source navigation. The second turns source reading into an experiment. Together they are the reason tracing sits at rung 3 of the bi-01 ladder rather than being a specialist performance tool.

Event shapes worth recognising in a trace:

  • Duration events (begin/end pairs) — nested, forming the flame chart.
  • Instant events — a point in time.
  • Async / flow events — the arrows that cross threads and processes. These are how you follow one frame from the renderer main thread to the compositor to the GPU process, which is the observation that makes bi-10 concrete rather than diagrammatic.
  • Counters — values over time (memory, tile counts).

3.6 Deep dive: choosing an instrument, expanded

bi-01's ladder said tracing sits above search and below the debugger. Here is the fuller decision table.

SituationInstrumentWhy
No hypothesis at alltracinga breakpoint requires knowing where to put it
"Does this code run?"tracing, or a one-line LOGa breakpoint answers this at 100× the cost
"How often, how long?"tracingthe debugger destroys the timing you are measuring
"How did I get here?"debugger backtracenothing else gives you the stack
"What is this value?"debugger, or LOG
A sequence across many iterationsloggingstepping 400 times is not a plan
Timing-dependent / racetracing onlybreakpoints change the schedule and hide the bug
Intermittent, 1-in-50tracing with a long bufferyou cannot sit at a breakpoint waiting
Cross-process orderingtracing with flow eventsthe only tool that shows both sides

The italicised rule: a debugger perturbs time; tracing perturbs it far less. Any bug whose existence depends on ordering is a tracing problem, and reaching for a breakpoint will make it disappear — which is the most frustrating way to lose an afternoon.


The three failure modes, with fixes.

1. Wrong process. Blink runs in a renderer. Attaching to the browser process you launched gets you nothing. Use the startup-dialog flags to pause a child process and attach to it, and always pass --disable-hang-monitor so a paused renderer is not killed for unresponsiveness.

2. Inlined. Release builds inline aggressively. Symptoms: the breakpoint "resolves" but never fires, or the stack has fewer frames than the source suggests. Fixes, cheapest first:

  • set the breakpoint on the caller instead,
  • break on a line rather than a symbol,
  • rebuild the one file with lower optimisation,
  • fall back to a TRACE_EVENT or LOG, which cannot be optimised away.

3. Behind a feature flag. The code exists and is never reached. Check runtime_enabled_features.json5 (bi-01 Technique 4) and try --enable-blink-features=YourFeature.

Conditional breakpoints that are actually usable

Blink's hot paths run thousands of times per frame, so unconditional breakpoints are useless.

(lldb) breakpoint set -n blink::Element::SetAttribute -c 'name == "class"'
(lldb) breakpoint set -n blink::Document::UpdateStyleAndLayout -i 50   # ignore first 50 hits
(lldb) breakpoint command add 1
> bt 12
> continue
> DONE

That last pattern — breakpoint plus automatic backtrace plus continue — gives you a sampled call stack log without stopping execution. It is the debugger being used as an instrument rather than as a pause button, and it is the right tool for "who calls this, in practice, on a real page."


3.8 Deep dive: thread backtrace all, and reading a renderer's threads

Run it once, early, and write down the names. It converts bi-02's thread diagram from a claim into something you have observed.

What you should be able to identify:

  • CrRendererMain — Blink, V8, style, layout, paint, rAF
  • the compositor threadcc impl side, input handling for scroll
  • raster / worker threads — a pool
  • Chrome_ChildIOThread — Mojo message send/receive; never do work here
  • worker and worklet threads if the page uses them

Two diagnostics fall straight out of this:

  • If your breakpoint is on the compositor thread but you expected the main thread, your mental model of the subsystem is wrong — stop and fix it before reading more code.
  • If the IO thread has a deep application stack in it, that is a bug on its own.

3.9 Deep dive: memory investigation

Different tools than performance, and worth naming so you do not reach for the wrong one.

ToolAnswers
memory-infra tracingper-process, per-allocator breakdown over time
DevTools heap snapshotwhat is retaining this JS object (retainer chains)
chrome://memory-internalsprocess-level totals
Oilpan statisticsBlink C++ heap, by type

The bi-04 skill — reading a retainer chain — is the one that transfers. Finding that memory grew is easy; finding what is holding it is the job. In a two-heap system the chain may cross from a JS closure to a C++ node and back, which is precisely why the unified heap exists and why the snapshot can show you the path at all.


3.10 Deep dive: logging, done properly

out/Default/content_shell --enable-logging=stderr --v=1 <url>
out/Default/content_shell --enable-logging=stderr --vmodule=html_document_parser=2,style_engine=1

--vmodule is the important one: per-module verbosity. Global --v=2 in Chromium produces an unreadable firehose; scoping to the two files you care about produces something you can actually read.

In code:

VLOG(1) << "state=" << state;            // level-gated, compiled in
DVLOG(1) << ...                          // debug builds only
LOG(ERROR) << ...                        // always
TRACE_EVENT("blink", "MyThing");         // shows in the timeline, correlates across threads

Prefer a TRACE_EVENT over a LOG when the question is when or how long; prefer VLOG when the question is what value. The reason is simple: a log line is a string in a stream with no timeline, no thread correlation, and no flow arrows.


4. Anti-Patterns

Debugging with --single-process. Collapses the boundaries you are studying and is not maintained to production quality.

--no-sandbox as a default. Changes the security model; never draw behavioural conclusions from it.

Adding printf and rebuilding chrome. Build content_shell, or better, add a trace event.

Believing a stack trace from a symbol_level = 0 build.

Long debugging sessions without writing anything down. In a codebase this size, an un-recorded finding is a finding you will re-derive.


5. Trade-offs

Tracing vs debugging. Tracing perturbs timing least and shows the system; the debugger shows exact state and destroys timing. Timing-dependent bugs are usually tracing problems.

DCHECK on vs off. On: earlier, clearer failures. Off: closer to shipping behaviour and faster. For learning, on.

Symbols vs disk/build time. bi-00-roadmap/docs/chromium-build-debug-trace.md recommends symbol_level = 1 with blink_symbol_level = 2 as the compromise.


6. Lab

  1. Thread census. Attach to a renderer, thread backtrace all, and write down every thread name. Map each to bi-02's diagram. Note any you did not expect.
  2. Pipeline breakpoints. Set breakpoints at DOM creation, style recalc, layout, and paint. Load a trivial page. Record the order and the call stacks connecting them. This is the rendering pipeline, observed rather than read.
  3. Conditional breakpoint. Break in setAttribute only when the attribute is class.
  4. Trace-to-source. Record a trace, pick three unfamiliar event names, and find each in the source. Write down what each one measures.
  5. Source-to-trace. Add your own TRACE_EVENT to a Blink function you studied in bi-03 or bi-07. Rebuild that target only. Confirm it appears.
  6. Break an invariant. Deliberately violate a DCHECK (for example by removing a guard), rebuild, and observe the assertion fire. Restore.

Deliverable: the thread census, the pipeline call-stack chain from (2), and the diff from (5).


7. Failure Lab

  1. Build with symbol_level = 0 and try to debug. Experience it once; it explains the flag.
  2. Set a Blink breakpoint on the browser process and observe it never hit. Diagnose from first principles.
  3. Break in a function that gets inlined. Find the workaround.
  4. Attach to a renderer without --disable-hang-monitor and wait 30 seconds. Explain the kill.

8. References

  • docs/mac/debugging.md, docs/lldbinit.md in your checkout.
  • docs/ tracing documentation; Perfetto UI docs.
  • base/trace_event/ — the TRACE_EVENT macros themselves.
  • bi-00-roadmap/docs/chromium-build-debug-trace.md — your operational reference.

9. Principal Engineer Review

  1. You have a rendering bug that reproduces once in 50 loads. Argue for tracing over a debugger, then describe the trace you would capture and what you would look for.

  2. DCHECK is compiled out in release. Give a scenario where this means users hit a bug that would have been caught, and say what Chromium does about that class of risk.

  3. A teammate debugs with --single-process because it is convenient. Explain the risk with a concrete example of a conclusion it would make them draw wrongly.

  4. Design the minimum instrumentation you would add to a subsystem you own so that a stranger could diagnose it from a trace alone. What are you deliberately not instrumenting?

  5. Trace events are string identifiers in source. Argue this coupling is good design; then name its failure mode at scale.

  6. You must hand off a hard bug at end of day. Write the note: what you observed, what you ruled out, what you would do next, and how someone reproduces it.

  7. When is adding a log statement better engineering than setting a breakpoint? Give two cases.

  8. You are asked to reduce a team's mean time-to-diagnose for rendering bugs. What do you change first — tooling, process, or knowledge — and how would you know it worked?

References — bi-12-debugging-tracing

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • docs/mac/debugging.md, docs/lldbinit.md in your checkout.
  • docs/ tracing documentation; Perfetto UI docs.
  • base/trace_event/ — the TRACE_EVENT macros themselves.
  • bi-00-roadmap/docs/chromium-build-debug-trace.md — your operational reference.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-12 — Analysis

The governing principle

Source reading answers "what could happen." Tracing answers "what did happen."

In a codebase with generated code, feature flags, and platform branches, that gap is wide enough to invalidate confident conclusions. Every claim in a bug report should be labelled with which of the two produced it.

Instrument selection

SituationInstrumentWhy
No hypothesistracinga breakpoint requires knowing where to put it
Does this run?tracing / one LOGa breakpoint costs 100×
How often, how long?tracingthe debugger destroys the timing
How did I get here?debugger backtracenothing else gives the stack
A sequence over many iterationsloggingstepping 400 times is not a plan
Timing-dependent or racytracing onlybreakpoints change the schedule and hide the bug
Cross-process orderingtracing with flow eventsthe only tool showing both sides

Why a breakpoint fails to hit

Three causes, in frequency order:

  1. Wrong process — Blink runs in a renderer, not the browser process you launched.
  2. Inlined — set it on the caller, break on a line, or fall back to a trace event.
  3. Feature-gated — the path is behind a RuntimeEnabledFeature that is off.

The bridge that makes tracing a navigation tool

Trace events are TRACE_EVENT macros with literal string names, so:

see an event in a trace  →  grep the exact name  →  land in the code that ran
add a TRACE_EVENT        →  rebuild that target  →  watch it appear

Bidirectional. This is why tracing sits at rung 3 of the ladder rather than being a specialist performance tool, and why devtools.timeline being a trace category matters: the DevTools panel is a curated view over data you can widen.

The build flag that changes what you learn

dcheck_always_on = true keeps Blink's invariants live in a release build. Breaking one then produces a message naming the invariant instead of a confusing misrender ten stages later. For a learning checkout this is the single highest-value flag.

Execution — bi-12-debugging-tracing

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Practice

Concrete procedures live in bi-00-roadmap/docs/chromium-build-debug-trace.md §4–§5: lldb setup and the in-tree pretty-printers, attaching to a paused renderer, conditional breakpoints, thread backtrace all, and the tracing categories worth recording.

Do thread backtrace all once, early. It converts bi-02's thread diagram from a claim into something you have seen.


6. Lab

  1. Thread census. Attach to a renderer, thread backtrace all, and write down every thread name. Map each to bi-02's diagram. Note any you did not expect.
  2. Pipeline breakpoints. Set breakpoints at DOM creation, style recalc, layout, and paint. Load a trivial page. Record the order and the call stacks connecting them. This is the rendering pipeline, observed rather than read.
  3. Conditional breakpoint. Break in setAttribute only when the attribute is class.
  4. Trace-to-source. Record a trace, pick three unfamiliar event names, and find each in the source. Write down what each one measures.
  5. Source-to-trace. Add your own TRACE_EVENT to a Blink function you studied in bi-03 or bi-07. Rebuild that target only. Confirm it appears.
  6. Break an invariant. Deliberately violate a DCHECK (for example by removing a guard), rebuild, and observe the assertion fire. Restore.

Deliverable: the thread census, the pipeline call-stack chain from (2), and the diff from (5).


7. Failure Lab

  1. Build with symbol_level = 0 and try to debug. Experience it once; it explains the flag.
  2. Set a Blink breakpoint on the browser process and observe it never hit. Diagnose from first principles.
  3. Break in a function that gets inlined. Find the workaround.
  4. Attach to a renderer without --disable-hang-monitor and wait 30 seconds. Explain the kill.

Observation — bi-12-debugging-tracing

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-12-debugging-tracing

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Thread census captured from thread backtrace all
  • Breakpoints at DOM creation, style recalc, layout, paint; call-stack chain recorded
  • Three unfamiliar trace event names found in source
  • Own TRACE_EVENT added, target rebuilt, event observed
  • A DCHECK deliberately violated and observed firing, then restored

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-12 — Broader Ideas

Instrument your own systems the way Chromium instruments itself

Trace events are literal strings in the source, so a timeline entry greps straight to the code that produced it. Structured, correlated, cross-process, low-overhead, and enabled by category.

Your equivalent is OpenTelemetry spans — and the design questions are the same:

  • Are span names greppable identifiers, or interpolated strings? (Interpolated names destroy the bridge back to source.)
  • Are they categorised, so you can enable a subset?
  • Do they carry flow/parent links across process boundaries?
  • Is the overhead low enough to leave on?

The minimum instrumentation test: could a stranger diagnose this subsystem from a trace alone, without reading the code? If not, you are relying on tribal knowledge.

Perturbation is a property of your instrument

A debugger destroys timing; tracing perturbs it far less; a log line has no timeline at all. Any bug whose existence depends on ordering is a tracing problem, and reaching for a breakpoint makes it disappear — the most frustrating way to lose an afternoon.

The same caution applies to your own observability: a render-count tracker that holds references leaks, and a profiler that adds 40% overhead is measuring itself.

Handoff discipline

The debugging exercise's "write the handoff note" is not busywork. In a codebase this size, an unrecorded finding is a finding you will re-derive. The note format — observed, ruled out, next step, reproduction — is worth standardising on your team.

Next

bi-13 turns reproduction into a test; bi-15 uses tracing as the primary instrument for all eight vertical traces.

Concepts — Chromium Test Architecture and Web Platform Tests

Phase 6 · Spec areas §22 (test architecture), §23 (WPT). Requires a working build for the runnable parts.


1. Why a Principal Engineer needs this

1. A Chromium contribution without a test will not land. Reviewers will ask for one, at the right layer, in the right style. Knowing which layer is a design judgement, not a formality.

2. WPT is the cross-browser contract, and it is where interop is negotiated. Reading a WPT tells you what all browsers agreed to. Reading Chromium's expected failures tells you where they currently disagree — which is a map of tractable contributions and of real risks to your products.

3. Choosing a test layer is a transferable skill. Unit vs integration vs end-to-end is the same decision you make in application code, with the stakes raised and the vocabulary changed.


2. Mental Model

2.1 The layers

LayerShapeRuns inGood for
Unit testfoo_test.cc (Blink) / foo_unittest.cc (rest)one process, no browseralgorithms, data structures, invariants
Browser test*_browsertest.ccreal multi-process browsercross-process behaviour, navigation, security
Web testHTML + expected output, under web_tests/content_shellrendering and DOM behaviour
WPTHTML/JS under web_tests/external/wpt/any browserspec conformance, cross-browser

The Blink/non-Blink naming split (_test.cc vs _unittest.cc) is a real search hazard — searching only one form silently halves your results (bi-01 Technique 6).

2.2 Web tests and how they assert

Two flavours worth distinguishing:

  • testharness.js tests — JavaScript assertions; the output is a pass/fail list. Prefer these: they state intent, and they are portable across browsers.
  • Reference tests ("reftests") — render the test and a reference page and require the pixels to match. Right when the behaviour is visual and cannot be asserted in JS.

There are also pixel/expectation-file tests, which compare against a stored baseline. These are the most brittle: a legitimate rendering change requires rebaselining, and a baseline can encode a bug as "expected." Prefer testharness.js, then reftests, then pixel baselines — in that order, for the same reason you prefer behavioural assertions to snapshots in application code.

2.3 Expected failures are declarative

Chromium does not delete tests it fails. It records them, with bug links, in third_party/blink/web_tests/TestExpectations.

TestExpectations is a map of Chromium's known interop gaps. Every entry is a documented, accepted, currently-wrong behaviour with a bug attached.

For this track that file has two uses: it is the richest source of tractable first contributions (§24 rung 3), and it is a genuine input to product risk assessment — if your app depends on a behaviour listed there, you have a supported reason to expect trouble.

2.4 WPT is bidirectional

WPT lives upstream and is imported into Chromium; tests you write upstream reach every browser, and tests added in Chromium's WPT directory are exported. That means a WPT contribution improves interoperability rather than only Chromium — which is often the highest-leverage first contribution available, and it does not require C++.

2.5 The bug-fix workflow

reproduce  ->  identify the correct layer  ->  minimal failing test
   ->  fix  ->  verify  ->  run surrounding tests

"Identify the correct layer" is the step that separates a good contribution from a rejected one. A rendering bug fixed in Blink with only a unit test on a helper function does not demonstrate the user-visible behaviour is fixed. Conversely, a pixel test for an algorithmic off-by-one is brittle and slow. The test should fail for the reason the bug exists.


3. Practice

Commands and paths: bi-00-roadmap/docs/chromium-build-debug-trace.md §6.

Without a build you can still do most of the reading work here: locate tests, read TestExpectations, read WPT sources, and map spec → test → implementation.


3.5 Deep dive: TestExpectations, read properly

Measured 2026-08-10: 9,418 lines. That is not a bug list, it is an institutional memory.

The format

# tags: [ Android Fuchsia Linux Mac Mac13 Mac14 Mac15 Mac26 Win Win10.20h2 Win11 Webview iOS26-simulator ... ]
# tags: [ Release Debug ]
# results: [ Timeout Crash Pass Failure Skip ]

crbug.com/123456 [ Mac ] fast/forms/some-test.html [ Failure ]

Every entry carries a bug link, an optional platform/config tag set, the test path, and an expected result. So the file is a queryable database: which behaviours are wrong, on which platforms, with what tracking issue.

Note the platform tags include specific OS versions (Mac13, Mac15, Win11-arm64). Expectations are frequently version-specific, which is a direct admission that browser behaviour varies by OS version — usually via system libraries for fonts, text shaping, and media.

The policy that is worth stealing

The file's own header states it plainly:

"Single [ Skip ] expectation is not allowed in this file. Normally we should not skip a test because it's failing or flaky. We should add failure or flaky expectations instead, so that they will still run on bots, and we can collect data about their flakiness and update their expectations accordingly."

Read that twice. The rule is: a failing test keeps running. You record that it fails; you do not stop executing it.

The reasons are worth enumerating because they apply to any large test suite:

  1. A skipped test yields no data. A test marked Failure that unexpectedly passes is reported — so the system tells you when someone accidentally fixed it.
  2. Flakiness is measurable only if the test runs. Skip destroys the signal you would need to decide whether it is flaky or broken.
  3. A skipped test rots silently; a failing-but-running test stays honest about its state.

There are narrow exceptions — NeverFixTests for things genuinely out of scope, SlowTests, and VirtualTestSuites' exclusive_tests for tests only meaningful under a virtual suite — and they are named and separated rather than being ad-hoc Skips.

Carry this into your own org. The instinct on a red test is to skip it. The better default is: mark it expected-to-fail, keep it running, attach a bug. Chromium runs one of the largest test suites in the world on this policy.

How to mine it

# What is currently failing in an area you have studied?
grep -n "html/parsing\|fast/table" third_party/blink/web_tests/TestExpectations | head -30

# Which entries have bug links (i.e. are tracked)?
grep -cE "crbug|issues\.chromium" third_party/blink/web_tests/TestExpectations

# Platform-specific breakage
grep -n "\[ Mac \]" third_party/blink/web_tests/TestExpectations | head

Each hit is a documented, accepted, currently-wrong behaviour with an owner-adjacent bug. That is your bi-14 candidate pool, and it is also real product-risk intelligence: if your application depends on a behaviour listed there, you have a supported reason to expect trouble.


3.6 Deep dive: virtual test suites

VirtualTestSuites lets the same test files run again under different flags — a feature flag on, a different compositing mode, a new algorithm. That is how a large behavioural change is validated against the existing corpus before it ships.

The idea generalises well: rather than forking tests for the new implementation, run the existing tests under the new configuration and record only the deltas. If you are ever migrating a large system behind a flag, this is the shape of the test strategy you want — the alternative (duplicating the suite) doubles maintenance and guarantees drift.


3.7 Deep dive: choosing a test layer, with the failure modes

LayerRuns inCostCatchesMisses
Unit (_test.cc / _unittest.cc)one processmsalgorithm and invariant bugsintegration, real DOM behaviour
Browser test (_browsertest.cc)real multi-process browserseconds+cross-process, navigation, securityfine-grained algorithm cases
Web test (web_tests/)content_shellfastDOM and rendering behaviournon-web-exposed internals
WPT (web_tests/external/wpt/)any browserfastspec conformance, interopChromium-specific internals

The decision rule that matters:

The test must fail for the reason the bug exists.

A rendering bug fixed in Blink with only a unit test on a helper does not demonstrate the user-visible behaviour is fixed — and a reviewer will say so. Conversely, a pixel test for an off-by-one in an algorithm is slow, brittle, and will be rebaselined away by someone in six months.

Preference order within web tests

  1. testharness.js — JS assertions, portable across browsers, states intent.
  2. Reference tests — render test and reference, require a pixel match. Correct when the behaviour is visual and cannot be asserted in JS. Survives unrelated rendering changes, because both sides change together.
  3. Pixel/baseline tests — compare against a stored image. Catches everything and breaks constantly; a baseline can silently encode a bug as "expected."

This is the same ordering as behavioural assertions over snapshots in application testing, for the same reason: a snapshot asserts "it looks like this," not "it is correct."


3.8 Deep dive: WPT is bidirectional, and that is the leverage

WPT lives upstream and is imported into Chromium; tests added in Chromium's WPT directory are exported back. So:

  • A WPT you write improves every browser's conformance signal, not just Chromium's.
  • It requires no C++ — HTML and JavaScript.
  • It lands through a lighter process than a Blink change.

For an application engineer this is frequently the highest-leverage open-source contribution available, and it is the right alternative if a docs-only first CL feels too trivial (bi-14 rung 1).

The workflow that turns confusion into a contribution:

you hit a cross-browser inconsistency
  → read the spec; decide what SHOULD happen
  → search WPT for existing coverage
  → if none: write a testharness.js test asserting the spec behaviour
  → run it in multiple engines; record who fails
  → land the test upstream

You have now converted "browsers disagree and it is annoying" into a durable, shared artifact that makes the disagreement visible to everyone. That is a genuinely Principal-level move: you changed the information available to the whole ecosystem, not just your own codebase.


3.9 Deep dive: flakiness as a first-class concern

Chromium treats flakiness as data, not as an annoyance to be suppressed. Expectations can record flaky results; bots collect statistics; tests that become reliably-passing get their expectations tightened.

The lesson for a team you lead:

  • A flaky test is a signal about the system, not only about the test. Ordering assumptions, timing dependence, and shared state are real product bugs that happened to surface in CI.
  • Suppression without measurement is how suites die. If you disable, you must record and revisit; Chromium's answer is "keep running it and record the expectation."
  • Deterministic reproduction is the deliverable, not "it passed on retry." fw-08 and fw-09 make the same demand of race-condition labs: if your reproduction is "click fast," you have observed the bug, not reproduced it.

4. Anti-Patterns

Writing a pixel test when a testharness.js test would do.

Rebaselining to make a test pass. Rebaselining is correct only when the new rendering is correct. Establish that first.

Testing implementation details of a helper instead of the specified behaviour.

Adding a test that passes before your fix. It proves nothing. Run it against unpatched source first — always.

Assuming a failing WPT is a Chromium bug. It may be a spec disagreement, an out-of-date imported test, or a deliberate deviation. Check the expectation's bug link.


5. Trade-offs

Fast unit tests vs realistic browser tests. Browser tests catch integration failures and cost minutes; unit tests are seconds and can pass while the feature is broken.

Reftests vs pixel baselines. Reftests express intent ("these two should look the same") and survive unrelated rendering changes; baselines catch everything and break constantly.

Declarative expected-failures vs deleting tests. Recording failures keeps them visible and fixable, at the cost of a large file that can quietly normalise breakage.


6. Lab

  1. Layer identification. For five behaviours you studied in bi-03, bi-04, bi-07, bi-08, bi-09, find the test at each layer that covers it, or establish that none exists.
  2. Run each kind once. A Blink unit test, a web test directory, and a WPT.
  3. Write a testharness.js test for the parsing lab's Case B. Verify it passes on Chromium and reason about whether it should pass on other engines.
  4. Mine TestExpectations. Find three currently-failing WPTs in areas you have studied. For each: read the bug, read the spec, and write one paragraph on whether it looks tractable. Keep this list — it is your bi-14 candidate pool.
  5. The break-and-observe loop (§23). Pick a behaviour, find the WPT, find the Blink implementation, deliberately break it, watch the test fail, restore. This proves you have connected spec → test → implementation.

Deliverable: the candidate list from (4), with a tractability judgement each.


7. Failure Lab

  1. Write a test that passes without your change. Explain how you would have caught this.
  2. Rebaseline a pixel test to accept a wrong rendering. Notice how easy it was — this is the argument for preferring reftests.
  3. Fix a bug at the wrong layer: patch a symptom in a caller rather than the cause. Write the review comment you would expect to receive.

8. References

  • docs/testing/web_tests.md and the web-test expectations documentation in your checkout.
  • third_party/blink/web_tests/TestExpectations.
  • web-platform-tests documentation; testharness.js API.
  • docs/testing/ generally — browser tests, unit tests, flakiness policy.

9. Principal Engineer Review

  1. Given a rendering bug, how do you decide the test layer? Give your decision procedure and a case where it is genuinely ambiguous.

  2. TestExpectations records known failures rather than deleting tests. Argue this is superior; then describe how it decays and what you would do about it.

  3. A WPT fails in Chromium and passes in two other engines. Enumerate what this could mean, ranked, and how you distinguish them.

  4. Pixel tests catch everything and break constantly. Argue for banning them; then defend the cases where nothing else works.

  5. You are asked to raise a team's confidence in a rendering-heavy product. Design the test strategy across layers, and say what you deliberately will not test.

  6. A contributor submits a correct fix with no test. Write the review comment.

  7. WPT contributions improve all browsers. Argue this is the highest-leverage work an application engineer can do; then give the strongest counterargument.

  8. Your product depends on a behaviour listed as an expected failure in Chromium. Walk through what you tell your team, and what you change.

References — bi-13-tests-wpt

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • docs/testing/web_tests.md and the web-test expectations documentation in your checkout.
  • third_party/blink/web_tests/TestExpectations.
  • web-platform-tests documentation; testharness.js API.
  • docs/testing/ generally — browser tests, unit tests, flakiness policy.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-13 — Analysis

Required invariants

  1. A test must fail for the reason the bug exists. A fix at the wrong layer with a passing test proves nothing.
  2. Run the test against unpatched source first. A test that passes before your fix is not a test.
  3. A failing test keeps running. Chromium forbids a bare [ Skip ] so that flakiness data keeps accruing and an accidental fix is detected.
  4. Expectations carry a bug link. An entry without one is an untracked known-broken behaviour.
  5. WPT is the cross-browser contract. A Chromium-only test cannot express interop.

The policy worth stealing

"Normally we should not skip a test because it's failing or flaky. We should add failure or flaky expectations instead, so that they will still run on bots, and we can collect data about their flakiness."

Three reasons this beats skipping:

  1. A skipped test yields no data; an expected-failure that unexpectedly passes is reported.
  2. Flakiness is measurable only if the test runs.
  3. Skipped tests rot silently; failing-but-running tests stay honest.

Measured: TestExpectations is 9,418 lines, with platform tags down to specific OS versions (Mac13, Win11-arm64). Version-specific expectations are an admission that behaviour varies by OS version — usually through system libraries for fonts, shaping, and media.

Test-layer selection

LayerCatchesMisses
Unitalgorithms, invariantsintegration, real DOM
Browser testcross-process, navigation, securityfine-grained algorithm cases
Web testDOM and rendering behaviournon-web-exposed internals
WPTspec conformance, interopChromium-specific internals

Preference within web tests: testharness.js → reftest → pixel baseline. Same ordering as behavioural assertions over snapshots in application testing, and for the same reason: a snapshot asserts "it looks like this," not "it is correct."

Failure modes

MistakeConsequence
Rebaseline to make it passencodes a bug as expected
Pixel test for an algorithmic bugbrittle; rebaselined away in six months
Unit test for a rendering bugdoes not demonstrate the user-visible fix
Assume a failing WPT is a Chromium bugmay be a spec disagreement or a stale imported test

Execution — bi-13-tests-wpt

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


2.3 Expected failures are declarative

Chromium does not delete tests it fails. It records them, with bug links, in third_party/blink/web_tests/TestExpectations.

TestExpectations is a map of Chromium's known interop gaps. Every entry is a documented, accepted, currently-wrong behaviour with a bug attached.

For this track that file has two uses: it is the richest source of tractable first contributions (§24 rung 3), and it is a genuine input to product risk assessment — if your app depends on a behaviour listed there, you have a supported reason to expect trouble.

3. Practice

Commands and paths: bi-00-roadmap/docs/chromium-build-debug-trace.md §6.

Without a build you can still do most of the reading work here: locate tests, read TestExpectations, read WPT sources, and map spec → test → implementation.


6. Lab

  1. Layer identification. For five behaviours you studied in bi-03, bi-04, bi-07, bi-08, bi-09, find the test at each layer that covers it, or establish that none exists.
  2. Run each kind once. A Blink unit test, a web test directory, and a WPT.
  3. Write a testharness.js test for the parsing lab's Case B. Verify it passes on Chromium and reason about whether it should pass on other engines.
  4. Mine TestExpectations. Find three currently-failing WPTs in areas you have studied. For each: read the bug, read the spec, and write one paragraph on whether it looks tractable. Keep this list — it is your bi-14 candidate pool.
  5. The break-and-observe loop (§23). Pick a behaviour, find the WPT, find the Blink implementation, deliberately break it, watch the test fail, restore. This proves you have connected spec → test → implementation.

Deliverable: the candidate list from (4), with a tractability judgement each.


7. Failure Lab

  1. Write a test that passes without your change. Explain how you would have caught this.
  2. Rebaseline a pixel test to accept a wrong rendering. Notice how easy it was — this is the argument for preferring reftests.
  3. Fix a bug at the wrong layer: patch a symptom in a caller rather than the cause. Write the review comment you would expect to receive.

Observation — bi-13-tests-wpt

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-13-tests-wpt

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Five behaviours mapped to their tests at each layer (or absence established)
  • One unit test, one web test directory, one WPT run
  • testharness.js test written for the parsing lab's Case B
  • Candidate pool built from TestExpectations with tractability judgements
  • Break-and-observe loop completed: spec -> test -> implementation -> break -> restore

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-13 — Broader Ideas

The policy to steal on Monday

A failing test keeps running. Record the expectation; do not skip.

The instinct on a red test is to disable it. Chromium runs one of the largest suites in the world on the opposite rule, because a skipped test yields no data, cannot be measured for flakiness, and rots silently — while an expected-failure that unexpectedly passes is reported.

Implementing this in your own CI needs three things: an expectations file with bug links, a runner that reports unexpected passes, and a review policy that treats an expectation as debt rather than a resolution.

Virtual test suites, and how to migrate safely

Running the existing suite under a new configuration — a feature flag on, a new algorithm — and recording only the deltas is the right shape for any large behavioural migration. The alternative, forking the suite, doubles maintenance and guarantees drift.

If you are ever rewriting something behind a flag, this is the test strategy.

WPT as leverage

A test written upstream improves every browser's conformance signal, requires no C++, and lands through a lighter process. For an application engineer, converting "browsers disagree and it is annoying" into a durable shared artifact is frequently the highest-leverage open-source contribution available.

It is also a genuinely Principal-level move: you changed the information available to the whole ecosystem, not just your own codebase.

Flakiness is a signal about the system

Ordering assumptions, timing dependence, and shared state are product bugs that happened to surface in CI. "It passed on retry" is not a diagnosis. fw-08 and fw-09 make the same demand: deterministic reproduction is the deliverable.

Next

bi-14 turns a TestExpectations entry into a contribution.

Concepts — Contributing to Chromium

Phase 6 · Spec areas §24 (contribution workflow), §49 (OSS portfolio). Requires a working build.

Verify everything here against current upstream docs/ before acting. Contribution process changes, and a stale instruction wastes a reviewer's time — which is the one cost that matters in an open-source contribution.


1. Why a Principal Engineer needs this

Landing a change in Chromium is a credential, but the reason it belongs in this track is narrower and better: it forces every other skill to be real. You cannot land a fix without locating the subsystem, understanding the invariant, writing the right test, and defending the design to someone who owns the code. Reading Blink is unfalsifiable; a CL is not.

It also teaches something you cannot learn from your own codebase: how to make a change in a system you do not own, for reviewers who owe you nothing. That skill transfers directly to cross-org work at Principal level.


2. Mental Model

2.1 The pipeline

find a bug  ->  reproduce  ->  locate subsystem  ->  minimal failing test
   ->  fix  ->  local tests  ->  git cl upload  ->  Gerrit review
   ->  OWNERS approval  ->  presubmit + try jobs  ->  CQ  ->  landed

Facts that shape the work:

  • Issue tracker: issues.chromium.org. (Chromium's own docs/contributing.md still contains a bugs.chromium.org reference — treat in-tree docs as authoritative but not infallible, and prefer what the live site does.)
  • CLA required; first-time contributors add themselves to AUTHORS.
  • Review happens in Gerrit (chromium-review.googlesource.com), not GitHub PRs. One change = one CL, revised as patchsets, not as new commits.
  • OWNERS approval is required per affected directory. git cl owners suggests reviewers. Committers need one Code-Review +1; non-committers need two.
  • Presubmit runs on upload; try jobs compile and test across platforms. A CQ dry run is how you check before asking for a human's time.

2.2 Choosing the change

The ladder from the specification, with what each actually teaches:

RungChangeTeaches
0Build Chromiumtoolchain, targets, iteration
1Docs or test-onlythe process end to end, at low risk
2Small isolated correctness fixlocating a cause; minimal reproduction
3Blink behaviour bug + regression testspec → test → implementation, the core loop
4Small rendering/style/layout improvementsubsystem depth, performance argument
5Cross-component changeMojo boundaries, multiple OWNERS, design review

Do rung 1 before rung 2, even though it feels trivial. The first CL's difficulty is entirely process — CLA, git cl, Gerrit conventions, presubmit, try-job failures unrelated to your change. Learning that with a doc fix costs a day; learning it while also defending a behaviour change costs a week and a reviewer's patience.

2.3 Where to find work

  • TestExpectations (bi-13) — documented, accepted, currently-wrong behaviours with bugs attached. This is the best-quality source of rung-3 candidates in the tree.
  • Failing WPTs in a subsystem you have studied.
  • The issue tracker, filtered to components you know. Look for hotlists aimed at new contributors; verify the current label names on the live tracker rather than trusting any written list, including this one.
  • A bug you actually hit. The strongest motivation and the best reproduction.

Choose a bug in a subsystem you have already studied in this track. A contribution is not the place to learn the subsystem.

2.4 What reviewers actually check

In rough order:

  1. Is the behaviour correct per the specification?
  2. Is there a test that fails without the fix?
  3. Is it at the right layer, and does it fit the subsystem's design?
  4. Ownership/lifetime correctness (bi-06).
  5. Style, naming, and git cl format.
  6. Does it need a flag, a metric, or a spec discussion first?

Item 6 catches people out: a behaviour change visible to the web may need a launch process, metrics, and standards-body agreement — not because Chromium is bureaucratic, but because unilateral behaviour changes break sites and interop. If your fix changes what the web sees, expect the conversation to be about compatibility, not correctness.


3. Practice

Verify against your checkout's docs/contributing.md and the linked process docs. The mechanical parts (git cl upload, git cl format, git cl owners, CQ dry run) are stable; the surrounding policy is not.

Keep a record per attempt — the specification asks for exactly this, and it is what turns a contribution into learning:

bug · reproduction · spec · suspected subsystem · source path · call path
· test · proposed fix · reviewer feedback · architectural lesson

3.5 Deep dive: the web-visible change process

Rung 3 and above frequently touch behaviour the web can observe. That is a different process from a bug fix, and not knowing it is the fastest way to have a technically-correct CL stall for months.

The shape of it:

idea  ->  spec discussion (WHATWG/W3C issue, or a explainer)
      ->  design doc, if non-trivial
      ->  UseCounter metrics: how much of the web actually does this?
      ->  intent-to-prototype  ->  behind a flag  ->  origin trial (sometimes)
      ->  intent-to-experiment / intent-to-ship on blink-dev
      ->  API owners' approval
      ->  ship, with metrics watching for regressions

The single most important artefact in that list is UseCounter. Blink instruments feature usage and aggregates it across the web, so questions like "how many page loads use this quirk?" have a real number attached. A removal proposal without usage data will not proceed.

This is the part that most surprises engineers arriving from product work. In a product you change behaviour and watch your own metrics. Here, "correct per spec" is necessary and not sufficient — you must also show the web will survive it. Compatibility is a constraint of equal weight to correctness, and arguing otherwise marks you as someone who has not internalised the problem.

If your fix changes what pages observe, expect the review conversation to be about compatibility and data, not about whether you read the spec correctly.


3.6 Deep dive: what makes a CL easy to approve

Reviewers are rationing attention across many changes. Optimise for their time, not yours.

Structural:

  • One change per CL. A fix plus a refactor plus a rename is three CLs.
  • Small. A first CL over ~200 lines is asking a stranger for a large favour.
  • Test first in the description: state what fails without the fix.
  • git cl format before every upload; never spend credibility on whitespace.

In the description:

  • What the bug is, in one sentence a non-expert can follow.
  • Why this layer is the right place to fix it — pre-empting the most likely objection.
  • What you considered and rejected. This converts "why didn't you just…" into "they already thought about it."
  • Bug: footer, always.

In the code:

  • Match the newest pattern in the file, not the nearest (bi-06: migrations in progress).
  • Add a DCHECK stating any new invariant.
  • Correct handle types (Member vs raw_ptr vs unique_ptr) — the most likely substantive comment.

Handling review feedback

  • Reply to every comment, even if only "Done."
  • If you disagree, say so once, with reasoning, and ask a question rather than restating.
  • If a reviewer wants a design change you think is wrong, ask what failure mode they are protecting against. Usually they know something you do not; occasionally the question reveals they misread. Either outcome is progress.
  • Going quiet is worse than being wrong. An abandoned CL costs the reviewer more than a bad one.

3.7 Deep dive: how to pick a first bug that will actually land

Bad first bugs share a shape: they are interesting. Interesting bugs are unfixed because they are hard, contested, or blocked on a design decision.

A good first bug:

  • is in a subsystem you have already studied in this track,
  • has a reliable reproduction,
  • has a clear expected behaviour, ideally spec-defined or in TestExpectations,
  • is small — one file, one behaviour,
  • is not load-bearing for a feature someone is actively rewriting (check git log for recent churn in the directory — heavy recent activity means you will conflict with a bigger change).

That last check takes thirty seconds and saves weeks:

git log --since=90.days --oneline -- <directory> | wc -l

A directory with a hundred commits in ninety days is being actively rewritten. Pick elsewhere for your first CL.


3.8 Deep dive: the record to keep, and why

The specification asks for a record per contribution. Here is why each field earns its place:

FieldWhy
bugthe problem, as the project sees it
reproductionthe thing you will lose first if you do not write it down
specwhat should happen, independent of any implementation
suspected subsystemyour prediction — score it later (bi-01 discipline)
source path + call paththe navigation, so it transfers
testwhat would have caught this
proposed fixincluding alternatives rejected
reviewer feedbackthe highest-value field
architectural lessonwhat you now believe that you did not before

Reviewer feedback is the field to protect. It is the only part of this you cannot generate yourself: a domain expert telling you what you missed, for free, on your own work. Most engineers read it, fix the code, and forget it. Writing it down converts a code review into a durable lesson.

And write the record for rejected CLs too. A rejected CL with a clear architectural lesson is a successful lab; the goal of rungs 1–3 is competence, not a merge count.


4. Anti-Patterns

A first CL that is large. Reviewers ration attention; so should you.

Fixing a symptom at a call site because the real cause is in unfamiliar code.

Arguing with a reviewer about style. Run git cl format and spend your credibility on substance.

Changing web-visible behaviour without checking compatibility. Expect to be asked for usage metrics and interop evidence.

Going quiet after review feedback. An abandoned CL costs the reviewer more than a bad one.

Starting at rung 3 to skip the boring parts. The boring parts are where the process failures live.


5. Trade-offs

Small safe changes vs meaningful ones. Rung 1 teaches process and nothing else. Rung 3 teaches the subsystem and risks stalling. Do both, in order.

Fixing vs filing. A well-written bug with a minimal reproduction is a real contribution and sometimes the better one, especially when the fix requires design agreement you cannot yet obtain.

Chromium vs WPT. A WPT contribution needs no C++, improves every browser, and lands faster. For an application engineer it is frequently higher leverage — and it is the right rung-1 alternative if a doc fix feels too trivial.


6. Lab

  1. Rung 0 — a build that runs. (Blocked here; see the roadmap's build guide §2.0.)
  2. Rung 1 — land a docs or test-only change. Goal: complete the process once. Record every step that surprised you.
  3. Rung 2 — from your bi-13 candidate list, pick the smallest defensible correctness fix. Write the failing test first.
  4. Rung 3 — a Blink behaviour bug with a regression test, in a subsystem you studied.
  5. Maintain the record above for each, and write the architectural lesson even when the CL is rejected. A rejected CL with a clear lesson is a successful lab.

7. References

  • docs/contributing.md in your checkout — the authority.
  • docs/cl_tips.md, docs/code_reviews.md, and the OWNERS documentation.
  • issues.chromium.org — the live tracker.
  • The Chromium blink-dev process for web-visible behaviour changes (intent-to-ship and friends).
  • web-platform-tests contribution guide.

8. Principal Engineer Review

  1. Your fix is correct per spec but would break a measurable fraction of sites. What happens next, and what is your role in it?

  2. A reviewer asks for a design change that doubles the work and you think it is wrong. How do you handle it, and what would change your mind?

  3. Argue that a well-written bug report is more valuable than a mediocre fix. Give the case where it is not.

  4. Chromium requires OWNERS approval per directory. Argue this scales; then name its failure mode and what you would do as an owner to mitigate it.

  5. You have one week of a team's time for open-source contribution. Chromium, WPT, or a framework? Justify from leverage, not from prestige.

  6. Your CL sits unreviewed for three weeks. Enumerate your options in order of escalation, and say which you would actually use.

  7. What makes a change "cross-component," and why is that qualitatively harder than a large change within one component?

  8. You are onboarding an engineer to contribute upstream. Design their first month. What do you deliberately not let them do yet?

References — bi-14-contribution

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

  • docs/contributing.md in your checkout — the authority.
  • docs/cl_tips.md, docs/code_reviews.md, and the OWNERS documentation.
  • issues.chromium.org — the live tracker.
  • The Chromium blink-dev process for web-visible behaviour changes (intent-to-ship and friends).
  • web-platform-tests contribution guide.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-14 — Analysis

Why this module is the falsification step

Reading Blink is unfalsifiable — you can believe you understood it. A CL is not: it forces you to locate the subsystem, identify the invariant, write the right test at the right layer, and defend the design to someone who owns the code and owes you nothing.

It also teaches the thing you cannot learn in your own codebase: making a change in a system you do not own.

Required invariants of a landable change

  1. One change per CL. A fix plus a refactor plus a rename is three CLs.
  2. A test that fails without the fix, at the layer the bug lives.
  3. The right layer. Fixing a symptom at a call site because the cause is in unfamiliar code is the most common rejection.
  4. Correct handle types and lifetimes (bi-06).
  5. Compatibility evidence if the change is web-visible. Correct per spec is necessary and not sufficient.

The step that surprises product engineers

If your fix changes what pages can observe, the conversation is about compatibility, not correctness. UseCounter metrics answer "how much of the web does this?", and a removal proposal without usage data does not proceed.

In a product you change behaviour and watch your own metrics. Here you must show the web will survive it. That is a genuinely different discipline, and treating it as bureaucracy is the fastest way to be dismissed.

Choosing a bug that will land

Bad first bugs are interesting; interesting bugs are unfixed because they are hard, contested, or blocked on a design decision. A good one is in a subsystem you have already studied, has a reliable reproduction, a spec-defined expected behaviour, is small, and is not in a directory being actively rewritten:

git log --since=90.days --oneline -- <directory> | wc -l

Thirty seconds; saves weeks.

Why the record matters

Reviewer feedback is the only part you cannot generate yourself: a domain expert telling you what you missed, for free, on your own work. Most engineers read it, fix the code, and forget it. Writing it down converts a review into a durable lesson — and a rejected CL with a clear architectural lesson is a successful lab.

Execution — bi-14-contribution

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Practice

Verify against your checkout's docs/contributing.md and the linked process docs. The mechanical parts (git cl upload, git cl format, git cl owners, CQ dry run) are stable; the surrounding policy is not.

Keep a record per attempt — the specification asks for exactly this, and it is what turns a contribution into learning:

bug · reproduction · spec · suspected subsystem · source path · call path
· test · proposed fix · reviewer feedback · architectural lesson

6. Lab

  1. Rung 0 — a build that runs. (Blocked here; see the roadmap's build guide §2.0.)
  2. Rung 1 — land a docs or test-only change. Goal: complete the process once. Record every step that surprised you.
  3. Rung 2 — from your bi-13 candidate list, pick the smallest defensible correctness fix. Write the failing test first.
  4. Rung 3 — a Blink behaviour bug with a regression test, in a subsystem you studied.
  5. Maintain the record above for each, and write the architectural lesson even when the CL is rejected. A rejected CL with a clear lesson is a successful lab.

Observation — bi-14-contribution

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-14-contribution

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Rung 1 landed (docs or test-only) — process completed once end to end
  • Rung 2 landed
  • Rung 3 landed: Blink behaviour bug + regression test
  • Full record kept per rung, incl. architectural lesson for rejected CLs

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-14 — Broader Ideas

Working in systems you do not own

This is the transferable skill, and it applies well beyond open source: another team's service, a vendor SDK, a legacy subsystem with no owner. The disciplines are identical — small changes, a test that fails first, the right layer, pre-empting the obvious objection, replying to every comment.

Compatibility as a first-class constraint

"Correct per spec" being necessary but not sufficient is the lesson most product engineers find surprising. UseCounter data decides whether a correct change can ship.

Your equivalent: before changing a shared API's behaviour, measure who depends on the current behaviour. Teams that skip this ship correct changes that break consumers, and then learn the lesson expensively. If you own a platform team, the ability to answer "how many callers rely on this?" is worth building before you need it.

Reviewer attention is the scarce resource

Everything about CL hygiene — small, one concern, formatted, tested, with rejected alternatives stated — is optimising someone else's time, not yours. That reframing improves code review inside your company immediately.

The record is the point

Reviewer feedback is the only input you cannot generate yourself: a domain expert telling you what you missed, on your own work, for free. Most engineers fix the code and forget the lesson.

A rejected CL with a clear architectural lesson is a successful lab. Rungs 1–3 are about competence, not merge count.

Next

bi-16 asks you to state honestly which mastery criteria you met — including this one, if the toolchain blocked it.

Concepts — Cross-Layer Vertical Traces

The join point. Spec areas §25 (archaeology), §43 (cross-layer traces). Prerequisites: accumulate across Phases 2–5; each trace has its own gate.


1. Why this is the centre of the track

Every other module studies one layer. This module is where the layers become one system. The capability being built is specific and testable:

Given any operation a web developer can perform, trace it downward until you run out of mechanism — and know where you ran out.

The last clause matters more than the first. A trace that stops at "and then Blink handles it" is not a failure; a trace that stops there without noticing is. The gap is the next thing to study, which is why every trace records its deepest resolved layer.


2. The trace template

Application code
   -> Framework (scheduler, reactivity, reconciliation)
   -> DOM / Web API surface
   -> Binding layer (IDL, V8 wrapper, conversion, checks)
   -> Blink (which subsystem, which data structure)
   -> Chromium (which process, which thread, any Mojo hop)
   -> Compositor (does it get here? what changes?)
   -> GPU (raster? draw only?)
   -> Pixels

For every trace, answer:

  1. Which layers are involved, and which are skipped?
  2. Which thread and process is each step on?
  3. What is deferred vs immediate?
  4. What would make this operation dramatically more expensive?
  5. What evidence did you use at each layer — source, trace, or debugger?

Question 1's second half is the valuable one. Knowing that transform skips layout and paint is worth more than knowing what it does do.


3. Method

Use all three instruments, in bi-01's ladder order:

  • Tracing first to find out what actually happened and on which thread.
  • Source to find the mechanism.
  • Debugger to confirm ordering and exact values where it matters.

Record each trace in its own docs/ file. Update the ledger in PROGRESS.md §4 with the deepest layer you genuinely resolved — not the deepest layer you can name.


4. The eight traces

Trace 1 — classList.add('active') · after bi-07

Focus: invalidation scope. The interesting result is how much work is not done.

  • Framework: did anything schedule, or was this a direct DOM call?
  • Binding: DOMTokenList mutation; what conversion happens?
  • Blink: attribute change → does any rule mention .active? If not, nothing is invalidated.
  • If yes: which invalidation sets, and what scope do they mark?
  • When does recalc actually run? What forces it earlier?
  • Then: construct a variant where the same visual change invalidates 100× more elements.

Trace 2 — getBoundingClientRect() · after bi-08

Focus: forced synchronous layout — the highest-value trace in the set.

  • Binding: which getter, and is it marked as forcing layout?
  • Blink: what does "update style and layout" actually do when called mid-task?
  • What is the cost when nothing is dirty? When everything is?
  • Build the read/write loop, measure at n = 100/1000/5000, and state the complexity class.
  • Where in the trace does the layout appear, and how is it attributed?

Trace 3 — DOM insertion (appendChild) · after bi-04

Focus: how much happens before the pointer update (bi-04 §3 question 1).

  • Hierarchy checks, adoption, removal from previous parent.
  • Custom element reactions and mutation observers: queued when, delivered when?
  • Style/layout/paint invalidation consequences.
  • Compare inserting into a detached tree vs the live document. Explain the difference.

Trace 4 — click · after bi-10, bi-11

Focus: the input path across processes and threads.

  • OS event → browser process → compositor thread: can the compositor handle it alone?
  • Hit testing: compositor-side vs main-thread. What decides?
  • Dispatch to the renderer main thread; event queued at input priority.
  • DOM event dispatch: capture, target, bubble; retargeting across shadow boundaries.
  • Framework handler; state update; scheduling.
  • Then measure: with a 300 ms task running, what is the input delay and where is it spent?
  • Finally: dispatch a synthetic click and diff the trace against a real one (fw-12).

Trace 5 — scroll · after bi-10

Focus: why this usually never reaches your JS.

  • Compositor-driven path end to end. Where is the scroll offset stored?
  • Add a non-passive wheel listener. Re-trace. What changed and where?
  • scroll event delivery: when, at what priority, and why it is always one frame behind.
  • Tiles, raster priority, checkerboarding.

Trace 6 — setCount(count + 1) · after fw-04

Focus: the full stack, and which stages get skipped. This is the capstone trace.

  • Framework: state slot write, schedule, batching, priority.
  • Render phase (interruptible), commit phase (atomic).
  • The actual DOM operations — count them.
  • Style invalidation scope; layout needed or not; paint needed or not; compositor-only or not.
  • Frame presentation.
  • Then produce the two variants: one where the update is compositor-only, and one where it forces layout. Explain the difference at every layer.

Trace 7 — fetch() · after bi-02

Focus: crossing the process boundary.

  • Binding, promise creation, which microtask resolves it.
  • Renderer → network service process via Mojo. The renderer never holds the socket.
  • Where are CORS, CSP, and cross-origin read blocking enforced, and why there?
  • Response streaming back; which thread does the body arrive on?
  • Promise resolution → microtask → your .then. Does this yield to rendering? (bi-11)

Trace 8 — requestAnimationFrame · after bi-11

Focus: the rendering opportunity.

  • Where is the callback stored, and who runs it?
  • Its position relative to style/layout/paint in the update-the-rendering steps.
  • Relationship to the compositor's BeginFrame and to vsync.
  • What happens when a frame is skipped, and how do you observe that?

4.5 The layer-skipping table — the thing you are really learning

Every trace is ultimately teaching one table. Fill it in from your own traces; the version below is the shape, not the answer.

OperationFrameworkDOMStyleLayoutPaintCompositeGPU
classList.add (no matching rule)skipped
classList.add (colour only)
classList.add (changes width)
transform animation
top animation
compositor scroll
scroll with non-passive listener???
getBoundingClientRect (clean)cached
getBoundingClientRect (dirty)forcedforced
setState → text changemaybe

The column that matters is "skipped." Anyone can learn that layout is expensive. The Principal-level skill is knowing precisely which changes avoid it, and being able to say why from mechanism rather than from a memorised list — because the list changes and the mechanism does not.

Cross-check every row against bi-07's css_properties.json5 invalidate: data. If your trace disagrees with the declared invalidation, one of the two is telling you something interesting.


4.6 How to run a trace when you cannot debug

The local build is blocked on this machine (bi-00-roadmap/docs/chromium-build-debug-trace.md), which removes the debugger but not the other two instruments. All eight traces are largely doable with:

  • Perfetto / DevTools Performance on stock Chrome — rung 3,
  • git grep and git log -S on the local checkout — rung 2, and faster than Code Search,
  • the declarative filescss_properties.json5, .idl, .mojom, runtime_enabled_features.json5.

For each trace, record the evidence type per layer. A trace resolved entirely from source reading is weaker than one where a trace event confirmed the code actually ran — and noticing that difference is itself part of the exercise. Source tells you what can happen; a trace tells you what did.


4.7 Trace-writing standards

A finished trace should let a reader who has not done it reach the same conclusion. Concretely:

  • Name the process and thread at every step. "Blink handles it" is not a resolved layer.
  • State deferred vs immediate. Most of the pipeline is deferred; saying so is most of the insight.
  • Quote your evidence. A trace event name, a source symbol, a spec sentence, a measured number.
  • Record where resolution was lost, explicitly. PROGRESS.md's ledger has a column for it.
  • Build the variant. Every trace requires one constructed case that changes which layers are involved — that is what proves you understood the mechanism rather than memorised a path.

A trace with an honest "I could not resolve below the compositor commit" is worth more than one that hand-waves through to the GPU. The gap is your next study target; a papered-over gap is a hole you will fall into later.


5. Archaeology missions (§25)

Same template, applied to subsystems rather than operations. For each: public API → binding → Blink → Chromium subsystem → process/thread → downstream effect.

document.createElement · classList.add · getBoundingClientRect · requestAnimationFrame · fetch · CSS Grid · click/input handling · accessibility tree

Overlap with the traces is intentional — the traces follow one invocation, the missions map one subsystem. Doing both on the same API from two directions is how the picture closes.


6. Verification

A trace is complete when:

  • Every layer is either resolved with evidence, or explicitly recorded as unresolved
  • Process and thread are named at every resolved step
  • Deferred vs immediate is stated at every step
  • At least one variant is constructed that changes which layers are involved
  • The evidence type (source / trace / debugger) is recorded per layer
  • PROGRESS.md §4 ledger updated with the deepest genuinely-resolved layer

7. Principal Engineer Review

  1. Pick any two traces. Name the layer where they diverge most, and what that says about the cost model a developer should carry.

  2. Trace 6 in its compositor-only variant skips layout, paint and raster. Give the rule a developer could apply without knowing any of this, and say where the rule fails.

  3. Which of the eight operations has the largest gap between "what developers think happens" and what happens? Justify.

  4. You are teaching this to a team with two hours. Which single trace do you use, and why that one?

  5. For each trace, name the one measurement you would put on a dashboard to detect regression.

  6. Where did you run out of resolution most often? What would it take to close that gap, and is it worth it?

References — bi-15-vertical-traces

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-15 — Analysis

The capability being built

Given any operation a web developer can perform, trace it downward until you run out of mechanism — and know where you ran out.

The second clause carries the weight. A trace that stops at "and then Blink handles it" is not a failure; a trace that stops there without noticing is. The gap is the next study target.

Required invariants of a completed trace

  1. Every layer is resolved with evidence, or explicitly recorded unresolved.
  2. Process and thread are named at every resolved step.
  3. Deferred vs immediate is stated at every step — most of the pipeline is deferred, and saying so is most of the insight.
  4. A variant is constructed that changes which layers are involved. This is what proves mechanism rather than memorised path.
  5. Evidence type is recorded per layer: source, trace, or debugger.

Why the evidence type matters

Source tells you what can happen; a trace tells you what did. A trace resolved entirely by reading is weaker than one where a trace event confirmed the code ran — and noticing that difference is itself part of the exercise. In a codebase with feature flags and generated code, "the source says so" is a hypothesis.

The table this is all teaching

The deliverable is not eight documents. It is the ability to say, for any change, which pipeline stages are skipped and why. Anyone can learn that layout is expensive; knowing precisely which changes avoid it — and being able to derive that rather than recall it — is the Principal-level skill, because the list changes and the mechanism does not.

Cross-check every row against css_properties.json5's invalidate: data. Where your trace disagrees with the declaration, one of the two is telling you something interesting.

Working without a debugger

The local build is blocked on the reference machine, which removes rung 5 and not rungs 1–3. All eight traces are largely doable with Perfetto on stock Chrome, git grep/git log -S on the checkout, and the declarative files. Record which instrument produced each conclusion.

Observation — bi-15-vertical-traces

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-15-vertical-traces

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Each trace: every layer resolved with evidence, or explicitly recorded unresolved
  • Process and thread named at every resolved step
  • Deferred vs immediate stated at every step
  • At least one variant constructed per trace that changes which layers are involved
  • Evidence type (source/trace/debugger) recorded per layer
  • PROGRESS.md ledger updated with deepest genuinely resolved layer

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-15 — Broader Ideas

Vertical tracing as a general method

The method — follow one operation through every layer, record where resolution is lost — applies to any stack:

  • a request through your CDN, load balancer, gateway, service mesh, application, and database;
  • a keystroke through your editor, language server, and compiler;
  • a click through your analytics pipeline to the warehouse.

Most engineers know their own layer and one neighbour. The person who can trace an operation end to end is the person who can arbitrate between teams — which is the job at this level.

The deliverable is a cost model, not a diagram

The layer-skipping table is what you actually gain: for any change, which stages run and which are skipped. That is what makes a performance review conversation quantitative rather than a swap of folklore.

Build the equivalent for your own stack. "Which layers does this request touch, and which does a cache hit skip?" is the same table.

Recording where you ran out

Explicitly marking unresolved layers is a discipline worth exporting. A design document that says "and then the database handles it" is hiding the same gap. Teams that name their unknowns can schedule work against them; teams that paper over them discover them during an incident.

Next

bi-16 synthesises the traces into the capstone explanation and the eight recurring patterns.

Concepts — Capstone: From Component to Pixel

Phase 7 · Spec areas §46 (complexity notebook), §47 (comparison matrix), §48 (design challenges), §50 (capstone), §51 (mastery criteria).


1. The deliverable

Build a real application using your own implementations:

your JSX/template compiler (fw-06)
   -> your component runtime (fw-02/fw-04)
   -> your reactive store (fw-01 or fw-03)
   -> your router (fw-08) + query cache (fw-09)
   -> DOM
   -> Blink -> style -> layout -> paint -> compositor -> GPU -> pixels

Instrument every layer you can observe. Then answer, in full, what happens after:

setCount(count + 1)

covering framework scheduling, state update, reconciliation or reactive effect, DOM operations, Blink invalidation, style, layout if required, paint if required, compositing if required, and frame presentation — and which stages are skipped, and why.

The application should be small and real: a list with filtering, a detail route with async data, and one animation. That is enough surface to exercise every layer and small enough to instrument honestly.


2. The three analytical artefacts

2.1 Complexity notebook (§46)

One entry per confrontation with production complexity, using the template in PROGRESS.md. Required: Fiber · Vue scheduler · HTML parser insertion modes · layout fragmentation · Mojo / multiprocess IPC · browser security boundaries · concurrent rendering · hydration · event delegation · property trees · style invalidation sets.

Each entry must classify the complexity as essential architecture, production hardening, or accretion — and defend the classification with evidence. Guessing is not classification.

2.2 Comparison matrix (§47)

Rows: mini-react · React · mini-vue · Vue · signals · Redux · Blink style invalidation. Columns: change detection · scheduling · memory · consistency · debuggability · incremental work · failure modes · extensibility · compile-time knowledge · runtime knowledge.

Never reduce to "which is faster."

2.3 Design challenges (§48)

For each: constraints, invariants, failure modes.

A. A UI framework with no virtual DOM · B. Dependency analysis at compile time · C. Canvas instead of DOM · D. Asynchronous rendering · E. SSR + hydration · F. Partial hydration · G. Offline-first state


2.5 The patterns this track keeps finding

By the capstone you should be able to name these unprompted. Each appears in both strands, which is the evidence that they are properties of the problem rather than of any one codebase.

1. Author-supplied guarantees unlock impossible optimisations

GuaranteeUnlocks
key (fw-02)identity-based reconciliation
{passive: true} (bi-10)compositor scrolling without consulting the main thread
contain / content-visibility (bi-07)skipping style, layout, paint for a subtree
sideEffects: false (fw-07)tree shaking
aria-setsize (fw-10)correct AT semantics for virtualized content
Origin-Agent-Cluster (bi-02)origin-keyed process isolation

The runtime cannot derive these facts. Rather than staying conservative forever, the platform adds a way for the author to promise. When you build something that must be conservative, ask what the smallest promise would be.

2. Record cheaply, resolve precisely, once per frame

Style invalidation (bi-07), dirty layout (bi-08), paint invalidation (bi-09), reactive scheduling (fw-03), React batching (fw-04). Every one separates marking from computing.

This is what makes a system incremental, and it is why interleaving a read into a write loop is catastrophic rather than merely slow: the read forces resolution, destroying the batching the whole architecture was built around.

3. Immutability is the enabling condition for caching

Layout results (bi-08), display items and fragments (bi-09), Redux state (fw-01), Fiber's double buffer (fw-04). If a value can change under you, "is this still valid?" is unanswerable.

4. A cache key must capture every input — and incomplete keys fail silently

Constraint spaces (bi-08), paint subsequences (bi-09), computed (fw-03), bundler content hashes (fw-07), query keys (fw-09). The failure is always the same: stale output, no error.

5. Author code must never observe a half-built state

Parser construction site (bi-03), custom element reactions and MutationObserver (bi-04), commit atomicity (fw-04). All three queue or batch for this reason, and all three chose it over a simpler synchronous design that was impossible to make safe.

6. Speculation is safe only when it cannot be observed

Preload and background scanners (bi-03), compositor scrolling before the main thread answers (bi-10), discardable render work (fw-04). Wrong is acceptable; observable is not.

7. Restrictions make circular systems analysable

Container queries require containment (bi-07); percentage heights against auto resolve to auto (bi-08); ResizeObserver has a depth limit (bi-11). Each time, the specification adds a restriction rather than an iteration limit — because restrictions keep the system analysable while limits merely keep it terminating.

8. Some complexity exists because of who owns what

Two GCs, because V8 and Blink are separate projects (bi-04). Two Mojo type namespaces, because Blink uses WTF types and the browser uses STL (bi-06). Generated bindings, partly because consistency across thousands of interfaces cannot rely on humans (bi-05).

Organisational boundaries become architectural boundaries. This is the most Principal-level observation in the track, and the one most worth carrying into your own org design.


2.6 The capstone question, decomposed

"Explain what happens after setCount(count + 1)" is really eight questions. A complete answer addresses each:

  1. Which framework work is scheduled, at what priority, and is it batched? (fw-04)
  2. Which components re-render, and why those? (fw-02 / fw-05 — and the answer differs by architecture, which is the point)
  3. Which DOM operations result? Count them. (fw-02's instrumented DOM)
  4. What does that mutation invalidate? Which elements, which stages — cite css_properties.json5 (bi-07)
  5. Does layout run? For which boxes, and does anything force it early? (bi-08)
  6. Does paint re-record, or is this a property-tree change? (bi-09)
  7. Which thread and process does each step run on, and where are the hops? (bi-02, bi-10)
  8. When is the frame presented, and what could have made it late? (bi-10, bi-11)

And then the question that demonstrates mastery rather than recall:

Construct two variants of the same visible change — one that reaches the GPU having skipped style, layout and paint entirely, and one that runs every stage. Explain the difference at each of the eight layers.

If you can do that from your own instrumented application, with measurements, you have completed the track's actual objective. Everything else is instrumental to it.

3. Mastery criteria (§51)

The track is complete when you can independently:

  • Draw Chromium's processes and explain the trust boundaries
  • Given a browser behaviour, locate the likely subsystem and its implementation
  • Explain HTML → DOM → style → layout → paint → compositing in implementation terms
  • Determine whether a change triggers style, layout, paint, or compositor work
  • Trace a Web API from JS through the bindings
  • Set native breakpoints and follow meaningful execution
  • Locate, run, and modify browser-engine tests
  • Connect implementation behaviour to specifications
  • Produce at least one upstream-quality Chromium/Blink change
  • Have working simplified implementations of: React-like runtime, Vue-like reactive/runtime/compiler system, Redux-like store, router, query cache, signals, virtualized list, bundler/compiler
  • Explain not only how these systems work, but why their complexity exists

The last criterion is the actual target. Everything else is instrumental to it.


4. Final review

  1. Explain setCount(count + 1) end to end, in ten minutes, to a strong engineer who has never thought below the framework. Then do it again in ninety seconds.

  2. Of everything you built, which mini-implementation taught you the most per hour? Which was least worth the time? What does that say about how to teach this to someone else?

  3. Name three pieces of production complexity you now believe are not essential. Defend each with evidence, and say what you would do instead.

  4. You are given a rendering performance problem in a codebase you have never seen, and two hours. Write the procedure.

  5. What is the most important thing you now know that you cannot easily convince another engineer of without them doing this work themselves?

References — bi-16-capstone

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

bi-16 — Analysis

What the capstone actually tests

Not whether you can build an application on your own runtime. Whether you can explain a single state update through eight layers, and construct two variants that differ in which layers run.

The application exists to make that explanation concrete and measurable. It should be small and real: a filtered list, a detail route with async data, one animation. Enough surface to exercise every layer; small enough to instrument honestly.

Required invariants of a complete answer

For setCount(count + 1):

  1. Framework scheduling, priority, and batching — named.
  2. Which components re-render, and why those.
  3. The DOM operations — counted, not described.
  4. What the mutation invalidates, cited against css_properties.json5.
  5. Whether layout runs, for which boxes, and what could force it early.
  6. Whether paint re-records or only a property-tree node changes.
  7. Thread and process at each step, and where the hops are.
  8. When the frame is presented, and what could have made it late.

Then the part that demonstrates mastery rather than recall: two variants of the same visible change — one compositor-only, one running every stage — explained at each of the eight layers.

The eight patterns as the real curriculum

By this point the track has produced eight recurring patterns, each appearing in both strands. That cross-strand recurrence is the evidence that they are properties of the problem rather than of any one codebase — and they, not the Chromium paths, are what survives the next five years.

The classification skill from fw-11 applies to the notebook: essential architecture, production hardening, or accretion, defended with evidence. Being able to say "this is accretion, and here is the CL" is what licenses you to argue for removing complexity in systems you own.

The honest self-assessment

The mastery criteria include producing an upstream-quality Chromium change. If the toolchain blocked that, say so explicitly rather than quietly dropping it — and record what remains. A curriculum that lets you mark yourself complete without the falsifiable step is a curriculum that flatters you.

Observation — bi-16-capstone

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — bi-16-capstone

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Application runs on your own compiler, runtime, store, router and query cache
  • Every observable layer instrumented
  • setCount(count+1) explained end to end, incl. skipped stages and why
  • Complexity notebook: all 12 entries, each classified and defended
  • Comparison matrix: 7 rows x 10 axes, incl. the Blink style-invalidation row
  • All 7 design challenges answered with constraints, invariants, failure modes
  • Mastery criteria checklist complete

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

bi-16 — Broader Ideas

What survives

Chromium paths rot; two are already documented as stale in this book. The eight patterns do not, because they are properties of the problem:

  1. Author-supplied guarantees unlock impossible optimisations.
  2. Record cheaply, resolve precisely, once per frame.
  3. Immutability is the enabling condition for caching.
  4. Incomplete cache keys fail silently.
  5. Author code must never observe a half-built state.
  6. Speculation is safe only when unobservable.
  7. Restrictions make circular systems analysable.
  8. Organisational boundaries become architectural boundaries.

These are the exportable output of the whole curriculum. Each was found independently in both a C++ rendering engine and a JavaScript runtime, which is the evidence that they are not idioms of either.

Where to go next

DirectionNext step
Depth in Chromiumpick one subsystem and go to contribution rung 4–5
Breadth in the platformWPT contributions in areas you studied
Framework authorshiptake fw-04's runtime to SSR and hydration
Organisational impactwrite your team's cold-read procedure and cost model
Cross-trackfe-* capstones — architecture and organisational judgement

The honest completion criterion

The mastery list includes an upstream-quality Chromium change. If the toolchain blocked it, record that explicitly rather than quietly dropping it.

A curriculum that lets you mark yourself complete without the falsifiable step is a curriculum that flatters you — and the entire method here has been to prefer measurement over flattery.

Concepts — mini-redux, and Reading an Entire Production Library

Phase 1, parallel with bi-03/bi-04 · Spec area §33, §44 Level 1. No browser-internals prerequisite. This is the first framework module, deliberately.


1. Why this is first

Redux is the only production library in this track small enough to read end to end. That makes it the Level-1 rung of the §44 reading ladder, and the capability it builds — "I have held an entire real system in my head" — is a prerequisite for facing 30M lines of Chromium, not a consolation prize afterwards.

It is also the cleanest possible demonstration of the §45 loop, because the gap between the naive implementation and the real one is small enough to enumerate completely. Every line of difference has a reason, and you can find all of them.

The store is 100 lines. The interesting part is the 100 lines you would not have written.


2. Mental Model

dispatch(action)
   -> assert not already dispatching
   -> state = reducer(state, action)
   -> notify a *snapshot* of listeners

Three ideas carry the whole design:

  • State is replaced, not mutated. Which makes change detection a reference comparison, and makes time travel a matter of keeping old references. Compare bi-08: immutable layout results are cacheable for exactly the same reason.
  • The reducer is pure. Which makes replay deterministic.
  • Subscription is unconditional. Every listener is called on every dispatch; selecting what changed is a separate concern (selectors), and pushing it out of the core is why the core stays small.

That last decision is the interesting one, and it is a genuine trade-off rather than an obvious win — see §5.


3. Build order

  1. createStore(reducer)getState, dispatch, subscribe.
  2. combineReducers.
  3. Middleware + applyMiddleware.
  4. Enhancers — and articulate why these are a different extension point from middleware.
  5. Selectors with memoisation.
  6. Action/state recording.
  7. Time travel.
  8. Persistence.

Middleware to write: logger, timing, error handling, async.

Write each stage before reading the corresponding Redux source. The whole value is in the diff between your version and theirs.


4. Failure Lab — the bugs the real source defends against

Each of these is a real defence in Redux. Feel the bug first, then find the guard.

  1. Dispatch inside a reducer. What breaks, and why is a guard better than "don't do that"?
  2. Subscribe/unsubscribe during notification. Unsubscribe a listener from inside another listener while the notification loop is running. Watch a listener get skipped. This is why the real implementation snapshots the listener list — reproduce the skip, then fix it.
  3. Mutating state in a reducer with a memoised selector downstream. The selector's reference check says "unchanged," the UI goes stale. This is the strongest possible argument for immutability, and it is much more convincing after you have seen it.
  4. Middleware that dispatches synchronously in its own path. Find the re-entrancy.
  5. Getting state during dispatch. What consistency guarantee is at risk?

5. Trade-offs to argue, not memorise

Notify-everyone vs fine-grained subscription. Redux calls every listener on every dispatch and leaves selection to userland. This keeps the core tiny and makes the framework integration layer responsible for performance. Vue and signals (fw-03) make the opposite choice and pay for it in machinery. Neither is right; they optimise different things, and §47 is where you say what.

Middleware vs enhancers. Middleware wraps dispatch; enhancers wrap createStore itself. Middleware is the common case; enhancers are strictly more powerful and much rarer. Ask why both exist rather than one general mechanism — this is your complexity-notebook entry for the module.

Single store vs many. Single store makes time travel and serialisation trivial and makes code-splitting state awkward.


5.5 Deep dive: the guards, and what each one is defending

Stage 4 of the spec is the module. Here is what each guard actually protects, so you can recognise the shape when you design your own systems.

isDispatching — the reducer must see a stable world

if (isDispatching) throw new Error('Reducers may not dispatch actions.');

A reducer is (state, action) => state. If it dispatches, the state it is computing from is being replaced underneath it. The result is not "slightly wrong" — it is undefined, because the order of the nested and outer assignments decides the answer.

Why a guard rather than documentation: the failure is silent, non-deterministic, and appears far from its cause. When a contract violation produces corruption rather than an error, make it an error. Compare Blink's DCHECK policy (bi-06) — same reasoning, different language.

Listener snapshotting — the notification list is fixed when notification begins

Redux's documented semantic: subscribing or unsubscribing while listeners are being invoked has no effect on the dispatch currently in progress. A listener unsubscribed mid-notification is still called that time; one subscribed mid-notification is not called until the next.

The naive implementation — iterating the live array with an index and splicing on unsubscribe — skips a listener, because removing element i shifts everything after it while your index keeps advancing.

Two lessons:

  1. Iterating a collection that callbacks can mutate is a bug class, not an edge case. It appears in event emitters, observer lists, animation frames, and DOM NodeLists.
  2. The fix is a snapshot, and the cost is one array copy per dispatch only when the list changed (the ensureCanMutateNextListeners pattern: copy lazily on write, not eagerly on read).

Immutability — why it is about change detection, not purity

state.items.push(x);   // reference unchanged
return state;

A memoised selector compares prevState === nextState, sees no change, and returns a stale result. The UI does not update. Nothing threw.

Immutability here is not a functional-programming preference. It is what makes O(1) change detection possible:

ModelCost to answer "did this change?"
Immutable + reference compareO(1)
Deep equalityO(size)
Dirty flagsO(1) but you must maintain them everywhere
Proxy interception (fw-03)O(1) at write, plus tracking overhead at read

You have now met the same trade in bi-08 (immutable layout results are cacheable) and bi-09 (immutable display items and fragments). Immutability is the enabling condition for caching, in a C++ rendering engine exactly as much as in a JS store.


5.6 Deep dive: middleware vs enhancers, resolved

The complexity-notebook entry for this module. The distinction:

middleware : wraps  dispatch          — (api) => (next) => (action) => ...
enhancer   : wraps  createStore       — (createStore) => (reducer, preloaded) => store

Middleware can: observe actions, transform them, delay them, swallow them, dispatch others. It cannot: change getState, add store methods, replace the reducer, or alter subscription.

An enhancer can do all of those, because it constructs the store. applyMiddleware is itself an enhancer — middleware is a special case of enhancer, packaged so that the common case is easy.

The design question to answer in your notebook: why not expose only enhancers, since they are strictly more powerful?

The honest answer has two halves. Enhancers are hard to write correctly and easy to make incompatible with each other (composition order matters and mistakes are subtle), whereas middleware has a trivially composable signature and a well-understood mental model. So the library provides a constrained interface for the 95 % case and an escape hatch for the rest.

That pattern — narrow API for common use, powerful API for rare use, with the narrow one implemented in terms of the powerful one — is worth naming, because you will design it yourself. Compare: useState implemented on useReducer; CSS custom properties vs Houdini; hooks vs render props.


5.7 Deep dive: what Redux deliberately does not do

The core notifies every subscriber on every dispatch, and does not tell them what changed.

That looks like a defect until you ask who should decide relevance:

  • The store cannot know which slice a subscriber cares about without being told.
  • Being told means a selector API in the core, plus memoisation, plus a dependency notion — you have re-invented a reactivity system (fw-03) inside a state container.
  • So Redux pushes it out to userland, and the framework binding layer (react-redux and friends) becomes responsible for not re-rendering everything.

The consequence: Redux's core stays ~200 lines and the ecosystem is enormous. That ratio is itself the finding. When you see a tiny core with a huge ecosystem, the library made a deliberate choice about where complexity should live — and someone still pays for it, just not in the core.

Compare directly with fw-03: Vue and signals make the opposite choice, putting dependency tracking in the core and paying for it with proxies, dependency graphs, and cleanup. Neither is correct; §47 is where you say what each optimises.


5.8 Deep dive: time travel, and why it is nearly free here

Given immutable state and pure reducers:

history = [s0, s1, s2, s3]     // just references
jumpTo(1) => notify(s1)

Time travel is keeping references and re-notifying. No inverse operations, no snapshots, no diffing.

Now compare a fine-grained reactive system (fw-03): state is scattered across many independent signals mutated in place, so "the state at time T" is not a value that exists anywhere. Time travel requires recording per-signal history and replaying it, and any effect with a side effect must be suppressed on replay.

This is the single sharpest architectural comparison in the framework strand, and it is not about performance. Explicit-update models make the whole state at a moment a first-class value; fine-grained reactive models make individual changes first-class. Debuggability, replay, serialisation, and undo all follow the first; update efficiency follows the second.

Which you want depends on the product — and being able to state the trade in one sentence is what §47 is checking.


6. Then: the reading ladder, Level 1

Read Redux end to end. Answer all eight §44 gate questions for createStore. Specifically account for every difference between your implementation and theirs — there should be no line you cannot explain.

Log the reading in fe-00-roadmap/docs/learning-log.md §3, with the "what surprised me" column filled honestly.


7. Verification

  • All eight build stages implemented
  • All five failure-lab bugs reproduced, then fixed
  • Every difference between mini-redux and Redux enumerated and explained
  • Eight gate questions answered for createStore
  • Complexity-notebook entry: middleware vs enhancers
  • §47 matrix row started: Redux's change-detection model

8. Principal Engineer Review

  1. Redux notifies every subscriber on every dispatch. Defend this as good design, then say what it forces every consuming framework to build.

  2. Explain why immutability is load-bearing here in terms of change detection cost, not purity.

  3. Middleware and enhancers are two extension points. Design a single mechanism that replaces both. What do you lose?

  4. Time travel is nearly free in Redux and expensive in a fine-grained reactive system. Explain why from the data model.

  5. A team wants to adopt Redux for a form-heavy app with high-frequency local state. Argue against, mechanically.

  6. You are asked to add "only notify subscribers whose selected slice changed" to the core. Specify it. What breaks, and where does the cost move?

  7. Redux is ~200 lines of core and enormous ecosystem. What does that ratio tell you about where the real design decisions were made?

  8. Compare Redux's explicit-update model with signals' automatic dependency tracking on debuggability, not performance. Which would you want at 3am?

References — fw-01-mini-redux

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-01 — Analysis

Required invariants

  1. A reducer sees a stable world. Dispatching inside a reducer means computing state from a moving target; the result is undefined, not merely wrong.
  2. The listener list is fixed when notification begins. Subscribing or unsubscribing mid-dispatch has no effect on the dispatch in progress.
  3. State is replaced, never mutated. This is what makes change detection O(1).
  4. The reducer is pure, so replay is deterministic — which is what makes time travel free.

Why each guard exists

GuardBug without itDetectability
isDispatchingstate computed from a moving targetsilent, nondeterministic
Listener snapshota subscriber is skipped when another unsubscribes mid-loopsilent, intermittent
Immutability (by convention)memoised selectors see no change; UI goes stalesilent

All three failures are silent. That is the argument for guards over documentation: when a contract violation produces corruption rather than an error, make it an error. The same reasoning produces Blink's DCHECK policy.

The change-detection cost table

ModelCost of "did this change?"
Immutable + reference compareO(1)
Deep equalityO(size)
Dirty flagsO(1), but maintained everywhere
Proxy interceptionO(1) at write, plus tracking at read

Immutability here is not a stylistic preference; it is the enabling condition for cheap change detection — the same role it plays for layout-result caching (bi-08) and display items (bi-09).

What Redux deliberately does not do

The core notifies every subscriber on every dispatch and does not say what changed. Pushing selection to userland is what keeps the core ~200 lines — and what makes the ecosystem enormous. That ratio is the finding: a tiny core with a huge ecosystem means the library decided where complexity should live, and someone still pays for it.

Execution — fw-01-mini-redux

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Build order

  1. createStore(reducer)getState, dispatch, subscribe.
  2. combineReducers.
  3. Middleware + applyMiddleware.
  4. Enhancers — and articulate why these are a different extension point from middleware.
  5. Selectors with memoisation.
  6. Action/state recording.
  7. Time travel.
  8. Persistence.

Middleware to write: logger, timing, error handling, async.

Write each stage before reading the corresponding Redux source. The whole value is in the diff between your version and theirs.


4. Failure Lab — the bugs the real source defends against

Each of these is a real defence in Redux. Feel the bug first, then find the guard.

  1. Dispatch inside a reducer. What breaks, and why is a guard better than "don't do that"?
  2. Subscribe/unsubscribe during notification. Unsubscribe a listener from inside another listener while the notification loop is running. Watch a listener get skipped. This is why the real implementation snapshots the listener list — reproduce the skip, then fix it.
  3. Mutating state in a reducer with a memoised selector downstream. The selector's reference check says "unchanged," the UI goes stale. This is the strongest possible argument for immutability, and it is much more convincing after you have seen it.
  4. Middleware that dispatches synchronously in its own path. Find the re-entrancy.
  5. Getting state during dispatch. What consistency guarantee is at risk?

Observation — fw-01-mini-redux

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-01-mini-redux

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • All eight build stages implemented
  • All five failure-lab bugs reproduced, then fixed
  • Every difference between mini-redux and Redux enumerated and explained
  • Eight gate questions answered for createStore
  • Complexity notebook: middleware vs enhancers
  • Comparison matrix row started

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-01 — Broader Ideas

Where the guards reappear

GuardSame bug elsewhere
Listener snapshotevent emitters, animation-frame callbacks, observer lists, live NodeList iteration
Re-entrancy guardany callback that can trigger the thing that invoked it
Immutability for change detectionReact props, layout results (bi-08), display items (bi-09)

Iterating a collection that callbacks can mutate is a bug class, not an edge case. Having hit it once here, you will recognise it in unfamiliar code.

Narrow API over powerful API

applyMiddleware is an enhancer, packaged so the common case is easy. Middleware has a trivially composable signature; enhancers are strictly more powerful and easy to make mutually incompatible.

That pattern — constrained interface for the 95% case, escape hatch for the rest, the former implemented in terms of the latter — is one you will design yourself. Compare useState on useReducer, or CSS custom properties versus Houdini.

The state-at-a-moment property

Explicit-update models make "the whole state at time T" a first-class value. Time travel, serialisation, undo, crash-report state dumps, and deterministic replay all follow from it — and none of them follow from update efficiency.

When you choose a state architecture, ask whether you will ever need to reproduce a user's exact state. If yes, that requirement outranks per-update cost, and this is the trade fw-03 makes in the other direction.

Next

fw-02 is the consumer that makes Redux's notify-everyone default expensive; fw-03 is the opposite architecture; §47 is where you write the comparison rather than accept one.

src — mini-redux

tiny-test.mjs   ~30-line test runner (plumbing)
spec.mjs        executable specification — run this
store.js        ← YOU WRITE THIS
node spec.mjs

store.js must export createStore, combineReducers, applyMiddleware.

How to use the spec

Stages are ordered. Get stage 1 green before reading stage 3 — the tests are the build order from CONCEPTS.md §3, made runnable.

Stage 4 is the point of this module. Each of those tests corresponds to a real guard in Redux's source. Write the naive implementation first, watch stage 4 fail, and only then work out what the guard has to be. If you write the guards up front you will have copied Redux without understanding why any line exists.

Stage 5 passes when the bug is present. It is not a test of your store — it is a demonstration that a mutating reducer is undetectable by reference equality. Read it, do not "fix" it.

The spec deliberately does not cover enhancers, selectors, time travel or persistence (stages 4–8 of the build order). Those are yours to design and to test — writing that test file is part of the work.

Concepts — mini-react I: Elements, Rendering, Reconciliation, State

Phase 2, parallel with bi-07 · Spec area §27 stages 1–7. Prerequisites: bi-04 (DOM cost model), fw-01.

Stages 8–11 (batching, scheduling, interruptible work) are not here. They are fw-04, after bi-11. Attempting them now produces a transcription of Fiber rather than a derivation of it.


1. Why a Principal Engineer needs this

You cannot evaluate React — or argue about it credibly — from its documentation. The design decisions that matter (why a virtual DOM at all, why keys, why hooks are order-dependent, why setState is asynchronous) are only legible once you have hit the problem each one solves.

The deeper aim is transferable: this module is where "declarative UI" stops being a slogan and becomes a specific algorithm with specific costs.


2. Mental Model

Component functions
   -> elements (plain data: {type, props, children})
   -> reconciliation against the previous tree
   -> a minimal set of DOM operations
   -> commit

The core bet: describing the whole UI and diffing is cheaper than manually tracking what changed — because DOM mutation is expensive (bi-04) but object allocation and comparison are cheap. Whether that bet pays depends entirely on the ratio, which is why it is a bet and not a theorem, and why fw-03's alternative exists.


3. Build order

  1. Element model. createElement(type, props, ...children) returning plain objects. No classes, no magic. Write h('div', {id:'x'}, 'hi') by hand and look at the object.
  2. DOM renderer. Render an element tree to real DOM. No diffing yet — mount only.
  3. Reconciliation. Given old and new trees, compute and apply the minimal DOM changes. Same-type nodes update in place; different types replace.
  4. Keyed children. Implement unkeyed first, find the bug (state attaching to the wrong item on reorder), then add keys. Do not skip the broken version — keys are meaningless until you have seen what they fix.
  5. Function components. Components return element trees; render recursively.
  6. State. useState with a per-component slot list. Discover that this requires stable call order, and write down why the rules of hooks exist before reading that they do.
  7. Effects. useEffect with dependency comparison, cleanup on unmount and on dep change. Get the cleanup ordering wrong at least once.

4. Failure Lab

  1. Unkeyed reorder. A list of inputs with text typed in them, reordered. Watch state follow position instead of identity. This is the canonical demonstration.
  2. Wrong key. Use array index as key on a reorderable list. Show the same bug returns.
  3. Hook order violation. Call a hook conditionally. Explain the failure precisely in terms of your slot list — you will explain it better than the docs do.
  4. Missing dependency. A stale closure reading old state. Then over-specify dependencies and produce an infinite effect loop. Both are real; describe the tension.
  5. Missing cleanup. Subscribe in an effect without unsubscribing; leak across remounts. Find it in a heap snapshot (bi-04's retainer-chain skill).

5. Trade-offs to argue

Diffing vs tracking. React re-renders and diffs; Vue/signals track dependencies. React trades per-update work for a simpler mental model and better composability. Where does the trade stop paying?

Keys as author-supplied identity. The runtime cannot infer identity, so it asks. Compare with {passive:true} in bi-10: an author-supplied guarantee that unlocks an optimisation the runtime could not otherwise make. Once you notice this pattern, you see it everywhere.

Hooks' positional storage. Enables tiny API surface and composition; costs the rules of hooks and a whole class of confusing errors. Design an alternative and cost it out.


5.5 Deep dive: why keys cannot be inferred

The runtime sees two arrays of descriptions. It must decide which old item corresponds to which new one. Without keys the only available correspondence is position.

That is not a limitation of the implementation; it is information-theoretic. Consider:

before: [A, B, C]
after:  [B, C]

Did you delete A, or rename A→B, B→C and delete the third? Both are consistent with the data. The runtime cannot distinguish them, and the two answers imply completely different DOM operations and completely different component-state outcomes.

So identity must be supplied. key is the author asserting "this description refers to the same conceptual thing as the one with the same key last time."

This is the third instance of the same pattern in the track, and it is worth collecting them explicitly: key (fw-02), {passive: true} (bi-10), contain / content-visibility (bi-07), sideEffects (fw-07). In every case the runtime cannot derive a fact it needs, so the platform adds a way for the author to promise it — and an optimisation becomes possible that was previously impossible in principle.

When you design a system that must be conservative because it cannot know something, ask whether the caller could simply tell you.

Why array index is not a key

key={index} restores exactly the positional correspondence keys were meant to replace. It is not "a weak key"; it is no key with extra steps, and it fails identically on reorder, insert-at-front, and delete-from-middle. It is only safe when the list is append-only and never reordered — at which point it is also unnecessary.


5.6 Deep dive: hooks, and the cost of positional storage

useState stores per-component-instance state in a list indexed by call order. That is why the rules of hooks exist: conditional calls shift every subsequent index.

Derive the alternatives and their costs, because "the rules of hooks are annoying" is only a complaint until you have priced the options:

DesignCost
Positional (React)rules of hooks; confusing errors; but tiny API and perfect composability
Named keys (useState('count', 0))no ordering rules; but every hook needs a unique name, and composition requires namespacing
Class fieldsexplicit and safe; but no composition without mixins/HOCs — the problem hooks were created to solve
Compiler-assigned slotsbest of both; requires a build step and makes runtime-only use impossible

Positional storage is what makes useCustomThing() compose with zero ceremony — a custom hook is just a function that calls other hooks, and nothing needs to know its name. The rules of hooks are the price of that, and it is a real trade rather than an oversight.

Note the fourth row is where React eventually went with its compiler, which is fw-06's subject: move the analysis to build time and you can relax the runtime constraint.

The stale-closure problem

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);  // captures count from THIS render
  return () => clearInterval(id);
}, []);                                                     // never re-runs

The effect closed over the first render's count, forever. The functional updater (setCount(c => c + 1)) fixes it by not depending on the captured value at all.

This is not a React bug — it is JavaScript closure semantics meeting a render model where the function body runs many times. Every value in a component body is a snapshot of one render. Internalising that sentence resolves most useEffect confusion, and it is a much better mental model than "add the dependency."


5.7 Deep dive: what "the virtual DOM is fast" actually claims

The claim is not that diffing is faster than DOM mutation. It is:

cost(build description) + cost(diff) + cost(minimal mutations)
    <  cost(mutations a naive implementation would perform)

That inequality holds when your alternative is "re-render this region from a template string," and fails when your alternative is "I know exactly which text node changed, so I'll set it directly."

Which is precisely why fine-grained reactive systems (fw-03) can win: they do know, because they tracked it.

Your spec's stage-3 test measures the left-hand side directly — changing one item in a 200-item list must cost ≤5 DOM operations. Run the same scenario with innerHTML replacement and count: that is the right-hand side. The virtual DOM's value proposition is a measurement you can make in your own implementation, not a claim to accept or reject on authority.

The honest summary for a design review:

A virtual DOM buys you a simple mental model (describe the whole UI; the runtime works out the difference) at the cost of per-update work proportional to the described tree. It is the right trade when developer velocity and composability matter more than update cost, and the wrong one when the update rate is high and the tree is large.


6. Verification

  • Renders and updates a non-trivial UI
  • Keyed reconciliation demonstrably fixes the reorder bug
  • useState, useEffect with correct cleanup ordering
  • All five failure-lab bugs reproduced and explained
  • Measured: DOM operations per update, mini-react vs naive innerHTML replacement
  • Complexity-notebook entry: why keys must be author-supplied

7. Principal Engineer Review

  1. Explain the virtual DOM's value proposition in terms of cost ratios, and name the workload where it is a net loss.

  2. Why can't the runtime infer list identity without keys? Give the theoretical answer and the practical one.

  3. Hooks depend on call order. Design an alternative with the same composability. What does it cost in API surface or ergonomics?

  4. useEffect dependency arrays are a manual correctness burden. Argue they are essential; then argue the compiler should do it (and note who has tried).

  5. A colleague says "React is slow because of the virtual DOM." Give the accurate version.

  6. You are choosing a rendering model for a 100k-row data grid. What does the diffing model cost you here specifically, and what would you do instead?

  7. Your mini-react re-renders a whole subtree on any state change. Name every mechanism production React uses to avoid that, and what each costs.

  8. What would break if setState were synchronous? Answer before fw-04, then revisit after.

References — fw-02-mini-react-core

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-02 — Analysis

Required invariants

  1. Elements are plain data. Descriptions, not DOM nodes; the runtime decides what to do with them.
  2. Same type and key ⇒ reuse the DOM node. Node identity is what carries state.
  3. Identity must be author-supplied for lists. The runtime cannot derive it.
  4. Hook order is stable per component instance. Positional storage requires it.
  5. Effects clean up before the next run, and on unmount.

Why keys cannot be inferred

before: [A, B, C]
after:  [B, C]

Deleted A? Or renamed A→B, B→C, deleted the third? Both are consistent with the data, and they imply different DOM operations and different state outcomes. This is information-theoretic, not an implementation limitation.

key={index} restores positional correspondence — it is not a weak key, it is no key with extra steps, and it fails identically on reorder, prepend, and delete-from-middle.

The measurement that settles the virtual-DOM argument

The claim is not "diffing beats DOM mutation." It is:

build description + diff + minimal mutations  <  mutations a naive implementation performs

That inequality holds against innerHTML replacement and fails against "I know exactly which text node changed." Which is why fine-grained reactive systems can win — they do know.

The spec measures the left-hand side directly: one change in a 200-item list must cost ≤5 DOM operations. A rebuild does ~200.

Failure modes

BreakConsequence
Unkeyed reorderstate follows position, not identity — inputs keep the wrong values
Index as keysame bug, disguised
Conditional hook callslot list shifts; every subsequent hook reads the wrong state
Missing effect dependencystale closure reads a value from a previous render
Missing cleanupsubscriptions accumulate across remounts

Every value in a component body is a snapshot of one render. Internalising that sentence resolves most useEffect confusion, and it is a better model than "add the dependency."

Execution — fw-02-mini-react-core

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Build order

  1. Element model. createElement(type, props, ...children) returning plain objects. No classes, no magic. Write h('div', {id:'x'}, 'hi') by hand and look at the object.
  2. DOM renderer. Render an element tree to real DOM. No diffing yet — mount only.
  3. Reconciliation. Given old and new trees, compute and apply the minimal DOM changes. Same-type nodes update in place; different types replace.
  4. Keyed children. Implement unkeyed first, find the bug (state attaching to the wrong item on reorder), then add keys. Do not skip the broken version — keys are meaningless until you have seen what they fix.
  5. Function components. Components return element trees; render recursively.
  6. State. useState with a per-component slot list. Discover that this requires stable call order, and write down why the rules of hooks exist before reading that they do.
  7. Effects. useEffect with dependency comparison, cleanup on unmount and on dep change. Get the cleanup ordering wrong at least once.

4. Failure Lab

  1. Unkeyed reorder. A list of inputs with text typed in them, reordered. Watch state follow position instead of identity. This is the canonical demonstration.
  2. Wrong key. Use array index as key on a reorderable list. Show the same bug returns.
  3. Hook order violation. Call a hook conditionally. Explain the failure precisely in terms of your slot list — you will explain it better than the docs do.
  4. Missing dependency. A stale closure reading old state. Then over-specify dependencies and produce an infinite effect loop. Both are real; describe the tension.
  5. Missing cleanup. Subscribe in an effect without unsubscribing; leak across remounts. Find it in a heap snapshot (bi-04's retainer-chain skill).

Observation — fw-02-mini-react-core

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-02-mini-react-core

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Renders and updates a non-trivial UI
  • Keyed reconciliation demonstrably fixes the reorder bug (broken version built first)
  • useState, useEffect with correct cleanup ordering
  • All five failure-lab bugs reproduced and explained
  • Measured: DOM ops per update vs naive innerHTML replacement
  • Complexity notebook: why keys must be author-supplied

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-02 — Broader Ideas

Identity is always author-supplied

key joins {passive:true}, contain, sideEffects, and aria-setsize as an author promise the runtime cannot derive. Once you have seen the ambiguity proof — [A,B,C] → [B,C] is genuinely undecidable — you will stop treating keys as a lint rule and start treating them as data.

The design question this hands you: when your system must be conservative because it cannot know something, could the caller simply tell you? That question turns impossible optimisations into possible ones.

The snapshot model of a render

Every value in a component body is a snapshot of one render. That single sentence resolves stale closures, the useEffect dependency debate, and most "why is this value old" confusion — better than any rule about dependency arrays.

It generalises to any system that re-executes a function to produce a description: your values are from that execution, and anything that outlives it (a timer, a subscription, a promise) captured a snapshot.

Positional storage, priced

Hooks' rules exist to buy zero-ceremony composition. The alternatives — named keys, class fields, compiler-assigned slots — each cost something specific, and knowing the table means you can evaluate the next framework's choice rather than react to it.

Note that the compiler row is where React eventually went, which is fw-06's subject: move analysis to build time and you can relax a runtime constraint.

Next

fw-03 builds the architecture that does know what changed. fw-04 takes this runtime and makes it interruptible, which is where the DOM-operation counter you built starts mattering under load.

src — mini-react (stages 1–7)

tiny-test.mjs   test runner (plumbing)
fake-dom.mjs    ~60-line DOM, instrumented to COUNT operations (plumbing)
spec.mjs        executable specification — run this
runtime.js      ← YOU WRITE THIS
node spec.mjs

Required exports

export function setDocument(doc)                    // spec injects the fake DOM
export function createElement(type, props, ...kids)
export function render(element, container)
export function useState(initial)
export function useEffect(fn, deps)

key is a reconciliation hint, not a DOM attribute — do not setAttribute('key', ...). React does not, and the stage-4 assertions compare rendered output.

Why the DOM is fake and counted

fake-dom.mjs counts every createElement, appendChild, insertBefore, removeChild, setAttribute and text mutation. That turns "the virtual DOM avoids DOM work" from a slogan into an assertion: stage 3's last test changes one item in a 200-item list and requires ≤ 5 DOM operations. A naive rebuild does ~200 and fails.

It also gives you node identity, which is how stage 4 tests that a keyed reorder moves nodes rather than recreating them. That is the mechanical version of "state follows identity, not position."

Order matters

Build the unkeyed reconciler first and watch stage 4 fail. Then add keys. Skipping the broken version means keys are a rule you were told rather than a fix you needed — and you will not be able to answer the review question about why the runtime cannot infer identity itself.

Stages 8–11 (batching, scheduling, interruptible work) are not here. They are fw-04, after bi-11. There is a separate spec there.

Concepts — Reactivity: mini-vue and mini-signals

Phase 3, parallel with bi-08 · Spec areas §29, §36. Prerequisites: fw-02 (so the contrast has something to contrast with).

Build both, back to back. Separating them by months destroys the comparison, which is the entire content of §36. Vue's reactivity and signals are the same idea at different granularities; seeing that is the lesson.


1. Why a Principal Engineer needs this

fw-02 built a system that recomputes and diffs. This module builds the opposite: a system that knows exactly what changed and updates only that. Holding both models precisely is what lets you evaluate any UI framework — including ones that do not exist yet — instead of tracking fashion.


2. Mental Model

reactive object
   -> property read inside a running effect  => record dependency
   -> property write                          => notify dependents
   -> scheduler decides when effects actually run

The core trick: reading is instrumented. A Proxy get-handler knows which effect is currently running and records the edge. This is why no keys, no diffing and no dependency arrays are required — the runtime observes the dependency rather than being told.

The cost is equally specific: you must intercept every read. Which means proxies (with their own costs and edge cases), care around collections and non-reactive values, and a dependency graph whose size scales with reads, not with components.

The comparison, stated precisely

React-styleVue reactivitySignalsRedux
How does it know what changed?re-run and difftracked at property granularitytracked at signal granularityyou tell it (actions)
Unit of updatecomponent subtreeeffecteffectsubscriber (all)
Author burdenkeys, depsalmost nonealmost noneaction discipline
Cost scales withtree sizenumber of readsnumber of readsnumber of subscribers
Debuggabilityrender trees, explicitimplicit graphimplicit graphbest: explicit log

Fill this in yourself as you build; do not copy it. §47 wants your version.


3. Build order — mini-vue reactivity

  1. reactive(obj) via Proxy — get/set traps.
  2. effect(fn) — a global "currently running effect" and a dependency map.
  3. Dependency tracking: track(target, key) on get, trigger(target, key) on set.
  4. ref(value) — the primitive-value case, and why it needs .value.
  5. computed(fn) — lazy, cached, and itself both a dependent and a dependency.
  6. watch(source, cb).
  7. Cleanup: effects must drop stale dependencies on re-run.
  8. Nested effects — an effect stack, not a single global.
  9. Scheduling and batching: a microtask-flushed queue, deduplicated.

4. Build order — mini-signals

Then rebuild the same capability with a minimal signal API (signal, computed, effect, batch). Deliberately do not reuse the Vue code. The point is to find out how much of it was essential and how much was Vue-specific.


5. Failure Lab

  1. Dependency leak. Omit cleanup on re-run. Build a case where an effect depends on a branch it no longer reads, and keeps firing. This is the bug cleanup exists for.
  2. Infinite loop. An effect that writes a value it reads. Predict, then observe. Then design the guard — and note what legitimate patterns your guard forbids.
  3. Stale computed. Break the cache invalidation so a computed returns an old value. Explain why lazy caching is harder than it looks.
  4. Lost reactivity. Destructure a reactive object and lose tracking. Explain to a hypothetical junior in three sentences.
  5. Nested effect corruption. Use a single global "current effect" instead of a stack; watch the inner effect steal the outer's dependencies.
  6. Batching absence. 1,000 writes in a loop with no scheduler. Measure. Then batch.

6. Trade-offs to argue

Fine-grained updates vs graph overhead. Tracking is not free: every read does bookkeeping. Find the workload where React's "re-render and diff" wins.

Implicit dependencies vs explicit ones. Automatic tracking removes an author burden and removes the author's ability to see the graph. Which do you want when debugging a production incident? (This is why fw-01's explicit model is not simply obsolete.)

Proxy-based vs compile-time. Vue tracks at runtime; some frameworks move dependency analysis to compile time. What does each know that the other cannot? (This is §48 Challenge B, and fw-06 is where you would build it.)


6.5 Deep dive: push, pull, and why computed is hard

Reactivity systems differ on when work happens, and the vocabulary is worth having.

ModelOn writeOn readProblem
Pure pusheagerly recompute all dependentsfreerecomputes values nobody reads; glitches
Pure pullmark dirty onlyrecompute if dirtymust walk the graph on every read
Push-pull (what real systems do)mark dirty, propagate invalidationrecompute if dirty, cache resultthe invalidation must reach everything, exactly once

computed is the hard case because it is both a dependent and a dependency. When its source changes it must not recompute (nobody may want it) but it must invalidate its own dependents, who may then pull.

The glitch problem

const a = signal(1);
const b = computed(() => a.value + 1);
const c = computed(() => a.value + b.value);

Set a = 2. A naive push order can evaluate c after a updated but before b did, so c briefly computes 2 + 2 = 4 instead of 2 + 3 = 5. That transient wrong value is a glitch.

Real systems avoid it by evaluating in topological order, or by making computed lazy so c pulls b and b recomputes on demand. Laziness is the cheaper fix and is why computed is specified as lazy rather than eager.

Test for this in your implementation. If your computed is eager, construct the diamond above and watch for the intermediate value. Most hand-written reactivity systems have this bug and never notice, because the glitch is transient and the final value is right.

The diamond, generalised

a → b → d and a → c → d is the canonical shape. A correct system evaluates d once, after both b and c are current. A naive one evaluates d twice, and possibly once with stale input. Count evaluations in your lab — that count is the difference between a toy and a real implementation.


6.6 Deep dive: what dependency tracking cannot see

Automatic tracking works by instrumenting reads. Anything that is not a tracked read is invisible:

const s = reactive({ items: [] });

effect(() => { console.log(s.items.length); });
s.items.push(1);        // does the effect re-run? depends on whether the ARRAY is reactive

The hard cases, all of which you should build and break:

  • Destructuringconst { a } = s reads a once, then you hold a plain value. Tracking is lost. (This is why Vue has toRefs.)
  • CollectionsMap, Set, arrays need their methods instrumented, not just property access.
  • Async boundaries — reads after an await happen outside the tracking context unless the system re-establishes it. This is the subtlest bug in the module and it is worth a deliberate failure lab.
  • Conditional reads — handled by cleanup (spec stage 5), and the reason cleanup exists.
  • Untracked escape hatches — every system needs one (untrack, peek), and every one is a place where a dependency is deliberately not recorded.

The trade against explicit models (fw-01) restated: automatic tracking removes an authoring burden and replaces it with an observability burden. With Redux you can print the action log. With a reactive system, "why did this effect run?" requires a devtool that shows the graph — which is why every mature reactive framework ships one.


6.7 Deep dive: scheduling, and why reactivity needs one at all

Naive triggering runs effects synchronously on write. Three problems follow immediately:

  1. N writes, N runs. A loop of 1,000 mutations runs the effect 1,000 times.
  2. Inconsistent intermediate states. An effect reading two values sees the first updated and the second not.
  3. Re-entrancy. An effect that writes triggers effects mid-flight.

So every real system has a scheduler: a deduplicated queue, flushed on a microtask.

Notice what that is: bi-11's microtask checkpoint, used as a batching boundary. The framework chose a microtask because it is the earliest point at which the current synchronous work is finished — the same reason MutationObserver delivers there (bi-04).

Pre-flush vs post-flush ordering matters too: component render effects must run before the DOM is read by anything that needs current geometry, and watch callbacks with flush: 'post' run after the DOM updates precisely so they can measure. That option exists because someone hit forced synchronous layout (bi-08).


6.8 Deep dive: the four models, on one axis that is not speed

ReduxReactVue reactivitySignals
Who knows what changedyou (actions)nobody — re-run and diffthe runtime (tracked reads)the runtime
Granularity of updatewhole subscriber setcomponent subtreeeffecteffect
Work proportional tosubscribersrendered tree sizenumber of tracked readsnumber of tracked reads
"State at time T" exists?yes, as one valueas props/state per componentno — scatteredno — scattered
Debug question"which action?""which component re-rendered?""which dependency fired?"same
Tooling requiredlog (trivial)render profilerdependency graph inspectorsame

The row that decides real architecture decisions is the fourth: does "the state at time T" exist as a value? Time travel, serialisation, undo, and crash-report state dumps all follow from it, and no amount of update efficiency substitutes.

Fill this table from your own implementations before reading anyone's comparison. §47 wants your version, and the exercise is worthless if it is copied.


7. Verification

  • mini-vue reactivity: all nine stages
  • mini-signals built independently
  • All six failure-lab bugs reproduced and fixed
  • Measured: update cost vs mini-react on (a) one deep change, (b) a broad change
  • §47 comparison table filled in your own words
  • Complexity-notebook entry: the Vue scheduler

8. Principal Engineer Review

  1. "Signals are faster than the virtual DOM." Give the accurate statement, including the workload where it reverses.

  2. Automatic dependency tracking removes author burden and hides the graph. Argue this is the right default; then design the debugging tool that makes it acceptable at scale.

  3. computed is lazy and cached. Enumerate the invariants that make caching safe, and the bug from breaking each.

  4. Why do refs need .value? Answer from the mechanism, then say what a language feature would have to provide to remove it.

  5. Nested effects require a stack. Construct the concrete corruption a single global causes.

  6. Compare Vue reactivity with signals: what is genuinely different, and what is naming?

  7. A team proposes replacing Redux with signals in a large app. Argue both sides in terms of debuggability and incident response, not performance.

  8. Design a reactivity system where dependencies are known at compile time. What must you forbid in the authoring language to make it sound?

References — fw-03-reactivity-signals

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-03 — Analysis

Required invariants

  1. An effect re-runs only if a value it actually read changed. This is the whole value proposition; failing it means you built "re-run everything."
  2. Dependencies are cleared before each re-run. Otherwise a branch no longer read keeps firing — a dependency leak.
  3. The active-effect context is a stack, not a global. A nested effect must not steal the outer effect's dependencies.
  4. computed is lazy and cached, and is simultaneously a dependent and a dependency.
  5. Effects are batched and deduplicated, flushed on a microtask.

The glitch, and why laziness fixes it

const a = signal(1);
const b = computed(() => a.value + 1);
const c = computed(() => a.value + b.value);

Set a = 2. A naive push order can evaluate c after a updated but before b did, computing 2 + 2 = 4 instead of 5. That transient wrong value is a glitch.

Fixes: evaluate in topological order, or make computed lazy so c pulls b. Laziness is cheaper, which is why computed is specified lazy rather than eager.

Test for it. Most hand-written reactive systems have this bug and never notice, because the glitch is transient and the final value is correct.

What tracking cannot see

CaseWhyMitigation
Destructuringreads once, then you hold a plain valuetoRefs-style wrappers
Collectionsmethods must be instrumented, not just property accesswrap Map/Set/array methods
Reads after awaitoutside the tracking contextre-establish, or forbid
Conditional readsstale deps accumulatecleanup before re-run

The trade against explicit models

Automatic tracking removes an authoring burden and adds an observability burden. With Redux you print the action log. With a reactive system, "why did this effect run?" needs a devtool that shows the graph — which is why every mature reactive framework ships one.

Execution — fw-03-reactivity-signals

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Build order — mini-vue reactivity

  1. reactive(obj) via Proxy — get/set traps.
  2. effect(fn) — a global "currently running effect" and a dependency map.
  3. Dependency tracking: track(target, key) on get, trigger(target, key) on set.
  4. ref(value) — the primitive-value case, and why it needs .value.
  5. computed(fn) — lazy, cached, and itself both a dependent and a dependency.
  6. watch(source, cb).
  7. Cleanup: effects must drop stale dependencies on re-run.
  8. Nested effects — an effect stack, not a single global.
  9. Scheduling and batching: a microtask-flushed queue, deduplicated.

4. Build order — mini-signals

Then rebuild the same capability with a minimal signal API (signal, computed, effect, batch). Deliberately do not reuse the Vue code. The point is to find out how much of it was essential and how much was Vue-specific.


5. Failure Lab

  1. Dependency leak. Omit cleanup on re-run. Build a case where an effect depends on a branch it no longer reads, and keeps firing. This is the bug cleanup exists for.
  2. Infinite loop. An effect that writes a value it reads. Predict, then observe. Then design the guard — and note what legitimate patterns your guard forbids.
  3. Stale computed. Break the cache invalidation so a computed returns an old value. Explain why lazy caching is harder than it looks.
  4. Lost reactivity. Destructure a reactive object and lose tracking. Explain to a hypothetical junior in three sentences.
  5. Nested effect corruption. Use a single global "current effect" instead of a stack; watch the inner effect steal the outer's dependencies.
  6. Batching absence. 1,000 writes in a loop with no scheduler. Measure. Then batch.

Observation — fw-03-reactivity-signals

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-03-reactivity-signals

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • mini-vue reactivity: all nine stages
  • mini-signals built independently, not refactored from the Vue code
  • All six failure-lab bugs reproduced and fixed
  • Measured: update cost vs mini-react for one deep change and one broad change
  • Comparison table filled in your own words
  • Complexity notebook: the Vue scheduler

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-03 — Broader Ideas

You have now built an invalidation engine twice

Blink's RuleFeatureSet and your dependency map solve the same problem: an index from "what changed" to "what must be recomputed," with a fallback when precision is impossible.

That equivalence is the payoff of running both tracks. Build systems, query caches, spreadsheet engines, and incremental compilers are all the same machine, and you now have a working mental model of it in 200 lines you wrote yourself.

Glitches are a distributed-systems problem in miniature

The diamond — a → b → d, a → c → d — evaluated in the wrong order produces a transient value that was never consistent. That is a consistency-under-concurrent-update problem, and the fixes (topological order, or laziness) are the same shapes used in dataflow systems and stream processors.

Test for it. Most hand-written reactive systems have this bug and never notice, because the final value is correct.

The observability tax

Automatic tracking removes an authoring burden and adds an observability burden: "why did this run?" needs a graph inspector. Every mature reactive framework ships one, and that is not a coincidence — it is the cost of the architecture.

Weigh this in framework choice explicitly. Debuggability at 3am is a real requirement, and it is where fw-01's explicit model keeps winning long after benchmarks stop mattering.

Next

fw-05 joins reactivity to a renderer; §47 asks you to fill the comparison from your own implementations — including a row for Blink, which is the point.

src — reactivity

tiny-test.mjs     test runner (plumbing)
spec.mjs          executable specification — run this
reactivity.js     ← YOU WRITE THIS (mini-vue)
signals.js        ← YOU WRITE THIS TOO, independently
node spec.mjs

Required exports from reactivity.js

reactive(obj)   ref(value)   effect(fn)   computed(fn)   watch(source, cb)   batch(fn)

effect(fn) runs fn immediately. batch(fn) flushes queued effects once at the end.

The two tests that matter

Stage 3 — an effect that never read b must not re-run when b changes. If that fails you have built "re-run everything on any write," which is not dependency tracking. Everything else in this module is downstream of getting stage 3 right.

Stage 5 — the stale-dependency test. An effect reads a or b depending on a flag; after the flag flips, writing the no-longer-read property must not trigger it. Failing here is a dependency leak: effects accumulate dependencies forever and fire on unrelated writes. This is precisely the bug that cleanup-before-re-run exists to prevent, and it is much more convincing after you have watched it happen.

Stage 6 fails if you use a single global "current effect" instead of a stack — the inner effect steals the outer's dependencies.

Then build signals independently

Write signals.js from scratch with a minimal API (signal, computed, effect, batch). Do not refactor the Vue code into it. The point is discovering how much of reactivity.js was essential and how much was Vue-specific. Copy this spec to signals.spec.mjs and adapt the API surface; the behavioural requirements should be identical, which is itself the finding.

Concepts — mini-react II: Batching, Scheduling, and Deriving Fiber

Phase 4, after bi-11 · Spec area §27 stages 8–11. Hard prerequisites: fw-02, bi-11 (Blink scheduling), bi-10 (frame budget).

This module exists separately because deriving Fiber before you have felt the frame budget produces transcription, not understanding. §45 is explicit: "Do not begin with production source. First derive a simple design."


1. Why a Principal Engineer needs this

Concurrent rendering, transitions, Suspense and streaming SSR are all scheduling abstractions. You cannot evaluate them, or decide whether their complexity is worth it for your product, without having built the constraint they answer to.

The specific insight this module produces: recursion is the enemy of interruptibility. A recursive render walks the tree using the call stack as its state, and a call stack cannot be paused, inspected, resumed, or thrown away. Everything Fiber-shaped follows from wanting those four verbs.


2. Derivation, not transcription

Work through this in order. Each step should feel forced by the previous one.

  1. Your fw-02 renderer is recursive. Render a tree of 10,000 nodes. Measure the task length. Compare to the frame budget from bi-10. Observe the dropped frames directly.
  2. Try to yield. Attempt to pause halfway through the recursive render and resume later. Discover that you cannot — the state you would need to resume is spread across the call stack.
  3. Therefore: make the stack an explicit data structure. A node with child, sibling, return pointers, walked with a loop instead of recursion. Now "where am I" is a value you can hold.
  4. Now yielding is possible. Loop while work remains and time remains; otherwise schedule a continuation (bi-11: which primitive, and why not microtasks?).
  5. Now you need two phases. Interruptible work must not touch the DOM, because a partially applied update is user-visible. So: a render phase that builds, and a commit phase that applies atomically and cannot be interrupted.
  6. Now you need double buffering. Building the new tree while the current one is displayed means two trees and a pointer swap — an alternate.
  7. Now you can prioritise. Different updates get different deadlines; a high-priority update can abandon in-progress low-priority work. Which forces: work must be discardable, so the render phase must have no side effects.
  8. Batching falls out. Multiple setState calls in one task coalesce into one render, because rendering is scheduled rather than immediate.

At the end, write down what you built. Then read React's Fiber. Every structural element you derived should be recognisable, and anything in React you did not derive is a question worth answering.


3. Failure Lab

  1. Commit interruption. Deliberately yield in the middle of the commit phase. Produce a visibly half-updated UI. This is why commit is atomic.
  2. Side effects in render. Mutate something external during the render phase, then have that render discarded by a higher-priority update. Observe the corruption. This is why render must be pure — and it is a far better explanation than "React is functional."
  3. Yield too often. Measure total time as you shrink the yield interval. Find the point where scheduling overhead dominates.
  4. Yield too rarely. Measure input delay. Find the point where the user notices.
  5. Starvation. Continuously schedule high-priority updates; watch low-priority work never complete. Then design the escalation rule. (Compare Chromium's anti-starvation, bi-11.)
  6. Tearing. Read a mutable external value at two points in one interruptible render, mutating it in between. Observe inconsistent output. This is the problem useSyncExternalStore exists for — meet it before reading about it.

4. Trade-offs to argue

Interruptible vs synchronous rendering. Interruptibility costs an explicit work loop, two trees, purity constraints on render, and substantial conceptual complexity. It buys responsiveness under load only if the work is actually interruptible — effects and layout do not yield.

Time-slicing vs doing less work. Slicing a 200 ms render makes it responsive; not rendering 200 ms of work makes it fast. When is each right?

Priorities as a public API. Transitions expose scheduling to authors. Compare with scheduler.postTask (bi-11) — the same trade-off, one layer up.


4.5 Deep dive: why the call stack is the enemy

Step 2 of the derivation asks you to try to pause a recursive render. It is worth stating exactly why you cannot.

A recursive renderer stores its progress in the call stack: which child of which parent, at what depth, with which locals. The stack is owned by the language runtime. You cannot:

  • inspect it (where am I?),
  • suspend it and resume later,
  • discard it without unwinding,
  • hold two of them (the current tree and the one you are building).

All four are required for interruptible rendering. So the stack must become a data structure you own: nodes with child / sibling / return pointers, walked by a loop.

This is the general technique, not a React trick. Any algorithm that must be pausable — generators, coroutines, async state machines, incremental garbage collection (bi-04), the compositor's tile raster queue (bi-10) — converts implicit stack state into explicit heap state. Recognising "this needs to be interruptible, therefore the recursion must become a data structure" is a transferable design move.

The return pointer is worth noting: it is a parent pointer, which a recursive traversal never needs because the stack provides it. Its presence in the data structure is a direct artefact of having removed the stack.


4.6 Deep dive: two phases, and the invariant each protects

PhaseInterruptible?May touch DOM?Invariant
Renderyesnowork is discardable, so it must have no observable effect
Commitnoyesthe user never sees a partially applied update

These are two statements of one requirement. Work can only be thrown away if it has not done anything; and output can only be consistent if it is applied all at once.

Everything else follows:

  • Render must be pure — not for functional-programming reasons, but because impure render work cannot be discarded. Your failure lab proves this directly.
  • Effects run after commit — they are side effects by definition, so they cannot be in the render phase.
  • Layout effects run after DOM mutation but before paint — because they measure, and measuring before the mutation is useless while measuring after paint causes a visible flash.
  • Commit cannot yield — hence the spec's assertion that exactly one continuation performs DOM operations.

4.7 Deep dive: tearing, and the external-store problem

Interruptibility plus mutable external state is a hazard. Your reference implementation hit it, and so will yours.

render component A  →  reads external store (value = 1)
   yield
                       ...store mutates to 2...
   resume
render component B  →  reads external store (value = 2)
   commit  →  UI shows 1 and 2 simultaneously

Internal state is safe because the runtime controls when it changes. External state is not.

Three possible fixes, with their costs:

  1. Restart on change — any update makes in-flight work stale, discard and restart. Simple, correct, wastes work. (This is what the reference implementation does, and what the spec enforces.)
  2. Snapshot at render start — read a consistent snapshot; requires the store to support it.
  3. Subscribe with a consistency check — the useSyncExternalStore approach: get a snapshot, and detect if it changed mid-render.

React needed a first-class API for this because the ecosystem's stores are arbitrary third-party objects with no shared contract. That is the real lesson: concurrency is not a property you can add to a runtime alone — it constrains everything the runtime integrates with.

If you have ever wondered why useSyncExternalStore exists and looks awkward, this is the entire answer.


4.8 Deep dive: does time-slicing actually help?

Be honest about this, because it is a real design question and the marketing is not.

Time-slicing makes a long render interruptible. It does not make it shorter — in fact total wall-clock goes up slightly from scheduling overhead.

It helps when:

  • the work is genuinely long (tens of ms),
  • the work is in the render phase (effects and layout do not yield),
  • there is competing higher-priority work (input) to yield to,
  • and the user would otherwise perceive the delay.

It does not help when:

  • the expensive work is in an effect, a layout read, or a third-party script,
  • the total work is small and the overhead dominates,
  • or the real fix is doing less work.

"Concurrent React fixes our INP" is true only when input delay is caused by long render phases. It is false — and a costly distraction — when the long tasks are effects, data processing, or getBoundingClientRect loops. The single diagnostic question is: "is the long task a render, or something else?" A LoAF scripts[] breakdown (bi-11) answers it in one recording.


4.9 Deep dive: priorities as a product decision

startTransition exposes scheduling to authors, and that is a genuine interface-design question, not just an implementation detail.

The author is asserting: "this update is less urgent than input." The runtime is then free to abandon and restart it. That is only safe if the update is idempotent and side-effect-free, which the API cannot enforce — it can only document.

Compare scheduler.postTask (bi-11): same shape one layer down, same problem. Both hand a scheduling lever to someone who does not see the whole system.

The governance question for a team you lead: who is allowed to mark work low-priority, and how do you stop everything drifting to user-blocking? Priority systems degrade to uniformity unless someone owns the policy — which is exactly why Chromium's scheduler has anti-starvation logic rather than trusting priorities to be assigned honestly.


5. Verification

  • Explicit work-loop renderer replacing recursion
  • Yielding with a justified choice of primitive (bi-11)
  • Separate render and commit phases; commit atomic
  • Double buffering
  • At least two priority levels, with abandonment of in-progress work
  • Automatic batching
  • All six failure-lab bugs reproduced
  • Complexity-notebook entry: Fiber — the flagship §46 entry of this track
  • Measured: p75 input delay under load, recursive vs interruptible

6. Principal Engineer Review

  1. Explain why interruptible rendering requires the render phase to be side-effect free, using your failure-lab result rather than an appeal to functional programming.

  2. Why can't effects be interrupted? What would break?

  3. React yields with MessageChannel rather than microtasks or setTimeout. Reconstruct the decision from bi-11, and say what scheduler.yield() changes.

  4. "Concurrent React fixes our INP." Under exactly which conditions is that true, and which false? What one question identifies which situation you are in?

  5. Tearing is a consequence of interruptibility plus external mutable state. Explain the mechanism, then evaluate the fix.

  6. Design a UI framework that is interruptible without a Fiber-like structure. What must you give up?

  7. Time-slicing makes a long render responsive but slightly slower overall. A senior engineer wants to revert it based on total wall-clock. Handle the disagreement — and say when they are right.

  8. Your app has a 300 ms render on a critical interaction. Rank: time-slice it, reduce the work, move it off-thread, or precompute. What decides?

References — fw-04-mini-react-scheduling

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-04 — Analysis

The generating constraint

A call stack cannot be paused, inspected, resumed, or duplicated.

Interruptible rendering needs all four, so the stack must become a heap data structure you own. The return (parent) pointer in a Fiber is the direct artefact: a recursive traversal never needs one, because the stack provides it.

Required invariants

  1. Render work is discardable, therefore side-effect free.
  2. Commit is atomic — exactly one continuation may touch the DOM.
  3. A new update makes in-flight work stale. Continuing it commits a tree where some components saw old state and some saw new: tearing.
  4. Two trees exist during render — current and work-in-progress.
  5. Higher priority abandons lower-priority in-progress work, which is only legal because of (1).

Invariants 1 and 2 are two statements of one requirement: discardable work must have done nothing, and output must be applied all at once.

The bug this module's spec caught

The reference implementation used to validate the spec failed the last test. Its scheduler treated a same-priority update arriving mid-render as "keep going," so a render half-finished with n = 1 continued after state became 2, and the commit mixed both.

The fix is one line of policy: any new update discards in-flight work and restarts. You are likely to write the same bug; the test will catch it.

This is the framework-level form of the defect useSyncExternalStore prevents at application level.

Does time-slicing help?

Helps whenDoes not help when
work is long (tens of ms)the long task is an effect or layout read
work is in the render phasetotal work is small; overhead dominates
there is higher-priority work to yield tothe real fix is doing less work

"Concurrent React fixes our INP" is true only when input delay comes from long render phases. The single diagnostic question is: is the long task a render, or something else? A LoAF scripts[] breakdown answers it in one recording.

Execution — fw-04-mini-react-scheduling

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


2. Derivation, not transcription

Work through this in order. Each step should feel forced by the previous one.

  1. Your fw-02 renderer is recursive. Render a tree of 10,000 nodes. Measure the task length. Compare to the frame budget from bi-10. Observe the dropped frames directly.
  2. Try to yield. Attempt to pause halfway through the recursive render and resume later. Discover that you cannot — the state you would need to resume is spread across the call stack.
  3. Therefore: make the stack an explicit data structure. A node with child, sibling, return pointers, walked with a loop instead of recursion. Now "where am I" is a value you can hold.
  4. Now yielding is possible. Loop while work remains and time remains; otherwise schedule a continuation (bi-11: which primitive, and why not microtasks?).
  5. Now you need two phases. Interruptible work must not touch the DOM, because a partially applied update is user-visible. So: a render phase that builds, and a commit phase that applies atomically and cannot be interrupted.
  6. Now you need double buffering. Building the new tree while the current one is displayed means two trees and a pointer swap — an alternate.
  7. Now you can prioritise. Different updates get different deadlines; a high-priority update can abandon in-progress low-priority work. Which forces: work must be discardable, so the render phase must have no side effects.
  8. Batching falls out. Multiple setState calls in one task coalesce into one render, because rendering is scheduled rather than immediate.

At the end, write down what you built. Then read React's Fiber. Every structural element you derived should be recognisable, and anything in React you did not derive is a question worth answering.


3. Failure Lab

  1. Commit interruption. Deliberately yield in the middle of the commit phase. Produce a visibly half-updated UI. This is why commit is atomic.
  2. Side effects in render. Mutate something external during the render phase, then have that render discarded by a higher-priority update. Observe the corruption. This is why render must be pure — and it is a far better explanation than "React is functional."
  3. Yield too often. Measure total time as you shrink the yield interval. Find the point where scheduling overhead dominates.
  4. Yield too rarely. Measure input delay. Find the point where the user notices.
  5. Starvation. Continuously schedule high-priority updates; watch low-priority work never complete. Then design the escalation rule. (Compare Chromium's anti-starvation, bi-11.)
  6. Tearing. Read a mutable external value at two points in one interruptible render, mutating it in between. Observe inconsistent output. This is the problem useSyncExternalStore exists for — meet it before reading about it.

Observation — fw-04-mini-react-scheduling

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-04-mini-react-scheduling

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • bi-11 completed first
  • Explicit work loop replacing recursion
  • Yielding with a justified primitive choice
  • Render and commit phases separated; commit atomic
  • Double buffering; two priority levels with abandonment
  • Automatic batching
  • All six failure-lab bugs reproduced, incl. tearing
  • Complexity notebook: Fiber — the flagship entry
  • Measured: p75 input delay under load, recursive vs interruptible

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-04 — Broader Ideas

Making the stack a data structure

"This must be interruptible, therefore the recursion must become an explicit structure" is a transferable design move. It appears in generators and coroutines, async state machines, incremental garbage collection (bi-04), the compositor's raster queue (bi-10), and any long job you want to pause and resume.

The tell that you need it: you want to inspect, suspend, discard, or duplicate progress, and the call stack gives you none of the four.

Concurrency constrains everything it integrates with

React needed useSyncExternalStore because the ecosystem's stores are arbitrary third-party objects with no shared contract. You cannot add concurrency to a runtime alone — it imposes requirements on every library it touches.

This is worth remembering before adopting any concurrency feature: the runtime change is the easy part; the ecosystem contract is the expensive one.

Purity as an enabling condition, not a philosophy

Render must be side-effect free so that work can be discarded, and work must be discardable so that output is consistent. Presenting it as "React is functional" loses the argument; presenting it as the two-line derivation wins it.

The same reframing works for immutability (fw-01), for pure reducers, and for idempotent job handlers: purity is what makes retry, cache, and cancellation legal.

Priorities need governance

startTransition hands a scheduling lever to someone who cannot see the whole system — the same problem as scheduler.postTask one layer down, and the reason Chromium's scheduler has anti-starvation logic rather than trusting priorities.

If you expose priorities in a system you own, plan who owns the policy.

Next

fw-11 sends you into React's source knowing exactly which structures you derived and which you did not — the ones you did not are your reading list.

src — mini-react scheduling (stages 8–11)

tiny-test.mjs           test runner (plumbing)
fake-dom.mjs            instrumented DOM, shared with fw-02 (plumbing)
scheduler-harness.mjs   deterministic clock + task queue (plumbing)
spec.mjs                executable specification — run this
runtime.js              ← YOU WRITE THIS
node spec.mjs

Prerequisite: bi-11. These tests describe behaviour whose justification lives there.

Additional required exports beyond fw-02

setScheduler({ now, schedule })   // injected clock + continuation primitive
setTimeSlice(ms)                  // budget before the work loop yields
startTransition(fn)               // updates inside fn are low priority

Why time is injected

Interruptible rendering cannot be tested against real time — you get flaky tests that assert nothing. So the runtime takes its clock and its "schedule a continuation" primitive as inputs and the test drives both, stepping one continuation at a time.

This is not a testing trick. It is why Chromium's scheduler is testable at all, and why React's scheduler has an injectable host config. Making time an input rather than an ambient effect is a design property, and you should carry it into your own systems.

What the tests actually enforce

They deliberately do not prescribe a Fiber structure. They assert observable properties:

  • Stage 9 — a 60 ms render with a 5 ms slice must span multiple continuations. A recursive renderer cannot satisfy this at all; that failure is the derivation in CONCEPTS.md §2 step 2, made concrete.
  • Stage 10 — the DOM must not change while render work is pending, and exactly one continuation may perform DOM operations. Render builds; only commit mutates.
  • Stage 11 — an urgent update must beat in-progress transition work, and a superseded render must leave no trace. That second one is why the render phase has to be side-effect free — not because "React is functional," but because discardable work cannot have done anything yet.

If stage 10 fails you will have seen a half-updated UI in a test rather than in production, which is the whole reason to build it this way.

What stage 11 caught when this spec was written

The reference implementation used to validate this spec failed the last test, and the reason is worth knowing before you hit it yourself.

Its scheduler treated a new update arriving mid-render as "same priority, keep going." So a render already half-finished with n = 1 continued after the state became 2, and the commit contained components that had seen the old state and components that had seen the new one.

That is tearing — and it is the same defect, at framework level, that useSyncExternalStore exists to prevent at application level (fw-11).

The fix is one line of policy: any new update makes in-flight render work stale, so discard it and restart. Note what makes that legal — you can only throw work away if it has not done anything yet. Stage 11's two tests are therefore the same requirement stated twice: render must be pure so that it can be discarded, and it must be discarded so that output is consistent.

You are likely to write the same bug. The test will catch it.

Concepts — A Vue-like Renderer, and the Architecture Comparison

Phase 5 · Spec areas §30, §47. Prerequisites: fw-02, fw-03, fw-04.


1. Why this module

fw-02 built diffing. fw-03 built dependency tracking. This module joins them — a VNode renderer driven by reactive effects — and that join is where the real question lives:

What work can each architecture avoid, and how does it know?

A component whose render is wrapped in an effect re-renders only when a value it actually read changes. The diff then only has to cover that component's output, not the tree below it. That is a fundamentally different cost curve from "re-render the subtree and diff," and articulating the difference precisely is the deliverable.


2. Build order

  1. VNode representation; h().
  2. render(vnode, container) — mount.
  3. patch(oldVNode, newVNode) — props, attributes, events, children.
  4. Keyed children diff. Implement a naive version, then the two-ended / longest-increasing- subsequence approach. Measure both on a shuffle.
  5. Components with lifecycle.
  6. The join: wrap each component's render in a fw-03 effect so reactive reads trigger re-render of that component only.
  7. Scheduler: dedupe and flush component updates on a microtask.

3. Failure Lab

  1. Naive keyed diff on a reversal — measure the DOM operation count against the smarter algorithm. Explain the algorithmic difference, not just the number.
  2. Component effect that reads a value conditionally — show the dependency set changing between renders, and what breaks without cleanup (fw-03).
  3. No scheduler: cause the same component to render three times in one tick.
  4. Event handler identity: re-creating handlers each render. Measure the cost of naive removal and re-addition versus a stable indirection.

3.5 Deep dive: keyed diff algorithms, actually compared

Your naive keyed diff and the production one differ algorithmically, and the difference is worth naming rather than accepting.

ApproachReorder costNotes
Naive: for each new child, find it in old, move itO(n²) lookups, O(n) DOM movescorrect, and quadratic
Map-based: index old children by keyO(n) lookups, still O(n) movesmost hand-written diffs stop here
Two-ended: walk from both ends inwardO(n), few moves for common editshandles prepend/append/reverse cheaply
Longest increasing subsequenceO(n log n), minimum movesVue 3's approach for the unmatched middle

The insight behind LIS: after matching keys, the new order is a permutation of the old. Elements already in relative order do not need moving. The longest increasing subsequence of old-indices is the largest set you can leave alone — everything else moves.

For [A,B,C,D,E] → [A,C,B,D,E], a map-based diff may move up to four nodes; LIS moves one.

When does this matter? Rarely, and that is the honest answer — most lists are appended to, not shuffled. It matters for drag-and-drop reordering, sortable tables, and animated list transitions, where every move is also a layout and paint cost. Measure the DOM-operation count in your lab before deciding it is worth the complexity in a system you own.


3.6 Deep dive: the component-effect join, and what it buys

Wrapping each component's render in a reactive effect is a small change with a large consequence:

effect(() => { patch(prevVNode, render(component)); });

Now a reactive read inside that component subscribes that component's render to that value. A change re-renders only that component — its parent does not re-run, and its children do not re-run unless their own dependencies changed.

Compare React: a state change re-renders the component and its subtree, unless memoisation intervenes. The difference is not "Vue is faster" — it is where the default sits:

DefaultEscape hatch
Reactre-render subtreememo, useMemo, useCallback
Vue / signalsre-render nothing but the tracked effectrarely needed

React's default is conservative and predictable; you opt into skipping work, and skipping wrongly causes stale UI. Vue's default is precise; you opt out of tracking, and losing tracking causes stale UI. Both have a failure mode; they are mirror images.

The organisational consequence, which is the part that matters at Principal level: React's model pushes performance work onto every engineer (correct memoisation is a per-component decision), while Vue's pushes it into the framework. That is a real hiring and code-review consideration, and it is a far better basis for a framework decision than benchmark numbers.


3.7 Deep dive: what patch flags add on top

fw-06's compiler annotates each VNode with which parts can change. The runtime then skips comparisons entirely:

// compiled output, conceptually
createElementVNode("div", { class: cls }, text, PatchFlags.CLASS | PatchFlags.TEXT)

patch() reads the flag and updates only class and text — no property enumeration, no full prop diff, no children reconciliation for static subtrees.

This is compile-time knowledge substituting for runtime work, and it is the axis on which the §47 matrix has a column. A template compiler can do this because templates are statically analysable; JSX largely cannot, because it is arbitrary JavaScript.

That single fact — templates are analysable, JSX is not — is the root of most React/Vue architectural divergence. It is not syntax preference, and framing it as such is the mark of someone who has not looked underneath.


4. The comparison matrix (§47)

Fill this from your own implementations. Axes: change detection · scheduling · memory · consistency · debuggability · incremental work · failure modes · extensibility · compile-time knowledge · runtime knowledge.

Rows: mini-react (fw-02/fw-04) · React · mini-vue (fw-03/fw-05) · Vue · signals · Redux (fw-01) · browser style invalidation (bi-07).

Include bi-07. Blink's invalidation sets are a change-detection system built to the same requirement, in C++, at a different scale. Putting it in the same table is the point of running both tracks — and it is the single most valuable row.

The specification is explicit: never reduce this to "which is faster."


5. Principal Engineer Review

  1. A component-level reactive renderer avoids re-rendering children. Name exactly what information it has that React does not, and what it pays for that information.

  2. React chose one model, Vue another, and both ship at enormous scale. What does that tell you about how much these choices actually matter, relative to what else you could work on?

  3. Keyed diff algorithms are a solved problem with real differences. When does the algorithm choice show up in a product, and when is it noise?

  4. Blink's style invalidation and Vue's reactivity solve the same abstract problem. Name two things Blink must handle that Vue does not, and what that costs it.

  5. You are designing a framework for a team of 200. Which model, and what is the deciding factor — performance, debuggability, or hiring?

References — fw-05-vue-renderer

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-05 — Analysis

Required invariants

  1. A component's render is an effect, so reactive reads subscribe that component's render.
  2. Keyed children preserve node identity across reorder.
  3. The scheduler deduplicates: one component renders once per flush regardless of how many of its dependencies changed.
  4. Patch flags are trusted. A wrong flag means a skipped update, silently.

Where the defaults sit

DefaultEscape hatchFailure mode of the escape hatch
Reactre-render subtreememo, useMemomemoise wrongly ⇒ stale UI
Vue / signalsre-render only the tracked effectuntracklose tracking ⇒ stale UI

Mirror images. React's default is conservative and predictable; Vue's is precise. Both have a stale-UI failure mode; they differ in what you must do to reach it.

The organisational consequence matters more than the mechanical one: React's model pushes performance work onto every engineer (correct memoisation is a per-component judgement), while Vue's pushes it into the framework. That is a hiring and code-review consideration, and a far better basis for a framework decision than benchmark numbers.

The keyed-diff spectrum

ApproachReorder cost
Find-in-old per childO(n²) lookups
Map by keyO(n) lookups, O(n) moves
Two-endedO(n), few moves for common edits
Longest increasing subsequenceO(n log n), minimum moves

LIS matters for drag-and-drop, sortable tables, and animated reorders, where each move is also layout and paint. For append-mostly lists it is noise. Measure the DOM-operation count before adopting the complexity.

Execution — fw-05-vue-renderer

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


2. Build order

  1. VNode representation; h().
  2. render(vnode, container) — mount.
  3. patch(oldVNode, newVNode) — props, attributes, events, children.
  4. Keyed children diff. Implement a naive version, then the two-ended / longest-increasing- subsequence approach. Measure both on a shuffle.
  5. Components with lifecycle.
  6. The join: wrap each component's render in a fw-03 effect so reactive reads trigger re-render of that component only.
  7. Scheduler: dedupe and flush component updates on a microtask.

3. Failure Lab

  1. Naive keyed diff on a reversal — measure the DOM operation count against the smarter algorithm. Explain the algorithmic difference, not just the number.
  2. Component effect that reads a value conditionally — show the dependency set changing between renders, and what breaks without cleanup (fw-03).
  3. No scheduler: cause the same component to render three times in one tick.
  4. Event handler identity: re-creating handlers each render. Measure the cost of naive removal and re-addition versus a stable indirection.

Observation — fw-05-vue-renderer

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-05-vue-renderer

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • VNode renderer with keyed diff (naive and optimised, both measured)
  • Component render wrapped in a reactive effect; only that component re-renders
  • Scheduler dedupes and flushes on a microtask
  • All four failure-lab items completed
  • Comparison matrix (§47) filled, including the Blink style-invalidation row

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-05 — Broader Ideas

Defaults determine where the work lands

React's conservative default pushes performance work onto every engineer; Vue's precise default pushes it into the framework. Both have a stale-UI failure mode reached by opposite mistakes.

That is a hiring, onboarding, and code-review consideration — and a far better basis for a framework decision than benchmark numbers, which are usually measuring a workload unlike yours.

Generalise it: any library's default is a decision about who does the remaining work. Ask that question of every dependency you adopt.

Compile-time knowledge as an architectural axis

Patch flags let the runtime skip comparisons entirely because the compiler already knew what could change. That axis — how much is known at build time versus runtime — separates React from Vue far more fundamentally than syntax does, and it is a column in the §47 matrix for that reason.

The same axis explains: static site generation vs SSR, typed vs dynamic languages, prepared statements vs ad-hoc SQL, and AOT vs JIT compilation.

The comparison matrix is the real deliverable

Ten axes, seven rows, including Blink's style invalidation. Putting a C++ rendering engine in the same table as three JavaScript frameworks is what demonstrates that change detection is one problem with a small number of known solutions.

Fill it from your own implementations. A copied matrix teaches nothing.

Next

fw-06 builds the compiler whose output this renderer consumes.

Concepts — Template and JSX Compilers

Phase 5 · Spec areas §31, §39. Prerequisites: bi-03 (you have written a tokenizer), fw-02, fw-05.


1. Why a Principal Engineer needs this

Compilation is how a framework buys information it cannot get at runtime. A template is statically analysable; a render() function generally is not. Everything a compiler-first framework does better — static hoisting, patch flags, dependency analysis, dead-code elimination — comes from that one asymmetry.

This module is also where bi-03's tokenizer pays off a second time. You have already written a state machine over markup; the skill transfers directly.


2. Mental Model

source -> lexer -> parser -> AST -> transform -> codegen -> render function

The interesting stage is transform, because that is where knowledge is added:

  • Static hoisting — a subtree with no dynamic bindings is created once, not per render.
  • Dynamic-node detection / patch flags — annotate which parts of a node can change, so the runtime diff can skip everything else. The compiler tells the runtime what to look at.
  • Compile-time dependency analysis — determine what a piece of markup reads, without running it.

The general principle: move work from runtime to build time, and move knowledge from the author to the runtime. Compare bi-07: Blink compiles stylesheets into RuleFeatureSet indices for exactly the same reason.


3. Build order

  1. Lexer for a small template language: text, interpolation {{ }}, elements, attributes, directives.
  2. Parser → AST, with source locations on every node.
  3. Transforms: interpolation, attribute binding, event binding, conditionals, loops.
  4. Codegen → a render function returning your fw-05 VNodes.
  5. Static hoisting: detect fully-static subtrees; emit them once.
  6. Patch flags: annotate dynamic bindings; make your renderer honour them.
  7. Error reporting with source locations — a caret-and-line message, not a stack trace.
  8. Then: a JSX-to-createElement transform over real JS, using an existing JS parser.

4. Failure Lab

  1. Drop source locations. Produce a compile error and try to act on it. This is why step 2 says every node.
  2. Hoist a subtree that is not actually static (it reads a variable). Show the stale render.
  3. Emit a wrong patch flag — mark a dynamic node static. Show the missed update. This is the central risk of compiler optimisation: a wrong annotation is a silent correctness bug, not a slow one.
  4. Codegen that breaks on a legal input (nested quotes, unicode, an expression containing }}).

4.5 Deep dive: what a compiler can know, precisely

The asymmetry that justifies compiler-first frameworks, stated concretely.

A template compiler can determine, without running anything:

  • which nodes are entirely static (no bindings anywhere in the subtree),
  • which attributes on a node are dynamic, and which specific ones,
  • the shape of the tree — how many children, of what types,
  • which variables an expression references,
  • whether a list has a stable key expression,
  • whether an event handler is a stable reference or recreated each render.

A JSX/JS render function generally cannot, because the "template" is arbitrary code:

{items.map(renderRow)}          // renderRow could be anything
{cond ? <A/> : <B/>}            // fine, analysable
{makeElement()}                  // opaque

The compiler must be conservative wherever it cannot prove a fact — and conservatism costs exactly the optimisation you wanted.

This is why React's compiler arrived a decade after Vue's: it had to become a whole-function analysis with escape hatches, rather than a template transform, and that is a substantially harder problem. Understanding the difficulty is more useful than tracking who shipped what.


4.6 Deep dive: the optimisations, and what each risks

OptimisationWinsSilent failure if wrong
Static hoistingsubtree created once, not per renderhoisted node mutates and is shared across instances
Patch flagsskip prop enumeration and children diffdynamic node marked static → update never appears
Static prop dedupshared prop objectsa mutated shared object leaks between instances
Inline component slotsfewer closuresscope captured wrongly
Tree flatteningskip static intermediate nodesdynamic descendant missed

Every row's failure is a correctness bug that manifests as "the UI didn't update," with no error and no stack trace pointing at the compiler.

This is the defining risk of compiler optimisation and the reason fw-06 matters more than it looks. A slow program is annoying; a program that silently shows stale data is a data-integrity incident. Any team shipping a compiler optimisation needs a testing strategy that runs the same suite with optimisations on and off and compares — which is exactly Chromium's virtual test suites idea (bi-13) applied to a compiler.


4.7 Deep dive: source maps, and why they are always slightly wrong

Source maps map generated positions back to original ones. They are lossy by construction:

  • Inlining destroys the one-to-one correspondence between call sites and functions.
  • Hoisting moves code to a position that has no meaningful original location.
  • Minification renames variables; the names field helps, but scopes get merged.
  • Multiple transforms chained (TS → JSX → bundler → minifier) require composing maps, and each composition loses fidelity.

The practical consequences you will meet: breakpoints landing one line off, variables not inspectable under their original names, and stack traces that point at the wrong function after inlining.

Design guidance: every transform in your pipeline must produce a map, and the maps must compose. A single transform that does not is enough to break debugging for the whole chain — which is why "we just do a quick regex replace on the output" is a decision with a debugging cost nobody prices at the time.


4.8 Deep dive: error messages are the compiler's real UX

Step 7 of the build order asks for caret-and-line errors, and it is not busywork.

error: unclosed element <div>
  --> Card.vue:12:3
   |
12 |   <div class="card">
   |   ^^^^ opened here, never closed
   |

versus

TypeError: Cannot read properties of undefined (reading 'children')
    at transform (compiler.js:412)

Both indicate the same bug. One takes five seconds to fix; the other takes twenty minutes and teaches the user to distrust the tool.

Getting this right requires that every AST node carries its source range, from lexing onward — which is why the build order puts source locations at step 2, before transforms exist. Retrofitting positions into an AST is painful and always incomplete; you cannot recover information you did not record.

The general rule: a compiler's error messages are the majority of its user interface. If you ever build a DSL, a config validator, or a schema checker for your organisation, this is the part that determines whether people adopt it.


5. Trade-offs

Compile-time vs runtime knowledge. The compiler knows the template shape; the runtime knows the actual data. Neither is sufficient alone, which is why compiled frameworks still have runtimes.

Optimisation vs debuggability. Hoisting and flags make generated code less like the source. Source maps are the mitigation and they are never perfect.

A template DSL vs plain JS. A DSL is analysable and constrained; JSX is expressive and mostly opaque to static analysis. This is the actual root of the React/Vue architectural divergence — not syntax preference.


6. Principal Engineer Review

  1. What can a template compiler know that a JSX compiler cannot, and what follows from that?

  2. A wrong patch flag is a silent correctness bug. Design the testing strategy that makes compiler optimisations safe to ship.

  3. Argue that frameworks should move as much as possible to compile time. Then argue that runtime flexibility is worth more. What decides for a given product?

  4. Source maps are always imperfect. What would you require before enabling an aggressive optimisation in a large org?

  5. React added a compiler after a decade of runtime-only. Reconstruct why that took so long, and what changed.

References — fw-06-compilers

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-06 — Analysis

The asymmetry that justifies compilers

A template is statically analysable; a render function generally is not. Everything a compiler-first framework does better follows from that one fact.

A template compiler can determine without running anything: which subtrees are fully static, which specific attributes are dynamic, the tree's shape, which variables an expression references, whether a key expression is stable.

A JSX/JS render function cannot, wherever the code is opaque — and the compiler must be conservative exactly where it cannot prove a fact, which costs precisely the optimisation you wanted.

That is why React's compiler arrived a decade after Vue's: it had to become a whole-function analysis with escape hatches rather than a template transform.

Required invariants

  1. Every AST node carries a source range, from lexing onward. Positions cannot be recovered later.
  2. A static hoist must be genuinely static — no reads of anything dynamic.
  3. A patch flag must be a superset of what can change. Under-flagging is a correctness bug.
  4. Generated code must be debuggable, which means composable source maps at every stage.

Why compiler optimisation is riskier than it looks

OptimisationSilent failure if wrong
Static hoistinga hoisted node mutates and is shared across instances
Patch flagsdynamic node marked static ⇒ update never appears
Static prop dedupa mutated shared object leaks between instances
Tree flatteninga dynamic descendant is missed

Every failure is a correctness bug presenting as "the UI didn't update" — no error, no stack trace pointing at the compiler. A slow program is annoying; one that silently shows stale data is a data-integrity incident.

The testing strategy that follows: run the same suite with optimisations on and off and compare. That is Chromium's virtual-test-suite idea (bi-13) applied to a compiler.

Error messages are the product

A caret-and-line error takes five seconds to act on; Cannot read properties of undefined takes twenty minutes and teaches distrust. A compiler's error messages are the majority of its user interface — which is why source ranges are step 2 of the build order, not an afterthought.

Execution — fw-06-compilers

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Build order

  1. Lexer for a small template language: text, interpolation {{ }}, elements, attributes, directives.
  2. Parser → AST, with source locations on every node.
  3. Transforms: interpolation, attribute binding, event binding, conditionals, loops.
  4. Codegen → a render function returning your fw-05 VNodes.
  5. Static hoisting: detect fully-static subtrees; emit them once.
  6. Patch flags: annotate dynamic bindings; make your renderer honour them.
  7. Error reporting with source locations — a caret-and-line message, not a stack trace.
  8. Then: a JSX-to-createElement transform over real JS, using an existing JS parser.

4. Failure Lab

  1. Drop source locations. Produce a compile error and try to act on it. This is why step 2 says every node.
  2. Hoist a subtree that is not actually static (it reads a variable). Show the stale render.
  3. Emit a wrong patch flag — mark a dynamic node static. Show the missed update. This is the central risk of compiler optimisation: a wrong annotation is a silent correctness bug, not a slow one.
  4. Codegen that breaks on a legal input (nested quotes, unicode, an expression containing }}).

Observation — fw-06-compilers

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-06-compilers

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Template compiler: lexer, parser, transforms, codegen -> VNodes
  • Source locations on every AST node; caret-and-line error messages
  • Static hoisting and patch flags implemented and honoured by the renderer
  • Wrong-patch-flag silent correctness bug demonstrated
  • JSX transform built over a real JS parser

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-06 — Broader Ideas

Every framework is buying information

A compiler exists to obtain, at build time, facts the runtime cannot derive. That is the same trade as: types, prepared statements, database query plans, AOT compilation, and static site generation.

When a runtime is being conservative, ask what a build step could prove. That question is how compiler-first frameworks were invented, and it is available to you inside your own codebase — a codemod, a lint rule, or a generated manifest is a small compiler.

Silent correctness failures deserve special handling

A wrong patch flag or a wrong sideEffects declaration produces stale output with no error. That class of bug needs a different testing strategy from ordinary bugs: run the suite with optimisations on and off and compare — Chromium's virtual-test-suite idea (bi-13) applied to a compiler.

If you ship any build-time optimisation — bundler config, a codemod, a caching layer — this is the safety net that makes it responsible rather than brave.

Error messages are the interface

Caret-and-line versus a stack trace inside the compiler is the difference between five seconds and twenty minutes, and between adoption and distrust. Source ranges must be recorded from lexing onward, because you cannot recover information you did not capture.

If you build a DSL, config validator, or schema checker for your organisation, this determines whether anyone uses it. It is the most under-invested part of most internal tooling.

Next

fw-07 consumes the output and adds the graph-level concerns: splitting, shaking, and caching.

Concepts — A Minimal Bundler

Phase 5 · Spec area §38. Prerequisites: fw-06.


1. Why a Principal Engineer needs this

Build tooling is where most large frontend organisations lose the most time, and where decisions are most often made by folklore. Having built a bundler — even a small one — converts "webpack is slow" into specific claims about graph construction, transform caching, and invalidation.

The deeper connection: a bundler is an incremental compiler with a cache-invalidation problem, which is the same problem as bi-07 style invalidation and bi-08 layout caching. Third time you have met it in this track. That repetition is the point.


2. Build order

entry -> parse imports -> dependency graph -> transform -> bundle
  1. Parse a module's imports (use an existing JS parser).
  2. Build the dependency graph; detect cycles.
  3. Transform each module (reuse fw-06).
  4. Emit a bundle with a tiny runtime module registry.
  5. Code splitting on dynamic import() — emit separate chunks, load on demand.
  6. Tree shaking: mark exports used; drop unused. Then find a case where it is unsafe (side effects at module scope) and implement the conservative fallback.
  7. Source maps.
  8. Caching + incremental rebuild: content-hash each module; rebuild only affected paths.
  9. HMR concepts: what must be true for a module to be replaceable without a reload?

3. Failure Lab

  1. Stale cache. Key the cache on filename only, not content. Produce a wrong bundle.
  2. Unsafe tree shaking. Drop a module with an import side effect. Break the app.
  3. Cycle. Create a circular import with top-level usage; observe the partially-initialised module.
  4. Broken source map. Off-by-one the mapping and try to debug through it.
  5. Over-splitting. Split into 200 chunks; measure the network cost against one bundle on a throttled connection.

3.5 Deep dive: why tree shaking is unsound without help

Static removal of unused exports requires proving that removal changes nothing observable. That proof is impossible in general:

// module.js
import './polyfill';                 // side effect at module scope
window.registry.push(thing);         // side effect
export const unused = 1;

Removing module.js because unused is unreferenced deletes the polyfill and the registration. The bundler cannot know those matter — and it cannot know they don't.

Hence "sideEffects": false in package.json: the author promising that importing this module for its exports alone is safe.

Fourth instance of the pattern in this track: key (fw-02), {passive:true} (bi-10), contain (bi-07), sideEffects (fw-07). Each time, a static analysis is provably insufficient, so the platform adds a way for the author to assert what the tool cannot derive.

When you build a tool that must be conservative, the design question is not "how do I analyse harder?" — it is "what is the smallest promise the author could make that would unblock me?"

The corollary that bites in practice: "sideEffects": false is a claim, and a wrong claim produces a bundle that is missing code, with no error. Which is the fw-06 risk again — author assertions and compiler optimisations fail the same way, silently.


3.6 Deep dive: the cache-invalidation problem, for the third time

A bundler is an incremental compiler, and its central difficulty is knowing what to rebuild.

Cache keyFails when
filenamecontents change
mtimefiles touched without change; checkouts; containers
content hasha dependency's content changed
content hash + resolved dependency hashescorrect — and requires a full graph
+ config, plugin versions, env varscorrect in practice

The last row is why build caches are invalidated by "unrelated" changes: a plugin upgrade or an env var legitimately changes the output of every module.

You have now met result-caching-keyed-on-complete-inputs four times: layout results (bi-08), paint subsequences (bi-09), computed (fw-03), and here. The failure mode is identical every time — an incomplete key produces stale output with no error — and so is the fix: enumerate the inputs exhaustively, and prefer a key that is expensive to compute over one that is incomplete.


3.7 Deep dive: code splitting, and the cost nobody counts

Splitting reduces initial bytes and adds:

  • an extra network round trip per chunk on the critical path (unless preloaded),
  • runtime module-registry bookkeeping,
  • risk of request waterfalls — chunk A loads, then discovers it needs chunk B,
  • cache-invalidation coupling: a change in a shared module invalidates every chunk containing it.

That last point drives real bundler design. Naive splitting duplicates shared modules into every chunk; smarter splitting extracts common chunks, which then become a single invalidation point for everything. There is no configuration that is simultaneously optimal for first load and for repeat-visit caching, which is why this is a product decision informed by your actual traffic mix, not a best practice.

The measurement that settles it: on a throttled connection, compare time-to-interactive for one bundle versus your split configuration. fw-07's failure lab does exactly this, and the result frequently surprises people who split aggressively on principle.


3.8 Deep dive: HMR, and what it requires of a module

Hot module replacement needs a module to be replaceable at runtime, which requires:

  1. Identifying what changed and its dependents.
  2. Deciding a boundary — how far up the dependency graph to propagate before giving up and reloading.
  3. Preserving state across the swap — which the module must cooperate with.
  4. Disposing the old module's side effects (listeners, timers, subscriptions).

Point 4 is the one that makes HMR hard and unreliable in practice: a module that registered a global listener and does not clean it up accumulates listeners on every hot update. That is why HMR "works" for pure view components and is unreliable for stateful services — and why frameworks provide explicit hot.dispose hooks.

The connection worth noticing: this is fw-02's effect-cleanup problem, at module granularity. Any system that re-runs code must define what "undo the previous run" means, and the systems that skip that definition are the ones that leak.

4. Trade-offs

Bundling vs native ES modules. Bundling reduces requests and enables cross-module optimisation; native modules remove a build step and improve cacheability. HTTP/2 and HTTP/3 changed this calculus — but not as much as commonly claimed. Measure.

Aggressive tree shaking vs correctness. Side effects make static removal unsound in general; sideEffects metadata is the industry's admission that the analysis needs author help. Same shape as keys in fw-02 and {passive:true} in bi-10 — an author-supplied guarantee unlocking an optimisation.

Caching granularity. Fine-grained caches invalidate less and cost more bookkeeping.


5. Principal Engineer Review

  1. Tree shaking is unsound without author annotations. Explain why, and evaluate sideEffects as a solution.

  2. Your build takes 8 minutes. Enumerate causes in order of likelihood and the measurement for each.

  3. Code splitting reduces initial bytes and adds requests and complexity. What decides the split granularity, and what would you measure?

  4. Compare a bundler's invalidation problem with Blink's style invalidation (bi-07). What is genuinely the same, and what is different?

  5. A team wants to move from a bundler to native ESM in development. Argue both sides from mechanism.

References — fw-07-bundler

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-07 — Analysis

Required invariants

  1. The dependency graph is complete before any transform decision. Splitting and shaking are graph properties, not per-file ones.
  2. A cache key captures every input that affects the output — contents, resolved dependency hashes, config, plugin versions, env.
  3. Removal must preserve observable behaviour, which static analysis cannot prove in general.
  4. Every transform emits a source map, and the maps compose.

Why tree shaking is unsound without help

import './polyfill';            // side effect at module scope
window.registry.push(thing);    // side effect
export const unused = 1;

Removing the module because unused is unreferenced deletes the polyfill. The bundler cannot know those matter — and cannot know they don't. Hence "sideEffects": false: the author promising what the tool cannot derive.

A wrong promise produces a bundle missing code, with no error — the same silent-failure shape as a wrong patch flag (fw-06). Author assertions and compiler optimisations fail identically.

The cache-key table

KeyFails when
filenamecontents change
mtimetouched files; fresh checkouts; containers
content hasha dependency's content changed
content hash + dependency hashescorrect — needs the full graph
+ config, plugins, envcorrect in practice

This is the fourth appearance of caching-keyed-on-complete-inputs in the curriculum — after layout results, paint subsequences, and computed. The failure mode is identical every time: stale output, no error.

Code splitting costs that nobody counts

  • an extra round trip per chunk on the critical path,
  • runtime registry bookkeeping,
  • request waterfalls when chunk A discovers it needs chunk B,
  • cache coupling: a shared module's change invalidates every chunk containing it.

There is no configuration optimal for both first load and repeat-visit caching, which makes this a product decision informed by your traffic mix, not a best practice.

HMR's real requirement

Replaceability needs disposal: a module that registered a listener and does not clean up accumulates listeners on every hot update. That is fw-02's effect-cleanup problem at module granularity — any system that re-runs code must define what "undo the previous run" means, and the ones that skip that definition leak.

Execution — fw-07-bundler

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


2. Build order

entry -> parse imports -> dependency graph -> transform -> bundle
  1. Parse a module's imports (use an existing JS parser).
  2. Build the dependency graph; detect cycles.
  3. Transform each module (reuse fw-06).
  4. Emit a bundle with a tiny runtime module registry.
  5. Code splitting on dynamic import() — emit separate chunks, load on demand.
  6. Tree shaking: mark exports used; drop unused. Then find a case where it is unsafe (side effects at module scope) and implement the conservative fallback.
  7. Source maps.
  8. Caching + incremental rebuild: content-hash each module; rebuild only affected paths.
  9. HMR concepts: what must be true for a module to be replaceable without a reload?

3. Failure Lab

  1. Stale cache. Key the cache on filename only, not content. Produce a wrong bundle.
  2. Unsafe tree shaking. Drop a module with an import side effect. Break the app.
  3. Cycle. Create a circular import with top-level usage; observe the partially-initialised module.
  4. Broken source map. Off-by-one the mapping and try to debug through it.
  5. Over-splitting. Split into 200 chunks; measure the network cost against one bundle on a throttled connection.

Observation — fw-07-bundler

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-07-bundler

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • Dependency graph, transform, bundle with module registry
  • Code splitting on dynamic import; tree shaking with a conservative fallback
  • Source maps; content-hash caching with incremental rebuild
  • All five failure-lab bugs reproduced
  • Over-splitting measured against one bundle on a throttled connection

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-07 — Broader Ideas

Cache invalidation, the fourth encounter

Layout results, paint subsequences, computed values, and now module builds. Same rule, same failure: an incomplete key produces stale output with no error.

Having met it four times in different languages and layers, you should now reach for the same checklist whenever you add a cache: enumerate every input, include configuration and tool versions, and prefer an expensive-but-complete key over a cheap-but-partial one.

Author assertions carry risk

"sideEffects": false is a promise, and a wrong promise silently removes code. It is the same shape as a wrong patch flag (fw-06) and, one layer down, the same shape as {passive: true} lying.

Author-supplied guarantees are powerful because they are unverifiable — which is exactly why they are dangerous. Any system accepting such a promise should have a way to run without trusting it, for verification.

There is no universally right splitting strategy

First-load bytes and repeat-visit cache stability pull in opposite directions, and shared-chunk extraction couples invalidation. This is a product decision informed by your actual traffic mix.

The measurement that settles it — throttled time-to-interactive, one bundle versus your config — frequently surprises teams who split aggressively on principle.

Disposal is the hard half of hot reload

A module that registers a listener and never cleans up accumulates them on every update. This is fw-02's effect cleanup at module granularity: any system that re-runs code must define what "undo the previous run" means. Systems that skip that definition leak, in every language.

Next

fw-08 and fw-09 move from build-time to runtime concurrency — where the analogous silent failure is a stale response rather than a stale bundle.

Concepts — A Client-Side Router

Phase 5 · Spec area §34. Prerequisites: fw-02.

1. Why this matters

Routing looks trivial and is not. It is a state machine over asynchronous, cancellable, user-interruptible transitions, layered on a History API with quirks. Almost every hard router bug is a concurrency bug: two navigations in flight, a loader resolving after its navigation was abandoned, or the back button racing a redirect.

This is the module where §35's race-condition skills first appear, and the two share a root cause.

2. Build order

  1. URL parsing and route matching (static, params, wildcards); ranking by specificity.
  2. History API: pushState, replaceState, popstate. Note what popstate does not tell you.
  3. Nested routes and a matched-route chain.
  4. Redirects, including loops — detect and fail loudly.
  5. Loaders: async data per route, resolved before the transition commits.
  6. Cancellation: a navigation superseded by another must abort its loaders.
  7. Error routes and error boundaries per level.
  8. Back/forward, including during an in-flight navigation.
  9. Scroll restoration.
  10. Blocking navigation on unsaved changes.

3. Failure Lab

  1. Stale loader. Navigate A→B→C quickly with random loader latencies. Land on C, show B's data. Reproduce it deterministically — if your reproduction is "click fast," you have observed it, not reproduced it.
  2. Back during load. Press back while a loader is pending. Where does the state end up?
  3. Redirect loop. A→B→A. What does the user see, and what should they see?
  4. Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
  5. Double submit. Two rapid clicks on a link with a side-effecting loader.

For (1), implement three fixes — AbortController, a navigation sequence number, and render-time guarding — then rank them. Ask which is correct when the loader has a server side effect. That question is the seam into fw-09.

3.5 Deep dive: navigation is a state machine with cancellation

Almost every hard router bug is a concurrency bug. Model navigation explicitly:

idle ──navigate──► loading ──resolved──► committing ──► idle
                     │  │
                     │  └──superseded──► discarded
                     └──error──────────► error route

The transitions that produce bugs:

  • superseded — a second navigation starts while the first is loading. The first must be cancelled and must not commit.
  • back during loadpopstate arrives mid-navigation. Which wins, and what is the URL now?
  • redirect during load — a loader returns a redirect; the original navigation must not commit.
  • error during load — which boundary catches it, and does the URL change?

The invariant to name: only the most recent navigation may commit. Every stale-data bug in a router is a violation of it, and stating it in those terms turns four separate bugs into one.

The three fixes, ranked

FixMechanismStops the work?Correct with server side effects?
Render-time guardcompare response's URL to currentnono — the effect happened
Sequence numberignore responses with a stale idnono — same
AbortControllersignal cancellation to the requestyes, best-effortclosest — the server may still have acted

The ranking is: AbortController plus a sequence-number guard. Cancellation is best-effort — the request may already have reached the server — so you still need to ignore late responses.

The question that separates a mid-level from a senior answer: what if the loader has a server side effect? Then no client-side fix is sufficient. You need idempotency keys, or the operation must not be in a loader at all. That is the seam into fw-09, and it is a genuinely architectural answer rather than a code fix.


3.6 Deep dive: the History API's sharp edges

RealityConsequence
popstate does not say directionyou cannot tell back from forward without tracking indices yourself
pushState does not fire popstateyour own navigations need explicit handling
History entries have a state object with a size limitdo not store your app state there
You cannot read the history stackno "can I go back?" without your own bookkeeping
You cannot cancel a back navigationwhich is why unsaved-changes guards are hard

That last row is a deliberate platform decision: allowing pages to trap the back button would be user-hostile. So beforeunload is limited to a browser-controlled dialog, and in-app blocking only works for navigations your router controls.

The Navigation API is the modern answer — it exposes an intercept-able navigation event with real cancellation — and knowing why it exists (the table above) is more valuable than knowing its method names.

Scroll restoration

history.scrollRestoration = 'manual' hands you the job. Doing it correctly requires restoring after the content that determines page height exists, which in an async-loading app means after data resolves — and possibly after images load. Restoring too early scrolls to a position that does not exist yet and silently clamps to the bottom.

This is the same anchoring problem as fw-10's virtualized list and the browser's own scroll anchoring (bi-08). Three encounters, one problem: you cannot restore a position in a document whose size you do not yet know.

4. Trade-offs

Commit-then-load vs load-then-commit. Showing the new route immediately with a spinner feels faster and can flash; waiting feels slower and is consistent. Frameworks disagree, and the right answer is product-specific.

Router-owned data vs component-owned. Loaders remove waterfalls and couple data to routes.

5. Principal Engineer Review

  1. Name the invariant violated by a stale loader overwrite, and defend your preferred fix including the side-effecting case.
  2. popstate does not distinguish back from forward. What does that force routers to build?
  3. Loaders eliminate request waterfalls but couple data to URL structure. When is that wrong?
  4. Design navigation blocking for unsaved changes that works with the back button. What can you not do, and why is that a platform decision?
  5. Compare commit-then-load with load-then-commit on perceived performance and on correctness.

References — fw-08-router

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-08 — Analysis

The single invariant

Only the most recent navigation may commit.

Every stale-data bug in a router violates it. Stating it this way collapses four separate bugs — stale loader, back-during-load, redirect race, double submit — into one.

Required invariants

  1. Only the most recent navigation commits.
  2. A superseded navigation's loaders are cancelled and their results discarded.
  3. A redirect during loading prevents the original navigation from committing.
  4. Scroll restoration happens after the content determining page height exists.
  5. Navigation blocking cannot silently swallow a browser back navigation.

The three fixes, and where each stops working

FixStops the work?Correct with a server side effect?
Render-time guardnono — the effect already happened
Sequence numbernono
AbortControllerbest-effortclosest — the server may still have acted

Ship AbortController plus a sequence guard: cancellation is best-effort, so late responses must still be ignored.

The question that separates seniority levels: what if the loader has a server side effect? No client-side fix suffices. You need idempotency keys, or the operation does not belong in a loader. That is an architectural answer, not a code fix.

Platform constraints you cannot engineer around

RealityConsequence
popstate does not report directiontrack indices yourself
pushState does not fire popstatehandle your own navigations explicitly
The history stack is unreadableno "can I go back?" without bookkeeping
Back cannot be cancelledunsaved-changes guards only work for router-controlled navigation

The last is a deliberate platform decision — trapping the back button would be user-hostile — which is why beforeunload is limited to a browser-controlled dialog. The Navigation API exists to give back a principled version of the capability.

The recurring anchoring problem

Restoring scroll before async content resolves scrolls to a position that does not exist and clamps. This is the same problem as fw-10's virtualized list and the browser's own scroll anchoring (bi-08): you cannot restore a position in a document whose size you do not yet know.

Execution — fw-08-router

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


2. Build order

  1. URL parsing and route matching (static, params, wildcards); ranking by specificity.
  2. History API: pushState, replaceState, popstate. Note what popstate does not tell you.
  3. Nested routes and a matched-route chain.
  4. Redirects, including loops — detect and fail loudly.
  5. Loaders: async data per route, resolved before the transition commits.
  6. Cancellation: a navigation superseded by another must abort its loaders.
  7. Error routes and error boundaries per level.
  8. Back/forward, including during an in-flight navigation.
  9. Scroll restoration.
  10. Blocking navigation on unsaved changes.

3. Failure Lab

  1. Stale loader. Navigate A→B→C quickly with random loader latencies. Land on C, show B's data. Reproduce it deterministically — if your reproduction is "click fast," you have observed it, not reproduced it.
  2. Back during load. Press back while a loader is pending. Where does the state end up?
  3. Redirect loop. A→B→A. What does the user see, and what should they see?
  4. Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
  5. Double submit. Two rapid clicks on a link with a side-effecting loader.

For (1), implement three fixes — AbortController, a navigation sequence number, and render-time guarding — then rank them. Ask which is correct when the loader has a server side effect. That question is the seam into fw-09.

Observation — fw-08-router

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-08-router

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • All ten build stages implemented
  • Stale-loader race reproduced deterministically, not just observed
  • Three fixes implemented and ranked, incl. the server-side-effect case
  • Back-during-load, redirect loop, scroll restoration, double submit all handled

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-08 — Broader Ideas

Cancellation is a system-wide property

"Only the most recent navigation may commit" generalises to: only the most recent search should render, only the most recent autocomplete request should populate, only the most recent tab switch should load.

The three fixes and their limits — guard, sequence number, abort — are the same everywhere, and the question that exposes the limit is always the same: what if the request had a server side effect? Then the answer moves from the client to idempotency keys or to a different API design.

That escalation, from code fix to architectural fix, is the Principal-level move.

Platform constraints you should recognise as deliberate

Back cannot be cancelled; the history stack cannot be read; popstate does not report direction. These are user-protective decisions, not oversights. Recognising the difference between "the platform is missing a feature" and "the platform is refusing on the user's behalf" saves a great deal of wasted argument — and points you at the sanctioned alternative (the Navigation API) rather than a hack.

The anchoring problem, three times

Router scroll restoration, virtualized lists (fw-10), and the browser's own scroll anchoring (bi-08) are one problem: you cannot restore a position in a document whose size you do not yet know.

Any UI that restores state into asynchronously-sized content has it — infinite scroll, chat histories, lazy images. The solution shape is always: reserve space, or restore after size is known.

Next

fw-09 takes the same race-condition discipline to server state, where the stakes include writes.

Concepts — A Server-State Query Cache

Phase 5 · Spec area §35. Prerequisites: fw-08.

1. Why this matters

Server state is not client state, and treating it as such is one of the most expensive architectural mistakes in frontend work. Server state is shared, stale by default, and asynchronously invalidated by actors you cannot see. A query cache is the machinery that admits this.

This is also the densest concentration of race conditions in the track. Build the races deliberately.

2. Architecture

query key -> cache entry { data, status, updatedAt, subscribers }
          -> fetch (deduplicated, cancellable)
          -> notify subscribers

3. Build order

  1. Key normalisation (structural equality, stable ordering).
  2. Cache with status: fresh | stale | fetching | error.
  3. Deduplication: N simultaneous subscribers, one request.
  4. Stale-while-revalidate.
  5. Retries with backoff; distinguish retriable from terminal errors.
  6. Cancellation on unsubscribe.
  7. Invalidation, exact and by key prefix.
  8. Background refetch (focus, reconnect, interval).
  9. Optimistic updates with rollback.
  10. Garbage collection of unobserved entries.
  11. Pagination and dependent queries.

4. Failure Lab — build every race

  1. Out-of-order responses. Request A then B for the same key; A resolves last. Show A's data winning. Fix with a sequence number; explain why timestamps are insufficient.
  2. Optimistic rollback onto a changed base. Apply an optimistic update, then a different server update arrives, then the optimistic one fails. Roll back to what?
  3. Dedup + cancellation. Three subscribers share one request; one unsubscribes. Does the request abort? Should it?
  4. Refetch storm. Window focus triggers refetch of 50 queries simultaneously.
  5. GC race. An entry is collected while a component is mid-mount.
  6. Cross-tab. Two tabs, same key, one mutates.

4.5 Deep dive: why timestamps fail for last-write-wins

The obvious fix for out-of-order responses is "keep the newest." It does not work.

t=0   request A dispatched
t=10  request B dispatched
t=50  response B arrives   (server processed at t=20)
t=90  response A arrives   (server processed at t=15)

Which is newer? By response arrival, A. By server processing, B. By request dispatch, B. Only the third is under your control and monotonic on the client.

Client clocks are also not trustworthy across tabs, and server timestamps have clock skew and insufficient resolution — two writes in the same millisecond are indistinguishable.

The rule: order by a monotonic counter you control, incremented at dispatch. Not by wall clock, not by arrival, not by server time. A sequence number is one integer and it is exactly correct.

Same conclusion as fw-08's navigation ordering, and for the same reason — which is why these two modules are adjacent.


4.6 Deep dive: optimistic rollback when the base moved

The genuinely hard case, and the one most implementations get wrong.

state:  { title: "A" }
user edits    → optimistic { title: "B" }
server push   → { title: "C", author: "X" }   (someone else changed it)
your mutation FAILS

Roll back to what? "A" discards the other user's change. Keep "C" and the optimistic edit silently vanishes — which is correct but confusing. Merge and you are writing a CRDT.

The practical answers, in increasing order of honesty:

  1. Snapshot-and-restore — restore the pre-mutation value. Simple; loses concurrent updates.
  2. Invalidate and refetch — discard local state, ask the server. Correct, costs a round trip, and flickers.
  3. Store the optimistic change as a separate layer applied over server state, removed on failure. Correct and composable; substantially more machinery.

Most libraries do (1) and document it. Option (3) is what you need when concurrent editing is real.

Design rule worth stating in a review: optimistic UI is a latency optimisation that trades correctness under concurrency. It is right for low-contention data (your own profile) and wrong for high-contention data (a shared counter, a seat booking). "Is this data contended?" is the question, and it is a product question, not a technical one.


4.7 Deep dive: the cache-key problem

useQuery(['todos', { status: 'done', page: 1 }])
useQuery(['todos', { page: 1, status: 'done' }])   // same query, different object

Keys must be structurally compared with stable ordering, or you get duplicate entries, doubled requests, and invalidations that miss.

The sharp edges:

  • Key order must not matter — serialise deterministically.
  • Undefined vs missing should usually be the same key.
  • Functions and class instances in keys are un-serialisable and usually a design error.
  • Partial matching for invalidation (['todos'] invalidates ['todos', ...]) requires the key to be a path, which is why array keys beat string keys.

Note the resemblance to bi-08's constraint-space cache key and fw-07's content hash: the key must capture every input that affects the value, and no more. Too little and you serve stale data; too much and you never hit the cache.


4.8 Deep dive: normalised vs document caches

Document cacheNormalised cache
Storeswhole responses per keyentities by id, queries as id lists
Update one entitymust invalidate every query containing itupdate once, all queries see it
Requiresnothinga schema, or id extraction
Complexitylowhigh
Chosen byReact Query, SWRApollo, Relay, Redux Toolkit Query (partly)

The trade is: normalised caches give automatic consistency across queries and demand that you describe your data model to the cache. Document caches stay simple and push consistency onto explicit invalidation.

Most libraries chose document caches, and the reason is worth understanding: REST responses have no reliable identity. Without ids and a schema you cannot normalise. GraphQL clients normalise because GraphQL gives them __typename and id for free.

That is a case where the data format determined the client architecture — a good example for any argument about API design having downstream consequences far beyond the wire.

5. Trade-offs

Cache-first vs network-first. Latency vs freshness; the right answer differs per query, which is why these libraries are configuration-heavy.

Normalised vs document cache. Normalisation gives consistency across queries and costs a schema and complexity. Most libraries chose document caches; say why.

Optimistic updates. Better perceived latency, and rollback is genuinely hard when the base moved underneath.

6. Principal Engineer Review

  1. Why is a timestamp insufficient for last-write-wins? Give the failing scenario.
  2. Optimistic update rollback when the base changed: specify the correct behaviour, and defend it.
  3. Argue that server state should never live in a client state manager. Then give the exception.
  4. Design invalidation for a mutation affecting an unknown set of queries. What do you give up?
  5. A team reports "the cache shows stale data sometimes." Give your diagnostic procedure.

References — fw-09-query-cache

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-09 — Analysis

The premise

Server state is shared, stale by default, and asynchronously invalidated by actors you cannot see. Treating it as client state is one of the most expensive architectural mistakes in frontend work; a query cache is the machinery that admits the difference.

Required invariants

  1. Order by a monotonic counter you control, incremented at dispatch. Not wall clock, not arrival order, not server time.
  2. One in-flight request per key, shared by all subscribers.
  3. A cancelled or superseded response never writes to the cache.
  4. Cache keys compare structurally, with stable ordering.
  5. Rollback restores a defined state, and you have decided which.

Why timestamps fail

t=0   request A dispatched
t=10  request B dispatched
t=50  response B arrives   (server processed at t=20)
t=90  response A arrives   (server processed at t=15)

By arrival, A is newest. By server processing, B. By dispatch, B. Only dispatch order is under your control and monotonic on the client. Client clocks disagree across tabs; server timestamps have skew and insufficient resolution.

A sequence number is one integer and it is exactly correct. Same conclusion as fw-08's navigation ordering, which is why the two modules are adjacent.

Optimistic rollback when the base moved

StrategyCorrectnessCost
Snapshot and restoreloses concurrent updatestrivial
Invalidate and refetchcorrecta round trip, and a flicker
Optimistic layer over server statecorrect and composablesubstantial machinery

Most libraries do the first and document it. The rule worth stating in review: optimistic UI is a latency optimisation that trades correctness under concurrency. Right for low-contention data (your own profile), wrong for high-contention data (a seat booking). "Is this data contended?" is a product question.

Normalised vs document caches

Document caches stay simple and push consistency onto explicit invalidation; normalised caches give automatic cross-query consistency and demand a schema. Most libraries chose document caches — and the reason is that REST responses have no reliable identity. GraphQL clients normalise because the format hands them __typename and id.

The data format determined the client architecture. A good example for any argument that API design has consequences far beyond the wire.

Execution — fw-09-query-cache

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


3. Build order

  1. Key normalisation (structural equality, stable ordering).
  2. Cache with status: fresh | stale | fetching | error.
  3. Deduplication: N simultaneous subscribers, one request.
  4. Stale-while-revalidate.
  5. Retries with backoff; distinguish retriable from terminal errors.
  6. Cancellation on unsubscribe.
  7. Invalidation, exact and by key prefix.
  8. Background refetch (focus, reconnect, interval).
  9. Optimistic updates with rollback.
  10. Garbage collection of unobserved entries.
  11. Pagination and dependent queries.

4. Failure Lab — build every race

  1. Out-of-order responses. Request A then B for the same key; A resolves last. Show A's data winning. Fix with a sequence number; explain why timestamps are insufficient.
  2. Optimistic rollback onto a changed base. Apply an optimistic update, then a different server update arrives, then the optimistic one fails. Roll back to what?
  3. Dedup + cancellation. Three subscribers share one request; one unsubscribes. Does the request abort? Should it?
  4. Refetch storm. Window focus triggers refetch of 50 queries simultaneously.
  5. GC race. An entry is collected while a component is mid-mount.
  6. Cross-tab. Two tabs, same key, one mutates.

Observation — fw-09-query-cache

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-09-query-cache

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • All eleven build stages implemented
  • All six races built deliberately and fixed
  • Out-of-order response fix uses a sequence number; why timestamps fail is explained
  • Optimistic rollback onto a changed base specified and defended

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-09 — Broader Ideas

Server state is a different substance

Shared, stale by default, mutated by actors you cannot see. Most frontend architecture mistakes at scale come from putting it in the same container as client state and then discovering that "refresh," "invalidate," "retry," and "who else changed this" have no meaning there.

The clean split — client state in a store, server state in a cache — is one of the highest-value architectural decisions available to a frontend team, and it is worth arguing for from mechanism rather than from library preference.

Monotonic counters beat clocks

The proof that timestamps fail for last-write-wins is short and complete, and it generalises to every distributed ordering problem you will meet: Lamport clocks, sequence numbers, and version vectors all exist because wall clocks disagree.

Any time you find yourself comparing timestamps to decide which of two events is newer, stop and ask whether a counter under one authority is available.

Optimistic UI is a contention bet

It trades correctness under concurrency for latency. Right for low-contention data, wrong for high-contention data — and "is this data contended?" is a product question that engineers routinely answer by default rather than by asking.

Rollback onto a moved base is the case that exposes it, and the three strategies have genuinely different costs.

Data format shapes client architecture

Most libraries chose document caches over normalised ones because REST responses have no reliable identity. GraphQL clients normalise because the format supplies __typename and id.

That is a strong, concrete example for any argument that API design has consequences far beyond the wire — useful the next time a backend team proposes a shape "because it is easier to serve."

Next

fw-10 is where cache, render cost, and the platform's own affordances meet in one component.

Concepts — A Virtualized List Engine

Phase 4 · Spec area §37. Prerequisites: bi-08, bi-09, bi-10.

1. Why this matters

Virtualization is the clearest case in application code where browser internals determine the architecture. You cannot design it well without knowing what DOM size costs (bi-04), what layout costs (bi-08), and why scrolling is compositor-driven (bi-10).

It is also where "measure, don't guess" is unavoidable: dynamic row heights require measurement, and measurement forces layout.

2. Build order

Render 100,000 logical rows with a small DOM window.

  1. Fixed heights: viewport → visible index range → render window + overscan.
  2. Absolute positioning or transform offset for the window. Choose deliberately and justify it from bi-09.
  3. Dynamic heights: measure rendered rows, cache measurements, correct the estimate.
  4. Scroll anchoring: keep the visually-anchored row stable when an above-viewport measurement changes.
  5. Fast scroll: what to show when you outrun measurement.
  6. Accessibility: what a screen reader sees when only 20 of 100,000 rows exist.

3. Failure Lab

  1. Measurement thrash. Measure every row every frame. Watch forced synchronous layout (bi-08).
  2. Anchor drift. Skip scroll anchoring; watch content jump as heights resolve.
  3. Overscan zero. Blank rows on fast scroll — the app-level analogue of checkerboarding (bi-10).
  4. DOM growth leak. Recycle rows incorrectly so the DOM grows; find it in a heap snapshot.
  5. Scroll handler on the main thread. Update the window in a scroll handler; measure the one-frame lag. Compare an IntersectionObserver/sentinel approach.

3.5 Deep dive: the measurement problem

Fixed heights make position arithmetic O(1): offset = index * rowHeight. Dynamic heights destroy that, and the resulting problems are all one problem — you cannot know the total height without measuring every row, and you cannot measure a row without rendering it.

The standard resolution is estimate-then-correct:

  1. Assume an estimated height for unmeasured rows.
  2. Measure rows as they render; cache by item id (not by index — indices shift).
  3. Maintain a prefix-sum structure so "what is the offset of row N" stays fast.
  4. When a measurement differs from the estimate, correct the scroll offset if the changed row is above the viewport, or the content under the user's finger jumps.

Step 4 is the one that separates a working virtualizer from a demo. It is scroll anchoring (bi-08), implemented by hand, and it is why the browser's built-in version exists.

A prefix-sum / Fenwick tree over row heights gives O(log n) offset lookups and O(log n) updates, versus O(n) for a naive running total recomputed on every measurement. At 100,000 rows that is the difference between smooth and unusable.


3.6 Deep dive: why measurement is expensive, precisely

Measuring means reading geometry, which forces style and layout to be current (bi-08). Doing it per row, per frame, interleaved with writes, is the forced-synchronous-layout bug at scale.

The mitigations, in order of preference:

  1. ResizeObserver — delivered inside the rendering steps, after layout, so it does not force a synchronous flush (bi-11).
  2. Batch reads, then writes — measure everything, then apply all offsets.
  3. IntersectionObserver for visibility rather than repeated getBoundingClientRect.
  4. content-visibility: auto with contain-intrinsic-size — let the engine skip layout for offscreen content and supply an estimate, which is the platform doing your job.

Option 4 is worth serious consideration before writing a virtualizer at all: it keeps every row in the DOM (so find-in-page, accessibility, and anchor links work) while skipping their layout and paint. It does not reduce DOM size, so it is not a substitute at 100,000 rows — but at 2,000 it frequently is.


3.7 Deep dive: what virtualization breaks

This is the section most tutorials omit, and it is where the real engineering judgement is.

BreaksWhyMitigation
Find-in-page (Ctrl+F)offscreen rows are not in the DOMnone reliable — a genuine loss
Screen reader navigationAT sees 20 of 100,000 rowsaria-setsize / aria-posinset, and a real grid role
Anchor links / scrollIntoViewtarget may not existroute through your own index → scroll
Tab orderfocusable elements appear and disappearmanage focus on recycle; never leave focus on a removed node
Text selection across rowsselection breaks at the window edgerarely fixable
Printonly the window printsrender an unvirtualized print view
Browser scroll restorationheight changes as rows measuremanual restoration (fw-08)

The accessibility row deserves emphasis. aria-setsize and aria-posinset let you tell assistive technology "this is item 4,207 of 100,000" even though only 20 exist — an author-supplied promise substituting for information the runtime cannot observe, which is the fifth instance of that pattern in this track.

The Principal-level framing: virtualization is not a free optimisation. It trades a set of platform behaviours you got for nothing in exchange for render performance. Whether that trade is right depends on the product — a data grid for analysts, yes; a documentation page, almost certainly not. The engineer who reaches for virtualization by default has not priced the right-hand side.

4. Trade-offs

Fixed vs dynamic heights. Fixed makes position arithmetic O(1) and is often a lie about the data.

Bigger overscan. Fewer blanks, more DOM, more layout.

transform vs top. bi-09's property-tree argument, applied.

Virtualization vs content-visibility. The platform now offers a partial answer. When does the built-in beat the library?

5. Principal Engineer Review

  1. Explain why virtualization helps, in terms of which pipeline stages it removes work from.
  2. Dynamic measurement forces layout. Design the scheme that minimises it; state what you give up.
  3. Argue that content-visibility: auto makes list virtualization obsolete. Then defeat it.
  4. What does virtualization do to accessibility and find-in-page, and what is your mitigation?
  5. A 100k-row grid must support sort, filter, and inline edit. Where does virtualization stop being the hard part?

References — fw-10-virtual-list

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-10 — Analysis

Required invariants

  1. Measurements are cached by item identity, not by index. Indices shift; identities do not.
  2. A measurement correction above the viewport adjusts scroll offset, or content jumps under the user's finger.
  3. Reads are batched separately from writes, or measurement becomes forced synchronous layout.
  4. Total height is derivable in better than O(n) — a prefix-sum or Fenwick structure.
  5. Removed rows release focus and listeners, or recycling leaks.

The circularity at the centre

You cannot know total height without measuring every row; you cannot measure a row without rendering it. The resolution is estimate → measure → correct, and step "correct" is what separates a working virtualizer from a demo. It is scroll anchoring (bi-08) implemented by hand — which is precisely why the browser ships its own.

At 100,000 rows, a naive running total recomputed per measurement is O(n) per update; a Fenwick tree is O(log n). That is the difference between smooth and unusable.

Why measurement is expensive

Measuring reads geometry, which forces style and layout to be current. Per row, per frame, interleaved with writes, that is the forced-synchronous-layout bug at scale. Mitigations in preference order: ResizeObserver (delivered inside the rendering steps, after layout), batched read-then-write, IntersectionObserver for visibility, and content-visibility: auto with contain-intrinsic-size.

What virtualization breaks

BreaksMitigation
Find-in-pagenone reliable — a genuine loss
Screen-reader navigationaria-setsize / aria-posinset, a real grid role
Anchor links, scrollIntoViewroute through your own index
Tab order and focusmanage focus on recycle
Text selection across rowsrarely fixable
Printrender an unvirtualized print view
Browser scroll restorationmanual restoration

aria-setsize is the pattern again: an author-supplied promise substituting for information the runtime cannot observe.

The judgement

Virtualization trades platform behaviours you got for free against render performance. Right for a data grid; almost certainly wrong for a documentation page. An engineer who reaches for it by default has not priced the right-hand column. Before adopting it, check whether content-visibility: auto alone is sufficient — at a couple of thousand rows it frequently is, and it keeps every row in the DOM.

Execution — fw-10-virtual-list

Steps extracted from CONCEPTS.md. Read the concepts first; this file is the doing.

Record results in observation.md; tick checkpoints in verification.md.


2. Build order

Render 100,000 logical rows with a small DOM window.

  1. Fixed heights: viewport → visible index range → render window + overscan.
  2. Absolute positioning or transform offset for the window. Choose deliberately and justify it from bi-09.
  3. Dynamic heights: measure rendered rows, cache measurements, correct the estimate.
  4. Scroll anchoring: keep the visually-anchored row stable when an above-viewport measurement changes.
  5. Fast scroll: what to show when you outrun measurement.
  6. Accessibility: what a screen reader sees when only 20 of 100,000 rows exist.

3. Failure Lab

  1. Measurement thrash. Measure every row every frame. Watch forced synchronous layout (bi-08).
  2. Anchor drift. Skip scroll anchoring; watch content jump as heights resolve.
  3. Overscan zero. Blank rows on fast scroll — the app-level analogue of checkerboarding (bi-10).
  4. DOM growth leak. Recycle rows incorrectly so the DOM grows; find it in a heap snapshot.
  5. Scroll handler on the main thread. Update the window in a scroll handler; measure the one-frame lag. Compare an IntersectionObserver/sentinel approach.

Observation — fw-10-virtual-list

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-10-virtual-list

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • 100,000 rows with a small DOM window; fixed then dynamic heights
  • Scroll anchoring keeps the anchored row stable
  • All five failure-lab items completed
  • transform vs top choice justified from bi-09
  • Accessibility consequences documented with a mitigation

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-10 — Broader Ideas

The clearest case of internals determining architecture

You cannot design a good virtualizer without knowing DOM cost (bi-04), layout cost and forced synchronous layout (bi-08), paint and property trees (bi-09), and compositor scrolling (bi-10).

It is the module that proves the browser track was worth doing, because every decision — transform versus top, ResizeObserver versus getBoundingClientRect, overscan size — is decided by a fact from another module.

Check whether the platform already did it

content-visibility: auto with contain-intrinsic-size keeps every row in the DOM while skipping their layout and paint. At a couple of thousand rows it frequently beats a virtualizer and keeps find-in-page, accessibility, anchor links, and print working.

The general habit: before building a mechanism, check whether the platform has shipped a declarative version. The platform's version is usually worse at the extreme and much better at everything surrounding it.

Pricing the right-hand column

Virtualization trades platform behaviours you got for free — find-in-page, screen-reader navigation, tab order, selection, print, scroll restoration — for render performance. Several have no reliable mitigation.

An engineer who reaches for it by default has priced only the left column. Stating the trade explicitly in a design review is a small act that materially improves decisions.

aria-setsize is the pattern again

Telling assistive technology "item 4,207 of 100,000" when only 20 exist is an author-supplied promise substituting for information the runtime cannot observe — the fifth instance in this curriculum.

Next

fw-12 builds the accessibility queries that make the mitigation testable rather than aspirational.

Concepts — Production Source Apprenticeship: React and Vue

Phase 5 · Spec areas §28, §32, §44 Levels 2–3, §46. Prerequisites: fw-02, fw-03, fw-04, fw-05 — all of them built first.

§45 is explicit: "Do not begin with production source. First derive a simple design. Then use production source to discover the constraints that forced additional complexity." This module is the second half of that sentence, and it is worthless without the first.


1. The method

For every area you read, you are answering one question: what did production have to handle that my version did not?

Three outcomes are possible per difference, and classifying them is the actual skill:

  • Essential architecture — my design cannot meet a real requirement without it.
  • Production hardening — my design works but fails on scale, edge cases, or hostile input.
  • Accreted complexity — historical, and a greenfield implementation would omit it.

Most engineers classify everything as the first. Being able to identify the third — with evidence from git history — is what separates reading source from understanding it.


2. React reading list

Read in this order; each maps to something you built.

AreaYour counterpartQuestion to answer
Element creationfw-02 stage 1what does the real element carry that yours does not, and why?
Reconciliation / child difffw-02 stages 3–4which cases does yours get wrong?
Fiber structurefw-04 step 3which fields did you not derive, and what is each for?
Work loopfw-04 steps 4–7how are priorities represented, and how is work abandoned?
Scheduler packagebi-11why MessageChannel; what does the deadline model assume?
Hooksfw-02 stage 6how is the slot list actually stored, and what does the dispatcher indirection buy?
DOM rendererfw-02 stage 2property vs attribute handling — the bi-04 distinction, in production
Eventswhy synthetic events? what does delegation cost, and what changed in React 17?
Commitfw-04 step 5what are the commit sub-phases and why is the order forced?
Hydrationhow are mismatches detected and recovered?
Server renderingwhat does streaming require of the component model?

3. Vue reading list

AreaYour counterpartQuestion
reactivityfw-03how are dep sets stored; what does cleanup actually do?
runtime-corefw-05scheduler queue, job dedup, flush timing
runtime-domfw-05prop patching, event handler caching
compiler-corefw-06transform pipeline, patch-flag generation
compiler-domfw-06DOM-specific transforms

3.5 Deep dive: the three classifications, with worked examples

The method asks you to classify every difference as essential architecture, production hardening, or accretion. That is only useful if you can tell them apart, so here are worked examples from this track's own material.

Essential architecture — the requirement forces it

Fiber's explicit work loop. Interruptibility requires the stack to be a data structure (fw-04 §4.5). There is no simpler design that yields the same capability. Your own derivation proves it: you tried to pause recursion and could not.

Property trees in Blink (bi-09). Without them, a transform change re-records display items. The capability "change position without repainting" is impossible otherwise.

Two-phase render/commit. Discardable work cannot have side effects; consistent output requires atomic application. Two statements of one requirement.

Production hardening — works in the small, fails in the large

Redux's listener snapshotting (fw-01). The naive version is correct until a listener unsubscribes during notification. Not an architectural insight — a bug that had to be closed.

Blink's fragment-parsing fast path (bi-03). The general algorithm is correct; the fast path exists because innerHTML is hot. It bails out to the general path, which is the tell: a fast path that falls back is hardening, not architecture.

Style sharing (bi-07). Correctness does not require it; 10,000-row tables do.

Accretion — historical, and a greenfield design would omit it

The script-escaping tokenizer states (bi-03). kScriptDataDoubleEscaped* exists because 1990s pages wrapped scripts in HTML comments. Nobody would design it.

<input type=hidden> inside <table> (the parsing lab). A compatibility fossil, not a principle — as the answer key argues.

[LegacyLenientThis], [LegacyUnforgeable] and friends in Web IDL (bi-05). The Legacy prefix is the platform labelling its own accretion, which is unusually honest and makes them easy to find.

The tell for accretion: the code has a name containing Legacy, Compat, Quirks, or a comment citing a specific site or year. The tell for essential architecture: removing it makes a capability impossible, not merely a case wrong. The tell for hardening: it is a fast path or a guard around an otherwise-correct implementation.

Being able to say "this is accretion and here is the CL that introduced it" is what separates reading source from understanding it — and it is the specific skill that lets you argue for removing complexity in systems you own.


3.6 Deep dive: reading production source without drowning

React and Vue are large. A procedure that terminates:

  1. Start from your own implementation's file, not theirs. "Where is their version of my reconcileChildren?"
  2. Find the entry point by searching for the public API name, then follow one call inward.
  3. Read the type/flow definitions first if they exist — a Fiber's field list tells you more in two minutes than an hour of following calls.
  4. Skip the bailout paths on the first pass. Production code is mostly early returns for cases you do not have. Read the main path, then come back.
  5. Keep a deferred list (bi-01). Names you chose not to follow are your map.
  6. Stop when you can explain the difference from your version. That is the deliverable, not full comprehension.

Point 4 deserves emphasis: in most production functions, the majority of the lines are cases your implementation does not have. Reading them in order makes the function look incomprehensible; reading the main path first makes the extra cases legible as answers to specific questions.


3.7 Deep dive: specific questions worth carrying into the source

Not "read the scheduler" — these:

React

  • Why does a Fiber initialise every field in the constructor, even to null? (bi-05, hidden classes — you now know the answer, so confirm it.)
  • What exactly is a "lane," and why lanes rather than a numeric priority?
  • Where is the double buffer (current / alternate), and what happens to the discarded tree?
  • What does bailout check, and how does it skip a subtree?
  • Why do effects have separate passive and layout phases? When does each run relative to paint?
  • How is hydration mismatch detected, and what is recoverable versus fatal?
  • What does the scheduler use as its host callback, and what changes with scheduler.yield()?

Vue

  • How are dependency sets stored — per target, per key? What is the memory shape at 10,000 reactive objects?
  • What exactly does effect cleanup remove, and when? (You built this; compare.)
  • How does the scheduler order pre / post / sync jobs, and why does flush: 'post' exist?
  • How are patch flags consumed in patchElement? Find the switch.
  • Where is the LIS computation, and what does it do with the unmatched middle?

Each has a specific answer you can find and check against your own implementation. A question you can answer wrongly is worth ten times a topic you can read about.

4. The gate (§44)

For each area, all eight questions in writing: why it exists, what invariant it maintains, who calls it, what it calls, which thread runs it, what happens if removed, how it is tested, what simpler design fails and why.

Log every reading in fe-00-roadmap/docs/learning-log.md §3.


5. Complexity notebook (§46)

Required entries, using the template in PROGRESS.md:

  • Fiber — the flagship entry
  • The Vue scheduler
  • Hydration
  • Event delegation / synthetic events
  • Concurrent rendering

For each, classify the complexity per §1 above and defend the classification with evidence — a commit message, an issue, a test that exists only because of a specific bug.


6. Principal Engineer Review

  1. Name one piece of React complexity you classify as accreted rather than essential. Defend it with evidence, and say what a greenfield implementation would do instead.

  2. Synthetic events: reconstruct the original justification, then evaluate whether it still holds.

  3. Hydration mismatches are recoverable in some cases and not others. What is the rule, and what does that imply for how you write SSR components?

  4. Vue's scheduler and React's scheduler solve overlapping problems differently. Name the deepest difference, and what each optimises.

  5. After reading both: which architecture would you choose for a new product, and what is the strongest argument against your choice?

  6. You now have mini-implementations and production source for both. What did building first teach you that reading first would not have?

References — fw-11-production-source

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-11 — Analysis

The method, and why order matters

"Do not begin with production source. First derive a simple design. Then use production source to discover the constraints that forced additional complexity."

This module is the second half of that sentence and is worthless without the first. Reading Fiber before deriving it produces recognition, not understanding — you will be able to name the fields and unable to say why any of them exist.

The classification, with the tells

ClassDefinitionTell
Essential architectureremoving it makes a capability impossibleyour derivation independently produced it
Production hardeningworks in the small, fails at scale or on hostile inputit is a fast path that bails out, or a guard around correct code
Accretionhistorical; a greenfield design would omit itnames containing Legacy, Compat, Quirks; comments citing a year or a site

Most engineers classify everything as essential. Identifying the third — with evidence from git history — is the skill, and it is what licenses you to argue for removing complexity in systems you own.

Worked examples from this curriculum

  • Fiber's explicit work loop — essential; you derived it.
  • Property trees in Blink — essential; without them a transform change re-records.
  • Redux listener snapshotting — hardening; correct until a listener unsubscribes mid-loop.
  • Blink's fragment fast path — hardening; it bails out, which is the tell.
  • kScriptDataDoubleEscaped*accretion; comment-wrapped scripts.
  • [LegacyLenientThis]accretion, self-labelled by the platform.

A reading procedure that terminates

  1. Start from your file, not theirs: "where is their version of my reconcileChildren?"
  2. Read type definitions first — a Fiber's field list teaches more in two minutes than an hour of call-following.
  3. Skip bailout paths on the first pass. In most production functions the majority of lines are cases your implementation does not have; reading them in order makes the function look incomprehensible.
  4. Keep a deferred list.
  5. Stop when you can explain the difference. That is the deliverable — not full comprehension.

Questions beat topics

"Read the scheduler" is not a task. "Why does a Fiber initialise every field to null in the constructor?" is — and you already know the answer from bi-05, so you can check whether you are right. A question you can answer wrongly is worth ten times a topic you can read about.

Observation — fw-11-production-source

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-11-production-source

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • All React reading-list areas covered with the eight gate questions
  • All Vue reading-list areas covered
  • Every difference from your implementation classified: essential / hardening / accretion
  • Classifications defended with evidence (commit, issue, or test)
  • All five required complexity-notebook entries written
  • Readings logged in fe-00-roadmap/docs/learning-log.md §3

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-11 — Broader Ideas

Classification is the exportable skill

Essential architecture / production hardening / accretion, defended with evidence from history. Most engineers classify everything as essential, which makes them unable to argue for simplification.

Apply it to your own codebase: which complexity is forced by a requirement, which is hardening earned by a real incident, and which is accretion nobody has dared remove? The third category is where your simplification budget should go, and the tells — Legacy/Compat names, comments citing a year, code with no test — are the same as in Chromium.

Derive-then-read, as a team practice

The rule that made this curriculum work — build a small version before reading the real one — is a training method you can run for others. Before a team adopts a library, have someone implement the core in an afternoon. The questions they generate are better than any evaluation checklist, and the adoption decision improves immediately.

Reading procedures beat reading

"Skip the bailout paths on the first pass" is the highest-leverage single tip here. In most production functions the majority of lines are cases your implementation does not have; read the main path first and the rest become legible as answers to specific questions.

Combine with a deferred list and a stopping rule and large-codebase reading stops being intimidating and becomes a routine, bounded activity.

Next

bi-16 collects the classifications into the complexity notebook, and asks you to defend each one.

Concepts — Testing Library, Browser Automation, and DevTools

Phase 5 · Spec areas §40, §41, §42. Prerequisites: fw-02, bi-04.


1. Why these are one module

All three are instrumentation: making a running system observable, from three angles. Building small versions of each teaches what your tools are actually doing, which is what lets you diagnose them when they lie to you.


2. mini-testing-library (§40)

Build user-centric queries: by role, by accessible name, by label, by visible text.

DOM -> accessibility tree -> testing queries -> user semantics

The lesson is that the accessibility tree is the honest description of your UI. A query by role that cannot find your button is telling you a real thing about real users — the same information, arriving through a channel engineers actually read.

Build order: visible-text query → label association (all the mechanisms: for, wrapping, aria-label, aria-labelledby) → role computation for a subset of elements → accessible-name computation → assertion helpers.

Failure lab: find four ways to make a button that your role query cannot find. Each is a real accessibility bug. Then find a case where your query succeeds and a real screen reader would still fail the user — and say what that means about testing-library-style tests as an accessibility strategy.


3. mini-automation (§41)

A small layer over a browser automation protocol: navigation, selectors, DOM queries, event dispatch, screenshots, network interception, console events, tracing, frames, contexts.

Key insight: synthetic events dispatched from JS are not the same as real input. A dispatchEvent(new MouseEvent('click')) skips hit testing, does not go through the compositor (bi-10), and has isTrusted: false. Automation protocols inject at a lower level for exactly this reason. Demonstrate the difference — it is the clearest possible illustration of why the input path in bi-02 matters.

Connect to the vertical trace: an automated click is a real opportunity to observe OS input → browser → compositor → renderer → handler.


4. mini-devtools (§42)

Instrument your fw-02/fw-05 runtime to display: component tree, render counts, state changes, network requests, performance marks.

The hard part is not the UI — it is instrumenting without changing what you measure. Your observer must not itself cause re-renders, retain components (a bi-04 leak), or add so much overhead that the timings become fiction.

Then study the Chrome DevTools architecture and the DevTools Protocol: DevTools is a client speaking a protocol to the browser, which is why it can run remotely and why the protocol is also what automation uses. That single fact explains the whole design.


4.5 Deep dive: accessible name computation is an algorithm

"Query by accessible name" sounds simple. The computation is a specified algorithm (accname) with a precedence order, and implementing a subset teaches you why so much ARIA advice is wrong.

Roughly, in order:

  1. aria-labelledby (follow the ids, recursively)
  2. aria-label
  3. native host-language labelling — <label for>, wrapping <label>, <caption>, alt, <legend>, title as a last resort
  4. subtree text content, if the role permits name-from-content
  5. title attribute

Facts that fall out and that matter in real code:

  • aria-labelledby beats visible text. An element can display "Submit" and be announced as something else entirely — a common accessibility bug that visual review never catches.
  • Name-from-content depends on role. A button takes its name from its contents; a div does not, which is why <div onclick> is invisible to a role query.
  • The algorithm recurses, so a labelled element referencing another labelled element resolves through.
  • Whitespace normalisation matters; naive string comparison produces flaky tests.

Implementing steps 1–3 for a handful of elements is enough to internalise the precedence, and it makes you notice the bug class immediately: the accessible name and the visible name have diverged.


4.6 Deep dive: synthetic events versus real input

el.dispatchEvent(new MouseEvent('click'))Real user clickCDP Input.dispatchMouseEvent
isTrustedfalsetruetrue
Hit testingskipped — you named the targetperformed (bi-09)performed
Compositor involvementnoneyes (bi-10)yes
Default actionsmany gated on trustfullfull
Focus / activationnot grantedgrants user activationgrants
Pointer capture, dragnot simulatedfullmostly

The consequences are concrete and they explain real test failures:

  • A synthetic click on an element covered by an overlay still fires, because hit testing was skipped. Your test passes; the user cannot click the button. This is the single most valuable thing to know about testing tools, and it is why Playwright/CDP-based tests catch a class of bug that jsdom-based tests structurally cannot.
  • APIs gated on user activation (fullscreen, clipboard write, autoplay with sound, popups) reject synthetic events. This is a deliberate security design, not an inconvenience.
  • Automation frameworks inject at the browser level precisely to get real hit testing and real activation.

The testing-strategy consequence: a component test with synthetic events verifies your handler logic. It does not verify the element is reachable, visible, unobstructed, or activatable. Those require real input. Knowing which question each layer answers is the whole of test-strategy design — and "we have 90% coverage" tells you nothing about which.


4.7 Deep dive: DevTools is a protocol client

Chrome DevTools is a web application that speaks the Chrome DevTools Protocol to the browser. It is not privileged internals; it is a client.

That single architectural fact explains everything else:

  • Remote debugging works — the client can be anywhere.
  • Automation uses the same protocol — Puppeteer and Playwright drive CDP; DevTools and your test runner are peers.
  • DevTools can be extended via panels that also speak the protocol.
  • Anything DevTools shows you, you can obtain programmatically. Coverage, heap snapshots, traces, layer trees, accessibility trees — all protocol domains.

The last point is the actionable one and it is underused: if DevTools can show it, you can put it in CI. Performance traces, unused-CSS coverage, accessibility trees, and layer counts are all scriptable. Most teams look at these manually once and never again; a Principal engineer wires the important ones into a pipeline.

The instrumentation constraint remains, though: your own devtools layer must not perturb what it measures. Specifically it must not hold strong references to component instances (a bi-04 leak), must not trigger re-renders by writing observable state, and must be cheap enough that the timings remain true. An observability layer that changes the system is producing fiction, and that is worth stating explicitly because it is the failure mode of most home-grown render-count trackers.

5. Principal Engineer Review

  1. Testing-library queries by accessible role. Argue this makes tests better and accessibility better. Then give the case where it produces a false sense of security.

  2. Synthetic events differ from real input. Enumerate the differences that matter, and give a bug that only real input reveals.

  3. Your observability layer changes the thing observed. Give three concrete instances from frontend tooling, and how each is mitigated.

  4. DevTools is a protocol client. What does that architecture buy, and what does it cost?

  5. You must set an org-wide policy on E2E vs integration tests. Write it in four sentences, including the criterion for choosing.

References — fw-12-testing-devtools

Primary sources first. In-tree Chromium docs beat any external source, including these modules.

No dedicated reference list yet — see CONCEPTS.md.


When a claim here proves stale, record it in PROGRESS.md section 7.

fw-12 — Analysis

Why three subjects are one module

Testing libraries, browser automation, and devtools are all instrumentation: making a running system observable, from three angles. Building small versions teaches what your tools actually do, which is what lets you diagnose them when they lie.

Required invariants

  1. A query by role must reflect what assistive technology computes, not what the DOM contains.
  2. Synthetic events are not user input and must never be presented as equivalent.
  3. An observability layer must not perturb what it measures.

Accessible name computation is an algorithm

Precedence, roughly: aria-labelledbyaria-label → native labelling (<label for>, wrapping label, alt, <caption>, <legend>) → subtree text if the role permits name-from-content → title.

Consequences that matter in real code:

  • aria-labelledby beats visible text, so an element can display "Submit" and be announced as something else. Visual review never catches this.
  • Name-from-content depends on role, which is why <div onclick> is invisible to a role query.
  • The algorithm recurses, and whitespace normalisation matters — naive comparison makes tests flaky.

The table that changes test strategy

dispatchEvent(new MouseEvent('click'))Real input / CDP
isTrustedfalsetrue
Hit testingskippedperformed
Compositornot involvedinvolved
User activationnot grantedgranted

A synthetic click on an element covered by an overlay still fires. Your test passes; the user cannot click the button. This is the single most valuable fact about testing tools, and it is why CDP-driven tests catch a class of bug that jsdom-based tests structurally cannot.

APIs gated on user activation — fullscreen, clipboard write, autoplay with sound — reject synthetic events by design.

The strategy consequence: a component test with synthetic events verifies handler logic. It does not verify the element is reachable, visible, unobstructed, or activatable. Knowing which question each layer answers is test-strategy design; "we have 90% coverage" tells you nothing about which.

DevTools is a protocol client

Not privileged internals — a web application speaking CDP. Therefore: remote debugging works; automation uses the same protocol; anything DevTools shows you, you can obtain programmatically.

That last point is underused. Traces, coverage, heap snapshots, accessibility trees, and layer counts are all scriptable. Most teams look at these manually once and never again; wiring the important ones into CI is the Principal-level move.

Observation — fw-12-testing-devtools

Predictions go above the line, before you run anything. A prediction written after observation is worth nothing; a clearly-stated wrong prediction is worth more than a vague right one.

Predictions (before)

Measurements / observations (after)

WhatPredictedActualGap explained by

Where my model was wrong

Source readings

Queries that worked (not just the paths they found):

Log these in ../../fe-00-roadmap/docs/learning-log.md section 3.

Open questions

Verification — fw-12-testing-devtools

A module is complete when these pass against measured or observed output, not when the prose has been read.

  • mini-testing-library: role, accessible name, label, visible text queries
  • Four ways to defeat the role query found — each a real accessibility bug
  • Synthetic vs real input difference demonstrated (isTrusted, hit testing, compositor)
  • mini-devtools instruments without perturbing what it measures
  • DevTools Protocol architecture understood: why it is a protocol client

Gate (§44) — for every source-reading exercise in this module

  • Why does this code exist?
  • What invariant does it maintain?
  • Who calls it?
  • What does it call?
  • Which process and thread executes it?
  • What happens if it is removed?
  • How is it tested?
  • What simpler design would fail, and why?

Record

fw-12 — Broader Ideas

Test layers answer different questions

A synthetic click verifies handler logic. It does not verify the element is reachable, visible, unobstructed, or activatable — because hit testing was skipped and isTrusted is false.

"We have 90% coverage" tells you nothing about which question your tests answer. Designing a test strategy is deciding which layer answers which question, and this module gives you the mechanism to argue it rather than assert it.

Accessibility as an engineering signal

A role query that cannot find your button is telling you something true about real users, through a channel engineers actually read. That makes testing-library-style queries a genuine accessibility signal — with the honest caveat that passing them is not the same as being usable.

The accname precedence order is worth knowing cold: aria-labelledby beats visible text, so an element can display one thing and be announced as another, and no amount of visual review catches it.

Everything DevTools shows is scriptable

DevTools is a CDP client, not privileged internals. Therefore traces, coverage, heap snapshots, accessibility trees, and layer counts can all go into CI.

Most teams look at these manually once and never again. Wiring the important ones into a pipeline is the Principal-level move — it converts a one-off investigation into a regression guard.

Observability must not perturb

A render-count tracker that retains component instances leaks; a profiler adding 40% overhead is measuring itself. This is bi-12's lesson at application scale, and it is the failure mode of most home-grown instrumentation.

Next

bi-16 uses this instrumentation to make the capstone's eight-layer explanation measurable rather than narrated.

mini-browser — the spine

This is not a project you sit down and build. It is a spine threaded through the browser strand: each milestone is built immediately before the Blink reading it motivates, so that the reading answers a question you already have.

Built as one block at the end, it teaches nothing — every "why is Blink like this?" question has already been answered by prose. That is specification §45 applied at module granularity.

MMilestoneBuilt inPaired reading
1hard-coded boxesbi-03
2HTML tokenizer + tree builderbi-03core/html/parser/
3DOMbi-04core/dom/
4CSS parserbi-07core/css/
5selector matchingbi-07SelectorChecker
6cascade, inheritance, computed stylebi-07StyleForLayoutObject
7layout treebi-08core/layout/
8block layoutbi-08block layout algorithm
9inline textbi-08core/layout/inline/
10display listbi-09core/paint/
11rasterbi-09paint → raster boundary
12eventsbi-11event dispatch
13incremental invalidationbi-11style + paint invalidation
14minimal JS integrationbi-05bindings
15profiling / optimisationbi-12tracing

At every milestone record the §47 comparison: ours vs production requirements vs Blink.

The rule that makes this work

For milestones 6, 8, 11 and 13, build the naive version first and measure it, then optimise. The cost curve you measure is the argument for the production design. Skipping the naive version means the optimisation is a fact you were told rather than a conclusion you reached.

src/foster.js from the parsing lab is the seed of M2.

Architecture Decision Records

One file per decision: NNNN-kebab-case-title.md.

Per frontend-principal-engineering.md §42, every ADR must carry all seven fields. The last two are the ones most real-world ADRs skip, and they are the ones that separate a Principal-level record from a meeting summary.

Template

# NNNN — <Title>

- **Status:** proposed | accepted | superseded by NNNN | rejected
- **Date:** YYYY-MM-DD
- **Deciders:**
- **Module / context:**

## Context
What situation forces a decision now? What changed? Include evidence — numbers,
incidents, constraints — not opinions.

## Constraints
Technical, organizational, temporal, budgetary. Distinguish hard constraints from
preferences; most "constraints" are preferences wearing a costume.

## Options
At least three, each with an honest case *for* it. If an option has no honest case,
it is a strawman and does not count.

## Decision
What was chosen, and the single most important reason.

## Consequences
Positive and negative. Required: what gets *harder* as a result. An ADR with no
negative consequences has not been thought through.

## Reversibility
One-way or two-way door? What is the cost of reversing in 3 months? In 2 years?
What is the point of no return, and how will we know we passed it?

## Migration strategy
How do we get from current state to decided state incrementally? What is the
strangler seam? What happens if we stop halfway — is the intermediate state
survivable, or is it worse than both endpoints?

Index

#TitleStatusDate
none yet

Frontend Principal Engineering Journey

Mission

Act as a seasoned Principal/Distinguished Frontend Engineer, Web Platform Architect, UI Infrastructure Engineer, browser-performance specialist, accessibility expert, frontend security engineer, and quality engineering SME.

Build a comprehensive, lab-driven, reference-quality learning journey for modern frontend and web UI engineering.

The journey must cover:

Foundation → Intermediate → Senior → Staff → Principal → Distinguished/SME

The objective is not merely to learn frameworks. The learner should become capable of acting as the frontend SME for a large engineering organization.

Core outcomes

The learner should be able to:

  • build excellent frontend applications,
  • debug difficult production problems,
  • understand browser and web-platform behavior,
  • design frontend architecture for large organizations,
  • create reusable frontend platforms,
  • establish engineering standards,
  • improve developer experience,
  • design testing and QA strategies,
  • reason about performance quantitatively,
  • make accessibility systemic,
  • understand frontend security boundaries,
  • lead migrations,
  • evaluate new frontend technologies,
  • diagnose architectural failure modes,
  • mentor senior engineers,
  • influence backend/API/product architecture,
  • make Principal-level engineering decisions.

Curriculum Philosophy

Learn the platform before abstractions

Teach underlying web concepts before framework abstractions.

Examples:

  • HTML before JSX
  • CSS layout before component libraries
  • DOM before virtual DOM
  • browser rendering before React rendering optimizations
  • HTTP before data-fetching libraries
  • URLs/history before routers
  • JavaScript runtime before framework lifecycle
  • accessibility semantics before ARIA abstractions
  • browser caching before query-library caching
  • browser security before framework-specific security patterns

For every major abstraction explain:

  1. What problem existed?
  2. What does the platform provide?
  3. What abstraction was introduced?
  4. What trade-off did it make?
  5. What complexity did it hide?
  6. When does the abstraction leak?
  7. When should we avoid it?

Required Structure for Every Topic

Every important subject must contain:

Concept

Explain the idea clearly.

Mental Model

Explain how an experienced engineer thinks about it.

Under the Hood

Explain what browsers, runtimes, frameworks, networks, or operating systems are actually doing.

Why It Matters

Connect the topic to production systems.

Example

Give a realistic implementation example.

Lab

Provide a hands-on exercise.

Failure Lab

Intentionally introduce a realistic bug, bottleneck, race condition, accessibility problem, testing failure, or architectural failure.

Debugging Exercise

Use tooling such as:

  • Chrome DevTools
  • Firefox DevTools
  • Performance panel
  • Network panel
  • Accessibility tree
  • React DevTools
  • Lighthouse
  • Playwright traces
  • logs
  • Web Vitals
  • heap snapshots
  • flame graphs

Anti-Patterns

Explain common bad implementations.

Trade-offs

Explain when competing approaches are appropriate.

Production Considerations

Explain how the topic changes at scale.

Principal Engineer Perspective

Explain what a Principal Engineer should notice that a Senior Engineer might miss.

Interview / Design Questions

Provide Staff/Principal-level questions.

Further Reading

Prefer specifications, browser documentation, standards, engineering blogs, conference talks, architecture papers, and high-quality source code.


Major Learning Areas

1. JavaScript and TypeScript Deep Foundations

Cover:

  • execution contexts
  • lexical environments
  • scope
  • closures
  • prototypes
  • this
  • event loop
  • microtasks
  • macrotasks
  • promises
  • async/await
  • generators
  • iterators
  • ESM
  • CommonJS
  • garbage collection
  • WeakRef concepts
  • memory leaks
  • object shapes
  • hidden classes
  • inline caches
  • JIT compilation
  • structured cloning
  • workers
  • SharedArrayBuffer
  • Atomics

TypeScript:

  • structural typing
  • inference
  • narrowing
  • generics
  • conditional types
  • mapped types
  • discriminated unions
  • variance
  • branded types
  • declaration files
  • type-safe APIs
  • runtime validation vs static typing
  • schema generation
  • API contracts

Anti-patterns:

  • any
  • excessive type gymnastics
  • unsafe assertions
  • leaking implementation types
  • treating TypeScript as runtime validation

2. Browser Architecture and Internals

Cover conceptually:

  • browser process architecture
  • renderer processes
  • site isolation
  • sandboxing
  • parsing HTML
  • DOM construction
  • CSSOM
  • render tree
  • style calculation
  • layout
  • paint
  • rasterization
  • compositing
  • GPU acceleration
  • layers
  • scrolling architecture
  • input handling
  • event dispatch
  • browser scheduling

Detailed Chromium/Blink source-code work belongs in browser-framework-internals.md.


3. HTML as an Application Platform

Cover:

  • semantic HTML
  • forms
  • native validation
  • inputs
  • dialog
  • popover
  • details/summary
  • tables
  • media
  • iframe
  • metadata
  • responsive images
  • picture
  • srcset
  • progressive enhancement
  • custom elements
  • Web Components
  • Shadow DOM

Include labs replacing over-engineered components with browser-native primitives.


4. CSS Architecture and Rendering

Cover:

  • cascade
  • inheritance
  • specificity
  • cascade layers
  • custom properties
  • logical properties
  • flexbox
  • grid
  • subgrid
  • container queries
  • media queries
  • intrinsic sizing
  • min/max-content
  • stacking contexts
  • positioning
  • transforms
  • animations
  • transitions
  • containment
  • content-visibility
  • typography
  • design tokens
  • theming
  • dark mode

Compare:

  • global CSS
  • CSS Modules
  • CSS-in-JS
  • atomic CSS
  • utility-first CSS
  • Tailwind-style approaches
  • design-system CSS
  • typed CSS extraction approaches

5. React Deep Dive

Cover:

  • component model
  • reconciliation
  • rendering
  • Fiber
  • render phase
  • commit phase
  • hooks
  • state
  • derived state
  • effects
  • stale closures
  • refs
  • memoization
  • context
  • state ownership
  • controlled/uncontrolled state
  • concurrent rendering
  • Suspense
  • transitions
  • streaming
  • hydration
  • server/client components
  • forms
  • compiler-assisted optimization concepts where applicable

Anti-patterns:

  • excessive useEffect
  • global context abuse
  • premature useMemo
  • excessive memoization
  • giant components
  • business logic inside views
  • duplicated derived state
  • state for everything
  • request waterfalls
  • CSR when SSR is more appropriate
  • framework abstractions where browser primitives suffice

6. Other Frontend Frameworks

Architecturally compare:

  • Vue
  • Angular
  • Svelte
  • Solid
  • Web Components
  • lightweight/no-framework architectures

Focus on:

  • reactivity models
  • change detection
  • compilation
  • runtime cost
  • server rendering
  • ecosystem
  • organizational trade-offs

7. Application Architecture

Cover:

  • component architecture
  • feature-based architecture
  • vertical slices
  • domain boundaries
  • dependency direction
  • modular frontend
  • clean architecture
  • ports/adapters
  • state machines
  • event-driven UI
  • DDD in frontend
  • bounded contexts
  • dependency injection
  • inversion of control

Discuss when these become overengineering.


8. State Management

Distinguish:

  • local state
  • lifted state
  • URL state
  • server state
  • global application state
  • derived state
  • cached state
  • persistent state

Compare:

  • plain framework state
  • Redux-style stores
  • Zustand-style stores
  • signals
  • state machines
  • observable systems
  • server-state/query caches

Critical distinction:

server state ≠ client application state

Cover:

  • race conditions
  • synchronization bugs
  • stale state
  • optimistic updates
  • rollback
  • conflict resolution
  • offline behavior

9. API and Backend Integration

Cover frontend behavior as a distributed-systems problem:

  • REST
  • GraphQL
  • RPC
  • WebSockets
  • SSE
  • streaming APIs
  • polling
  • pagination
  • cursors
  • filtering
  • sorting
  • retries
  • cancellation
  • request deduplication
  • idempotency
  • timeout strategies
  • caching
  • backpressure
  • optimistic updates
  • partial failures

Use intentionally unreliable APIs in labs.


10. Networking

Cover:

  • DNS
  • TCP
  • TLS
  • HTTP/1.1
  • HTTP/2
  • HTTP/3
  • QUIC
  • connection reuse
  • multiplexing
  • cache headers
  • ETags
  • Cache-Control
  • CDN architecture
  • compression
  • Brotli
  • priorities
  • preconnect
  • preload
  • prefetch
  • Early Hints

11. Frontend Performance Engineering

Make this a deep track.

Cover:

  • Core Web Vitals
  • LCP
  • INP
  • CLS
  • TTFB
  • FCP
  • long tasks
  • main-thread contention
  • JS execution cost
  • bundle size
  • parsing
  • compilation
  • hydration cost
  • rendering cost
  • image performance
  • font performance
  • caching
  • CDN behavior

Teach:

  • code splitting
  • lazy loading
  • tree shaking
  • dynamic imports
  • preload
  • prefetch
  • streaming SSR
  • partial hydration
  • island architectures
  • server components

Use numerical performance budgets.

Require quantitative claims.


12. Accessibility

Treat accessibility as an engineering discipline.

Cover:

  • WCAG
  • semantic HTML
  • keyboard navigation
  • focus management
  • focus traps
  • screen readers
  • accessibility tree
  • accessible names
  • ARIA
  • landmarks
  • forms
  • error messages
  • contrast
  • motion
  • zoom
  • localization considerations

Use:

  • keyboard-only testing
  • screen readers
  • browser accessibility inspector
  • axe-style automated checking

Explain the limits of automation.


13. Frontend Security

Cover:

  • XSS
  • reflected/stored/DOM XSS
  • CSRF
  • CSP
  • CORS
  • SameSite cookies
  • secure cookies
  • authentication
  • authorization
  • token storage
  • session management
  • iframe security
  • clickjacking
  • dependency attacks
  • prototype pollution
  • supply-chain security
  • DOM sanitization
  • Trusted Types
  • browser security boundaries

Use safe local exploit-and-fix labs.


14. Testing Strategy

Treat quality as an architecture problem.

Cover:

  • unit tests
  • component tests
  • integration tests
  • contract tests
  • E2E tests
  • visual regression
  • accessibility tests
  • performance tests
  • browser compatibility tests
  • synthetic monitoring
  • production monitoring

For every test type explain:

  • what it catches
  • what it misses
  • execution cost
  • maintenance cost
  • diagnosis difficulty
  • ideal usage

15. UI Testing

Cover:

  • user-centric testing
  • DOM testing
  • component testing
  • state interactions
  • async interactions
  • forms
  • keyboard behavior
  • accessibility assertions
  • browser-native behavior

Anti-patterns:

  • testing implementation details
  • snapshot-test abuse
  • asserting private state
  • excessive mocking
  • brittle selectors

16. End-to-End Testing

Use Playwright-style browser automation.

Cover:

  • browser contexts
  • isolation
  • fixtures
  • test data
  • authentication
  • API interception
  • multi-tab workflows
  • downloads/uploads
  • permissions
  • responsive testing
  • mobile emulation
  • cross-browser testing

Debug with:

  • traces
  • screenshots
  • video
  • network logs
  • console logs
  • DOM snapshots

Teach flaky-test prevention.


17. Integration Testing

Use realistic flows:

frontend → API → authentication → backend → database

Cover:

  • contract testing
  • schema validation
  • API stubs
  • service virtualization
  • test containers
  • ephemeral environments
  • preview deployments

18. Visual Regression Testing

Cover:

  • screenshot comparison
  • component visual tests
  • full-page visual tests
  • responsive visual testing
  • rendering differences
  • tolerance thresholds
  • false positives
  • browser differences

19. Edge-Case Engineering

Create a major module:

Designing for the Unhappy Path

Include:

  • slow network
  • offline
  • partial response
  • timeout
  • retry
  • duplicate request
  • double-click
  • stale response
  • races
  • empty results
  • huge input/data
  • Unicode
  • RTL
  • localization
  • missing/broken images
  • partial permissions
  • expired authentication
  • session timeout
  • browser back/forward
  • refresh
  • multiple tabs
  • stale cache
  • mobile
  • keyboard-only
  • touch
  • zoom
  • reduced motion
  • high latency
  • server errors
  • malformed responses

Create labs where happy-path tests pass but production-like edge cases fail.


20. AI-Assisted Frontend Engineering

Teach effective use of tools in the category of:

  • Claude Code
  • coding agents
  • IDE copilots
  • repository agents
  • test-generation agents
  • browser automation agents
  • LLM-based code review
  • MCP-enabled engineering tools

Teach reusable workflows, not product-specific tricks.


21. AI-Assisted Test Generation

Have AI inspect:

  • components
  • routes
  • API clients
  • schemas
  • design-system components
  • user stories
  • bug history

and generate structured test matrices.

Example:

DimensionCases
Authenticationlogged in / logged out / expired
Networknormal / slow / offline
APIsuccess / 4xx / 5xx / malformed
Viewportmobile / tablet / desktop
Inputmouse / keyboard / touch
Accessibilityscreen reader / keyboard
Dataempty / normal / huge
LocaleEnglish / RTL / long strings

22. AI for Edge-Case Discovery

Workflow:

Agent reads feature code
↓
Agent reads API/schema
↓
Agent identifies assumptions
↓
Agent generates failure states
↓
Agent maps failures to tests
↓
Browser automation executes
↓
Trace/screenshots/network captured
↓
Agent analyzes artifacts
↓
Developer verifies
↓
Regression tests committed

Teach adversarial QA prompting.


23. AI-Based Exploratory Testing

Given:

  • application URL
  • user story
  • expected behavior
  • test accounts

have an agent systematically explore:

  • navigation
  • forms
  • state transitions
  • invalid inputs
  • error recovery
  • browser history
  • responsive layouts

Produce:

  • discovered states
  • failures
  • reproduction
  • screenshots
  • severity
  • proposed regression test

24. AI-Assisted Code Review

Use AI to help detect:

  • accessibility problems
  • state bugs
  • race conditions
  • rendering inefficiencies
  • unsafe DOM usage
  • unnecessary re-renders
  • missing cancellation
  • error-state gaps
  • security issues
  • untested behavior

Never treat AI review as authoritative.


25. Quality Engineering at Principal Level

Design:

  • organization-wide testing policy
  • definition of done
  • test ownership
  • CI quality gates
  • browser-support matrix
  • release criteria
  • flaky-test budgets
  • accessibility gates
  • performance budgets
  • visual regression strategy
  • synthetic monitoring
  • production-quality metrics

Answer:

Why can thousands of tests still produce low confidence?


26. Design Systems

Cover:

  • design tokens
  • primitives
  • components
  • patterns
  • theming
  • accessibility
  • versioning
  • adoption
  • governance
  • documentation
  • visual tests
  • contribution models

Distinguish design system from component library.


27. Frontend Platform Engineering

Design platforms supporting dozens or hundreds of engineers.

Cover:

  • templates
  • shared infrastructure
  • dependency management
  • build systems
  • monorepos
  • package boundaries
  • linting/formatting
  • code generation
  • design systems
  • observability
  • deployment infrastructure
  • feature flags
  • CI/CD
  • preview environments
  • engineering standards

28. Build Tooling

Cover:

  • bundlers
  • transpilers
  • compilers
  • module graphs
  • tree shaking
  • dead-code elimination
  • minification
  • source maps
  • incremental builds
  • caching
  • HMR

Compare Vite/Webpack/Rollup/esbuild/SWC/Turbopack-style architectures conceptually.


29. Monorepos

Cover:

  • workspaces
  • package boundaries
  • dependency graphs
  • incremental builds
  • remote caching
  • affected builds
  • ownership
  • sharing
  • versioning

Discuss Nx/Turborepo/Bazel-style approaches.


30. Microfrontends

Teach critically:

  • runtime composition
  • build-time composition
  • Module Federation
  • route decomposition
  • iframe isolation
  • shared dependencies
  • design-system coordination
  • independent deployment

Ask:

Is this solving an organizational problem or a technical problem?


31. SSR, SSG, and Modern Rendering

Cover:

  • CSR
  • SSR
  • SSG
  • ISR
  • streaming SSR
  • partial hydration
  • islands
  • server components

Compare:

  • performance
  • caching
  • personalization
  • infrastructure
  • developer complexity
  • failure modes
  • SEO
  • operational cost

32. Frontend Observability

Cover:

  • logging
  • error tracking
  • session replay
  • browser metrics
  • Web Vitals
  • RUM
  • synthetic monitoring
  • distributed tracing
  • correlation IDs
  • frontend/backend traces

Diagnose claims such as:

Users say the application feels slow.

with evidence.


33. Reliability Engineering for Frontend

Borrow from SRE:

  • SLIs
  • SLOs
  • error budgets
  • availability
  • latency
  • graceful degradation
  • partial failure
  • feature flags
  • kill switches
  • fallbacks
  • circuit breakers
  • rate limits

34. Frontend CI/CD

Typical pipeline:

commit
→ static analysis
→ type checking
→ unit tests
→ component tests
→ build
→ security scanning
→ E2E
→ visual regression
→ accessibility checks
→ preview deployment
→ production deployment
→ monitoring

Teach risk-based testing and pipeline optimization.


35. Release Engineering

Cover:

  • feature flags
  • canaries
  • percentage rollouts
  • A/B tests
  • rollback
  • backward-compatible APIs
  • frontend/backend coordination
  • schema evolution

36. Internationalization

Cover:

  • i18n
  • l10n
  • Unicode
  • formatting
  • currencies
  • dates
  • pluralization
  • RTL
  • text expansion
  • timezones

Include Arabic RTL, CJK, and long-string labs.


37. Mobile Web

Cover:

  • responsive design
  • touch
  • mobile CPU
  • memory
  • network constraints
  • viewport behavior
  • virtual keyboards
  • PWA
  • service workers
  • offline-first
  • installability

38. Workers and Parallelism

Cover:

  • Web Workers
  • Shared Workers
  • Service Workers
  • worklets
  • message passing
  • transferable objects
  • structured clone
  • SharedArrayBuffer
  • Atomics

39. WebAssembly

Cover enough WASM for architectural judgment:

  • execution model
  • JS interoperability
  • memory
  • startup/performance trade-offs
  • appropriate use cases

40. Frontend Data Structures and Algorithms

Teach algorithms in UI context:

  • tree traversal
  • DOM trees
  • virtualized lists
  • interval trees
  • tries/autocomplete
  • LRU caching
  • diff algorithms
  • dependency graphs
  • scheduling
  • debounce/throttle
  • indexing

41. Large-Scale UI Performance

Labs:

  • 100,000-row tables
  • virtualized lists
  • large trees
  • rich-text editors
  • dashboards
  • real-time feeds
  • drag-and-drop
  • SVG-heavy UIs
  • Canvas

Require profiling and quantitative optimization.


42. Architecture Decision Records

Frontend ADR topics:

  • React vs Web Components
  • SPA vs SSR
  • monorepo vs polyrepo
  • GraphQL vs REST
  • design-system architecture
  • state-management strategy
  • CSS strategy
  • testing strategy

Each ADR:

  • Context
  • Constraints
  • Options
  • Decision
  • Consequences
  • Reversibility
  • Migration strategy

43. Technical Decision Making

Teach:

  • one-way vs two-way doors
  • cost of change
  • blast radius
  • optionality
  • reversibility
  • organizational constraints
  • build vs buy
  • standardization vs autonomy

44. Technical Debt

Distinguish:

  • deliberate debt
  • accidental debt
  • architecture debt
  • dependency debt
  • testing debt
  • accessibility debt
  • performance debt

Teach how to quantify and prioritize debt.


45. Frontend Migrations

Projects:

  • JavaScript → TypeScript
  • legacy React → modern architecture
  • CSS-in-JS → alternative styling
  • REST → GraphQL
  • SPA → SSR
  • design-system migration
  • build-system migration
  • monolith → modular frontend

Teach incremental/strangler migrations.


46. Principal Engineer Skills

Cover:

  • technical strategy
  • architectural influence
  • standards
  • cross-team alignment
  • RFCs
  • ADRs
  • mentoring
  • incident analysis
  • technical roadmaps
  • technology evaluation
  • platform ownership
  • organizational leverage

Clarify:

  • Senior
  • Staff
  • Principal
  • Distinguished

Failure Case Studies

Include realistic incidents such as:

  • rendering loop freezes browser
  • memory leak after hours
  • stale response overwrites newer data
  • accessibility regression
  • CSS bundle explosion
  • hydration mismatch
  • cache poisoning
  • flaky E2E suite
  • design-system upgrade breaks many apps
  • dependency compromise
  • poor observability hides an outage
  • global rerenders from context misuse

For each ask:

  1. What do you investigate first?
  2. What evidence do you need?
  3. What hypotheses exist?
  4. What experiments distinguish them?
  5. What mitigation is appropriate?
  6. What permanent fix is appropriate?
  7. What systemic change prevents recurrence?

Capstones

Foundation

Production-quality accessible responsive application.

Intermediate

Complex application with API integration, state management, and full testing.

Senior

High-performance application with SSR, caching, and observability.

Staff

Shared design system and frontend platform used by multiple applications.

Principal

Design frontend architecture used by dozens of teams.

Include:

  • RFC
  • architecture diagrams
  • performance budgets
  • testing strategy
  • observability
  • security model
  • accessibility policy
  • migration strategy
  • CI/CD
  • developer experience
  • operational model

Distinguished / SME

Design a multi-year frontend platform strategy for a large organization.

Include:

  • standardization strategy
  • technology radar
  • migration roadmap
  • platform APIs
  • governance model
  • organizational topology
  • adoption strategy
  • build-vs-buy decisions
  • measurable engineering outcomes

Principal Engineer Heuristics

Maintain and challenge heuristics such as:

  • Prefer platform primitives when the browser already solves the problem.
  • Move state to the lowest layer that actually owns it.
  • Optimize critical user journeys, not vanity benchmarks.
  • Every abstraction creates a future migration.
  • Test observable behavior, not implementation details.
  • E2E tests protect critical user journeys rather than duplicating unit tests.
  • Make invalid states difficult to represent.
  • Accessibility is architecture, not polish.
  • Performance problems are often scheduling problems.
  • Frontend architecture frequently reflects organizational architecture.

For every heuristic include counterexamples and limitations.


AI Usage Rules

AI should increase engineering leverage, not replace understanding.

Whenever AI generates code:

  1. Explain the architecture.
  2. Identify assumptions.
  3. Identify failure states.
  4. Generate tests.
  5. Run static analysis.
  6. Run browser tests.
  7. Inspect accessibility.
  8. Measure performance where relevant.
  9. Review security implications.
  10. Require human approval.

Use AI as:

  • implementation assistant
  • code reviewer
  • test designer
  • edge-case generator
  • repository explorer
  • architecture critic
  • debugging assistant
  • documentation assistant

Never treat an LLM response as authoritative without verification.


Required Learning Output

Produce:

  1. full roadmap
  2. dependency graph
  3. recommended order
  4. Beginner → Distinguished progression
  5. modules
  6. labs
  7. failure labs
  8. debugging labs
  9. architecture exercises
  10. code-reading exercises
  11. production incident exercises
  12. testing exercises
  13. AI-assisted engineering exercises
  14. capstones
  15. reference library
  16. interview questions
  17. Principal-engineer review questions
  18. technology radar
  19. anti-pattern encyclopedia
  20. engineering heuristics

Start by producing the complete map and dependency graph.

Then divide the journey into phases.

For each phase specify:

  • concepts
  • expected depth
  • labs
  • failure labs
  • code-reading assignments
  • production case studies
  • testing exercises
  • AI-assisted exercises
  • architecture exercises
  • references
  • expected deliverables
  • mastery criteria

At the beginning of each module state:

Why a Principal Engineer needs to understand this.

At the end provide:

Principal Engineer Review

with 5–10 architectural-judgment questions.

Continuously connect lower-level topics to larger architectural decisions.

As the program advances, make it progressively less tutorial-like and increasingly resemble actual Principal Engineer work.

Browser & Framework Internals

Mission

Create a systems-construction track for becoming deeply competent in:

  • Chromium source code
  • Blink
  • V8 integration
  • rendering-engine internals
  • browser architecture
  • browser debugging
  • browser contribution
  • frontend framework internals
  • state-management internals
  • compilers/bundlers
  • browser automation
  • source-code archaeology

The objective is to remove abstraction boundaries.

The learner should eventually understand:

Application
↓
Framework
↓
DOM / Web APIs
↓
Blink
↓
Style
↓
Layout
↓
Paint
↓
Compositor
↓
GPU
↓
Pixels

and be able to move both directions through the stack.

Always verify current Chromium directory names, contribution requirements, build commands, build executors, testing procedures, and framework source organization against upstream documentation before relying on them.


1. Browser Engine Engineering: From Web Page to Pixels

The learner must eventually be able to:

  • clone and build Chromium locally,
  • navigate the Chromium source tree,
  • debug Chromium and Blink,
  • trace browser behavior from Web API to implementation,
  • understand renderer/browser/GPU/network boundaries,
  • read and modify Blink code,
  • write browser-engine tests,
  • investigate Chromium bugs,
  • prepare Chromium patches,
  • work with Chromium reviewers/OWNERS,
  • and make meaningful upstream contributions.

Treat source-code competence as a first-class engineering skill.


2. Chromium Architecture Map

Build a mental model before reading files.

Conceptual architecture:

Chromium
│
├── Browser Process
├── Renderer Processes
│   └── Blink
│       ├── HTML
│       ├── DOM
│       ├── CSS
│       ├── Style
│       ├── Layout
│       ├── Paint
│       └── Web APIs
│
├── V8
│   └── JavaScript / WebAssembly
│
├── Compositor
├── GPU Process
├── Network Service
├── Storage
├── Accessibility
├── DevTools
└── Platform / UI

Map these concepts to the current Chromium tree.

Likely important areas include:

//content
//third_party/blink
//v8
//cc
//gpu
//net
//services
//components
//ui
//base

For each:

  • responsibility
  • process
  • callers
  • callees
  • security boundary
  • thread/task runner
  • tests
  • ownership
  • dependency-direction rules

3. Build Chromium From Source

Hands-on lab:

  1. configure local Chromium environment,
  2. obtain source,
  3. understand depot_tools,
  4. understand gclient,
  5. understand dependencies,
  6. understand GN,
  7. understand the current platform build executor,
  8. configure build args,
  9. build Chromium,
  10. launch local browser,
  11. build smaller targets,
  12. modify source,
  13. rebuild incrementally,
  14. attach debugger,
  15. run tests.

Explain:

  • debug vs optimized builds
  • component builds
  • assertions
  • symbols
  • incremental compilation
  • target graphs
  • generated sources
  • build configurations
  • why Chromium builds are large
  • iteration-time reduction

Never freeze commands permanently; consult current upstream docs.


4. Chromium C++ for Frontend Engineers

Teach only the modern C++ needed to read and contribute to Chromium:

  • RAII
  • ownership
  • smart pointers
  • references
  • move semantics
  • templates
  • callbacks
  • lambdas
  • enums
  • optionals
  • spans
  • Chromium containers/utilities
  • task runners
  • sequences
  • threading
  • weak pointers
  • reference counting
  • IPC interfaces
  • generated code
  • Blink GC types

For every concept, locate real Chromium examples.


5. Complete Rendering Pipeline

Teach:

Network Bytes
↓
Character Decoding
↓
HTML Tokenization
↓
HTML Tree Construction
↓
DOM
↓
CSS Parsing
↓
Style Data
↓
Selector Matching
↓
Cascade
↓
Computed Style
↓
Layout Tree
↓
Layout
↓
Fragments
↓
Pre-Paint
↓
Paint
↓
Display Items
↓
Paint Chunks
↓
Property Trees
↓
Compositing
↓
Rasterization
↓
GPU
↓
Pixels

For every transition answer:

  • subsystem
  • major input structure
  • major output structure
  • source classes/files
  • invalidation rules
  • caching
  • incremental behavior
  • thread/process
  • performance pathologies
  • DevTools visibility

6. HTML Parser Internals

Cover:

  • input streams
  • character decoding
  • tokenizer
  • tokens
  • tree builder
  • insertion modes
  • malformed-markup recovery
  • parser-blocking scripts
  • speculative/preload parsing
  • document.write
  • script execution
  • DOM node creation
  • custom elements
  • scheduling

Source-reading lab:

<html>
  <body>
    <table>
      hello
      <div>world</div>
    </table>
  </body>
</html>

Predict the DOM.

Then:

  1. verify in browser,
  2. identify spec rules,
  3. trace Blink implementation,
  4. locate tests.

7. Build a Mini HTML Parser

Implement:

Stage 1

Tokenizer.

Stage 2

DOM-like tree.

Stage 3

Basic error recovery.

Stage 4

Conceptual script interruption.

Stage 5

Compare with Blink.

The goal is architectural understanding, not standards completeness.


8. DOM Internals

Study:

  • Node
  • Element
  • Document
  • Text
  • attributes
  • tree mutation
  • shadow trees
  • custom elements
  • mutation observers
  • lifecycle
  • event targets
  • ownership
  • GC
  • bindings

Trace:

document.createElement("div")
element.setAttribute("class", "foo")
parent.appendChild(element)
element.remove()

through:

JavaScript
↓
Binding
↓
Blink
↓
DOM Mutation
↓
Invalidation
↓
Style/Layout/Paint consequences

9. JavaScript ↔ Browser Binding Layer

Trace APIs such as:

document.querySelector(...)

Cover:

  • Web IDL
  • generated bindings
  • V8
  • JS wrappers
  • native DOM objects
  • exceptions
  • promises
  • callbacks
  • GC interaction
  • execution contexts
  • realms
  • security boundaries

10. CSS Engine Internals

Study:

  • tokenization
  • parsing
  • selectors
  • selector matching
  • specificity
  • cascade
  • inheritance
  • custom properties
  • computed values
  • style sharing
  • invalidation
  • pseudo-elements
  • pseudo-classes
  • media queries
  • container queries

Investigate:

  • what happens after classList.add
  • how invalidation scope is determined
  • why every DOM change does not trigger global recomputation
  • selector-complexity implications

11. Build a Mini CSS Engine

Support:

div {}
.foo {}
#header {}
.parent .child {}

Implement:

  1. parser
  2. selector representation
  3. selector matching
  4. specificity
  5. cascade
  6. inheritance
  7. computed style
  8. style tree

Then intentionally use global recomputation and optimize toward invalidation.


12. Layout Engine Internals

Cover:

  • containing blocks
  • box model
  • intrinsic sizing
  • block layout
  • inline layout
  • line breaking
  • flexbox
  • grid
  • fragmentation
  • percentage resolution
  • min/max sizing
  • replaced elements
  • writing modes
  • RTL
  • scroll containers

Mental model:

DOM Tree
↓
Style
↓
Layout Objects
↓
Fragments
↓
Geometry

13. Build a Mini Layout Engine

Stages:

  1. block flow
  2. margins/padding
  3. nested boxes
  4. inline text
  5. basic flex
  6. intrinsic sizing
  7. dirty-layout tracking

Input:

DOM + Computed Style

Output:

Layout Tree + Geometry

Compare every stage to Blink.


14. Paint Internals

Study:

  • paint invalidation
  • display items
  • paint chunks
  • property trees
  • transforms
  • clipping
  • effects
  • stacking contexts
  • paint order
  • hit testing

Trace CSS properties through the paint/compositor pipeline.


15. Compositor and GPU Pipeline

Cover:

  • compositor architecture
  • compositor threads
  • layers
  • property trees
  • raster
  • tiles
  • GPU process
  • surfaces
  • scrolling
  • animations
  • frame production
  • vsync
  • dropped frames

Relate to:

60 Hz ≈ 16.7 ms/frame
120 Hz ≈ 8.3 ms/frame

Explain why the app does not own the full budget.


16. Browser Scheduling

Study:

  • event loop
  • tasks
  • microtasks
  • rendering opportunities
  • animation frames
  • idle work
  • input
  • timers
  • network callbacks
  • compositor interaction

Trace:

setTimeout(...)
Promise.resolve().then(...)
requestAnimationFrame(...)
queueMicrotask(...)

17. V8 Integration

Cover enough V8 to understand browser execution:

  • JS parsing
  • AST
  • bytecode
  • interpreter
  • optimizing compiler
  • shapes/hidden classes
  • inline caches
  • GC
  • deoptimization
  • WebAssembly
  • isolates
  • contexts

Clearly distinguish:

  • V8
  • Blink
  • Chromium/content

18. Multiprocess Browser Architecture

Study:

Browser Process
│
├── Renderer A
├── Renderer B
├── GPU
├── Network Service
└── Utility Processes

Cover:

  • process isolation
  • frames
  • renderer processes
  • site isolation
  • sandboxing
  • IPC
  • Mojo
  • privileges
  • compromised-renderer model
  • browser-process trust boundary

Design exercise:

A Web API requires privileged OS access. Decide what belongs in Blink, renderer, Mojo interface, and browser process.


19. Browser Security Architecture

Connect frontend security to browser enforcement:

  • same-origin policy
  • CORS
  • CSP
  • iframe isolation
  • sandbox
  • cookies
  • permissions
  • navigation
  • site isolation
  • Trusted Types

For each:

  1. web abstraction
  2. enforcement boundary
  3. compromised-renderer implications

20. Chromium Debugging

Teach:

  • native debugger
  • breakpoints
  • conditional breakpoints
  • stack inspection
  • logs
  • DCHECK/assertions
  • tracing
  • crash dumps
  • browser-process debugging
  • renderer-process debugging
  • GPU debugging
  • Blink debugging

Lab:

Set breakpoints around DOM creation, style recalculation, layout, and paint.


21. Chromium Tracing

Teach:

  • trace events
  • task execution
  • main-thread activity
  • compositor activity
  • frame production
  • renderer/browser interaction

Progress:

Chrome DevTools Performance
↓
Chromium-level tracing
↓
source-code correlation

22. Chromium Test Architecture

Learn:

  • unit tests
  • browser tests
  • Blink web tests
  • Web Platform Tests
  • regression tests
  • pixel/reference tests where appropriate
  • integration tests

Bug-fix workflow:

Reproduce
↓
identify correct layer
↓
minimal failing test
↓
fix
↓
verify
↓
run surrounding tests

23. Web Platform Tests

Understand:

  • interoperability
  • browser-neutral behavior
  • specification conformance
  • cross-browser testing
  • harnesses
  • reference tests

Exercise:

  1. pick a behavior,
  2. read spec,
  3. locate WPT,
  4. run test,
  5. locate Blink implementation,
  6. deliberately break behavior,
  7. observe failure,
  8. restore/fix.

24. Chromium Contribution Workflow

Teach current upstream process, including concepts such as:

  • contributor requirements
  • CLA
  • AUTHORS
  • issue tracking
  • Gerrit
  • CLs
  • OWNERS
  • reviewers
  • presubmit
  • formatting
  • try jobs
  • commit queue
  • patchsets
  • review feedback

Always verify against current official Chromium docs.

Contribution ladder:

Contribution 0

Build Chromium.

Contribution 1

Docs/test-only improvement.

Contribution 2

Small isolated correctness fix.

Contribution 3

Blink behavior bug + regression test.

Contribution 4

Small rendering/style/layout improvement.

Contribution 5

Cross-component architectural change.

Maintain for each:

  • bug
  • reproduction
  • spec
  • suspected subsystem
  • source path
  • call path
  • test
  • proposed fix
  • reviewer feedback
  • architectural lesson

25. Chromium Source Archaeology

Missions:

  • document.createElement
  • classList.add
  • getBoundingClientRect
  • requestAnimationFrame
  • Fetch
  • CSS Grid
  • click/input handling
  • accessibility representation

For each map:

Public Web API
→ Binding
→ Blink
→ Chromium subsystem
→ Process/thread
→ downstream effect

26. Build a Browser Engine From Scratch

Project:

mini-browser/

Pipeline:

HTTP
↓
HTML Parser
↓
DOM
↓
CSS Parser
↓
Style
↓
Layout
↓
Display List
↓
Paint
↓
Pixels

Milestones:

  1. hard-coded boxes
  2. HTML parser
  3. DOM
  4. CSS parser
  5. selector matching
  6. cascade
  7. layout tree
  8. block layout
  9. text
  10. display list
  11. raster
  12. events
  13. incremental invalidation
  14. minimal JS integration
  15. profiling/optimization

At every stage compare:

Our implementation
vs.
Production browser requirements
vs.
Blink architecture

27. Build a React-Like Runtime

Project:

mini-react/

Stages:

  1. element representation
  2. DOM renderer
  3. reconciliation
  4. keyed children
  5. function components
  6. state
  7. effects
  8. batching
  9. scheduling
  10. interruptible work
  11. priorities
  12. context
  13. error boundaries
  14. SSR
  15. hydration
  16. concurrency experiments

Derive why Fiber-like data structures become useful rather than copying them mechanically.


28. React Source-Code Apprenticeship

After building the mini runtime, study current React source.

Investigate:

  • element creation
  • reconciliation
  • Fiber
  • work loop
  • scheduler
  • hooks
  • DOM renderer
  • events
  • commit
  • hydration
  • server rendering

For every reading:

  1. what problem is solved?
  2. what invariant is maintained?
  3. what complexity did our implementation avoid?
  4. what failure occurs without this?
  5. essential architecture or production complexity?

29. Build a Vue-Like Reactive Runtime

Project:

mini-vue/

Implement:

reactive()
effect()
ref()
computed()
watch()

Conceptual dependency graph:

Reactive Object
↓
Property
↓
Dependencies
↓
Effects

Add:

  • Proxy interception
  • dependency tracking
  • triggering
  • cleanup
  • nested effects
  • computed caching
  • watchers
  • scheduling
  • batching

Create failure labs:

  • dependency leaks
  • infinite loops
  • stale dependencies

30. Build a Vue-Like Renderer

Architecture:

VNode
↓
Renderer
↓
Patch
↓
DOM

Implement:

  • elements
  • attributes
  • events
  • components
  • keyed children
  • lifecycle
  • reactive updates

Compare React-style rerendering with dependency-tracking architectures.

Ask:

What work can each architecture avoid, and how does it know?


31. Build a Template Compiler

Pipeline:

Template
↓
Tokenizer
↓
Parser
↓
AST
↓
Transform
↓
Code Generation
↓
Render Function

Implement:

  • interpolation
  • attributes
  • events
  • conditionals
  • loops

Study:

  • static analysis
  • static hoisting
  • dynamic-node detection
  • compile-time hints

32. Vue Source-Code Apprenticeship

Study current Vue areas corresponding to:

  • reactivity
  • runtime core
  • DOM runtime
  • compiler core
  • DOM compiler

Compare against the mini implementation.


33. Build a Redux-Like Store

Project:

mini-redux/

Core API:

const store = createStore(reducer)

store.getState()
store.dispatch(action)
store.subscribe(listener)

Implement:

  • reducers
  • reducer composition
  • middleware
  • enhancers
  • selectors
  • action/state recording
  • time travel
  • persistence

Middleware examples:

  • logger
  • timing
  • error handling
  • async

Then compare against Redux source.


34. Build a Client-Side Router

Project:

mini-router/

Implement:

  • URL parsing
  • History API
  • navigation
  • route matching
  • parameters
  • nested routes
  • redirects
  • loaders
  • error routes
  • back/forward
  • scroll restoration

Study:

  • aborted navigation
  • concurrent navigation
  • stale loaders
  • authentication
  • unsaved changes
  • hashes

35. Build a Server-State Query Cache

Project:

mini-query/

Architecture:

Query Key
↓
Cache
↓
Fetch
↓
Subscribers

Add:

  • deduplication
  • stale/fresh state
  • retries
  • cancellation
  • invalidation
  • background refetch
  • optimistic updates
  • rollback
  • GC
  • pagination
  • dependent queries

Create race-condition labs.


36. Build a Signals Library

Project:

mini-signals/

Implement:

  • signal
  • computed
  • effect
  • dependency graph
  • batching
  • cleanup

Compare:

React-style rerendering
Vue dependency tracking
Signals
Redux explicit updates

Focus on computational models, not syntax.


37. Build a Virtualized List Engine

Project:

mini-virtual-list/

Render 100,000 logical rows with a small DOM window.

Implement:

  • viewport calculation
  • overscan
  • fixed heights
  • dynamic heights
  • measurement
  • scrolling
  • anchor preservation

Investigate:

  • layout cost
  • DOM size
  • GC
  • scroll jank

38. Build a Minimal Bundler

Project:

mini-bundler/

Pipeline:

Entry
↓
Parse imports
↓
Dependency graph
↓
Transform
↓
Bundle

Add:

  • code splitting
  • dynamic imports
  • tree-shaking concepts
  • source maps
  • caching
  • incremental rebuild
  • HMR concepts

39. Build a JSX / Template Compiler

Implement:

Source
↓
Lexer
↓
Parser
↓
AST
↓
Transform
↓
Code Generation

Use this to understand:

  • JSX
  • template compilation
  • static analysis
  • source transforms
  • compiler errors
  • source locations

40. Build a Testing Library

Project:

mini-testing-library/

Build user-centric queries corresponding to:

  • role
  • accessible name
  • label
  • visible text

Connect:

DOM
↓
Accessibility Tree
↓
Testing
↓
User Semantics

41. Build a Browser Automation Layer

Build a small layer over browser automation.

Understand:

  • navigation
  • selectors
  • DOM queries
  • events
  • screenshots
  • network interception
  • console events
  • tracing
  • frames
  • browser contexts

Then connect it to Playwright-style E2E testing.


42. Build a Mini DevTools

Project:

mini-devtools/

Instrument an app to display:

  • component tree
  • render count
  • state changes
  • network requests
  • performance events

Later study Chrome DevTools architecture and Chrome DevTools Protocol.

Optional: custom DevTools panel.


43. Cross-Layer Trace Labs

Trace:

User clicks button
↓
Operating-system input
↓
Browser input handling
↓
DOM event
↓
Framework handler
↓
State update
↓
Framework scheduler
↓
Reconciliation / reactive effect
↓
DOM mutation
↓
Style invalidation
↓
Layout
↓
Paint
↓
Compositor
↓
GPU
↓
Frame displayed

Repeat for:

  • React update
  • Vue update
  • Redux dispatch
  • CSS class change
  • DOM insertion
  • scroll
  • animation
  • network-driven UI update

44. Source-Code Reading Ladder

Level 1

Small libraries, such as Redux-sized systems.

Goal: understand an entire production library.

Level 2

Focused framework subsystem, such as Vue reactivity.

Goal: understand one subsystem completely.

Level 3

Framework runtime.

Goal: follow complex scheduling and state structures.

Level 4

Browser subsystem.

Goal: understand production C++ architecture.

Level 5

Cross-process feature.

Goal: trace Blink + Chromium/content + Mojo.

Level 6

Contribution.

Goal: modify production source.

A source-reading exercise is not complete until you can explain:

Why this code exists
What invariant it maintains
What calls it
What it calls
What happens if removed
How it is tested
Where ownership lies

45. Reimplementation Rule

For important abstractions:

Use It
↓
Break It
↓
Build a Tiny Version
↓
Read Production Source
↓
Compare Designs
↓
Modify Production Source
↓
Explain Trade-offs

Do not begin with production source.

First derive a simple design.

Then use production source to discover the constraints that forced additional complexity.


46. "Why Does This Complexity Exist?" Notebook

For difficult production code record:

Observed Complexity:
...

My simpler design:
...

What requirement breaks my design?
...

Production constraint:
...

Resulting architecture:
...

Apply to:

  • Fiber
  • Vue scheduler
  • HTML parser states
  • layout fragmentation
  • Chromium multiprocess IPC
  • browser security boundaries
  • concurrent rendering
  • hydration
  • event delegation

The key skill is identifying which constraint caused the complexity.


47. Implementation Comparison Matrix

Compare:

  • mini React
  • React
  • mini Vue
  • Vue
  • browser behavior

Across:

  • change detection
  • scheduling
  • memory
  • consistency
  • debuggability
  • incremental work
  • failure modes
  • extensibility
  • compile-time knowledge
  • runtime knowledge

Never reduce comparisons to "which is faster?"


48. Framework Design Challenges

Examples:

Challenge A

Design a UI framework without a virtual DOM.

Challenge B

Move dependency analysis to compile time.

Challenge C

Target Canvas instead of DOM.

Challenge D

Support asynchronous rendering.

Challenge E

Design SSR + hydration.

Challenge F

Design partial hydration.

Challenge G

Design offline-first state.

For each, identify constraints, invariants, and failure modes.


49. OSS Contribution Portfolio

Progression:

Small JS Library
↓
Framework Ecosystem
↓
Developer Tooling
↓
Web Platform Test
↓
Chromium/Blink

For every contribution retain:

  • issue
  • investigation
  • code change
  • tests
  • review discussion
  • rejected alternatives
  • result
  • lessons

50. Browser/Framework Expert Capstone

From Component to Pixel

Build an application using custom implementations:

Custom JSX
↓
Custom Component Runtime
↓
Custom Reactive Store
↓
Custom Router
↓
DOM
↓
Chromium/Blink
↓
Style
↓
Layout
↓
Paint
↓
Compositor
↓
GPU
↓
Pixels

Instrument every layer that can reasonably be observed.

Explain exactly what happens after:

setCount(count + 1)

including:

  • framework scheduling
  • state update
  • reconciliation/reactivity
  • DOM operations
  • Blink invalidation
  • style
  • layout if required
  • paint if required
  • compositing if required
  • frame presentation

Also explain which stages can be skipped and why.


51. Mastery Criteria

The browser/framework track is complete only when the learner can independently:

Browser architecture

Draw Chromium's major processes and explain trust boundaries.

Source navigation

Given a browser behavior, locate the likely subsystem and implementation.

Rendering

Explain HTML → DOM → style → layout → paint → compositing in implementation-level terms.

Performance

Determine whether a change triggers style, layout, paint, or compositor work.

JS integration

Trace a Web API from JavaScript through browser bindings.

Debugging

Set native breakpoints and follow meaningful execution.

Tests

Locate, run, and modify relevant browser-engine tests.

Specifications

Connect implementation behavior to standards.

Chromium contribution

Produce at least one meaningful upstream-quality Chromium/Blink change.

Framework internals

Implement simplified working versions of:

  • React-like runtime
  • Vue-like reactive/runtime/compiler system
  • Redux-like store

Infrastructure

Implement simplified versions of:

  • router
  • query cache
  • signals
  • virtualized list
  • bundler/compiler

Architectural judgment

Explain not only how these systems work, but why their complexity exists.


52. Final Mental Model

PRODUCT
│
▼
APPLICATION
│
▼
COMPONENT FRAMEWORK
│
├── Scheduler
├── Reactivity
├── Reconciliation
└── State
│
▼
WEB APIs / DOM
│
▼
BLINK
│
├── HTML Parser
├── DOM
├── CSS
├── Style
├── Layout
├── Paint
└── Events
│
▼
CHROMIUM
│
├── Content
├── IPC
├── Network
├── Security
└── Browser Process
│
▼
COMPOSITOR
│
▼
GPU
│
▼
PIXELS

At Principal/Distinguished level, continuously move both directions:

Product requirement
↓
architecture
↓
framework
↓
browser

and:

browser constraint
↓
framework behavior
↓
application architecture
↓
user experience

The goal is to understand the frontend as one interconnected system rather than a collection of isolated libraries and abstractions.