Frontend Principal Journey
This repository is also a book. Build it with
bash build.sh(ormdbook servefor live reload) and read it at http://localhost:3000. Start with the Introduction, the Phases & Modules index, and Toolchain Setup.Modules
fe-01…fe-06are built and measured: 22 experiments drive a real browser over CDP, and every table in the book is regenerated bynpm run allinside that module'ssrc/. 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 belief | Measured |
|---|---|
Chunking work with await keeps the page responsive | 0 frames rendered over 400 ms; 49 for task-based chunking |
| Moving work to a Web Worker makes it faster | The naive worker was the worst of seven strategies — worse than doing nothing |
| A closure only retains what it references | An unused, never-called sibling retained 38 MB vs 0.01 MB |
performance.memory will show you a leak | Freeing 508 KB of detached DOM moved the JS heap 0.10 MB |
| Semantic HTML is verbose | Div soup was 1.38× larger — with zero landmarks, headings or controls |
| Property order is the hidden-class hazard | 1.14×. delete is 11.9× |
| Flex and grid are slow; absolute is fast | 1.4× across six modes, and absolute was slowest |
| Large DOM is inherently slow | Identical 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
- Read Toolchain Setup — Node 18+ and a Chrome binary is the whole dependency list.
- Read the Curriculum Map for the dependency graph and why the ordering is what it is.
- 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`
- 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
| Track | Prefix | Modules | Focus |
|---|---|---|---|
| Frontend Principal Engineering | fe-NN | 6 built of 51 | judgment, architecture, measured trade-offs |
| Browser Internals | bi-NN | 17 | Chromium, Blink, V8 — source to pixels |
| Framework Internals | fw-NN | 12 | build 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:
- Frontend Principal Engineering — the 46-area curriculum spec
- Browser & Framework Internals — the sibling depth track
Where the book and the specifications disagree, the specifications win.
Phases & Modules
This repository holds three parallel tracks, all sharing the same module shape:
| Track | Prefix | Modules | What it builds |
|---|---|---|---|
| Frontend Principal Engineering | fe-NN | 51 planned, 6 built | Architectural and organisational judgment, measured |
| Browser Internals | bi-NN | 17 | Chromium, Blink and V8 — from source to pixels |
| Framework Internals | fw-NN | 12 | Build 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.
| Module | Title | Status | Headline measured result |
|---|---|---|---|
| fe-01 | Execution Model, Scheduling & the Rendering Pipeline | ✅ | Microtask chunking rendered 0 frames vs 49; naive worker worst of 7 strategies |
| fe-02 | Memory Model, Retention & Leaks | ✅ | An unused sibling closure retained 38 MB vs 0.01 MB (5744×) |
| fe-03 | Object Shapes, Hidden Classes, ICs & JIT | ✅ | One forced layout = 1,448 de-megamorphised property reads |
| fe-04 | HTML as an Application Platform | ✅ | Native <dialog> 7/7 behaviours in 222 bytes; custom 6/7 in 5.4× |
| fe-05 | CSS Architecture, Containment & Invalidation | ✅ | content-visibility cut initial layout 98%, identical DOM |
| fe-06 | Layout: Flex, Grid, Intrinsic Sizing, Container Queries | ✅ | 1.4× across six layout modes; absolute positioning slowest |
| fe-07 | Networking: HTTP/1.1→3, Caching, ETags, CDN | ⬜ | |
| fe-08 | TypeScript as a Contract System | ⬜ | |
| — | Capstone 1 — Foundation | ⬜ | Production-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.
| Module | Title | Status |
|---|---|---|
| fe-09 | Component model, reconciliation, Fiber, render vs commit | ⬜ |
| fe-10 | Hooks mechanics, stale closures, the useEffect pathology | ⬜ |
| fe-11 | Memoization economics | ⬜ |
| fe-12 | Concurrent rendering, transitions, Suspense as scheduling | ⬜ |
| fe-13 | Reactivity models compared (Vue / Svelte / Solid / Web Components) | ⬜ |
| fe-14 | UI algorithms: diff, LRU, tries, intervals, virtualization | ⬜ |
Phase 3 — Architecture & Data · Senior → Staff
A frontend is a distributed system with a rendering engine attached.
| Module | Title | Status |
|---|---|---|
| fe-15 | State taxonomy: local / lifted / URL / server / global / derived | ⬜ |
| fe-16 | Server state ≠ client state; query caches & invalidation | ⬜ |
| fe-17 | Races, cancellation, idempotency, optimistic update + rollback | ⬜ |
| fe-18 | API integration as a distributed-systems problem | ⬜ |
| fe-19 | Application architecture — and its overengineering critique | ⬜ |
| fe-20 | Designing for the unhappy path | ⬜ |
| fe-21 | Rendering 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.
| Module | Title | Status |
|---|---|---|
| fe-22 | Performance engineering & budgets | ⬜ |
| fe-23 | Loading architecture: splitting, tree shaking, preload, Early Hints | ⬜ |
| fe-24 | Accessibility as engineering | ⬜ |
| fe-25 | Frontend security | ⬜ |
| fe-26 | Internationalisation | ⬜ |
| fe-27 | Mobile web & PWA | ⬜ |
| fe-28 | Workers & parallelism | ⬜ |
| fe-29 | WebAssembly | ⬜ |
| fe-30 | Large-scale UI performance | ⬜ |
| — | Capstone 3 — Senior | ⬜ |
Phase 5 — Quality Engineering · Staff
Thousands of tests can still produce low confidence.
| Module | Title | Status |
|---|---|---|
| fe-31 | Test strategy economics | ⬜ |
| fe-32 | UI testing | ⬜ |
| fe-33 | E2E & flaky-test prevention | ⬜ |
| fe-34 | Integration & contract testing | ⬜ |
| fe-35 | Visual regression | ⬜ |
| fe-36 | AI-assisted QA | ⬜ |
| fe-37 | Principal quality engineering | ⬜ |
Phase 6 — Platform & Delivery · Staff → Principal
You stop building applications and start building the conditions under which others build them.
| Module | Title | Status |
|---|---|---|
| fe-38 | Design systems | ⬜ |
| fe-39 | Build tooling | ⬜ |
| fe-40 | Monorepos | ⬜ |
| fe-41 | Microfrontends — taught critically | ⬜ |
| fe-42 | Frontend platform engineering | ⬜ |
| fe-43 | Observability | ⬜ |
| fe-44 | Reliability | ⬜ |
| fe-45 | CI/CD & release engineering | ⬜ |
| — | Capstone 4 — Staff | ⬜ |
Phase 7 — Principal Judgment · Principal → Distinguished
The work is now decisions, influence, and being right about reversibility.
| Module | Title | Status |
|---|---|---|
| fe-46 | Architecture Decision Records | ⬜ |
| fe-47 | Technical decision-making: one-way vs two-way doors | ⬜ |
| fe-48 | Technical debt | ⬜ |
| fe-49 | Migrations | ⬜ |
| fe-50 | Principal skills, strategy, technology radar | ⬜ |
| fe-51 | Failure 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-00 … bi-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-01 … fw-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)
| Tool | Why |
|---|---|
| Chrome / Chromium (stock) | every observation lab; DevTools; --enable-blink-features |
Chromium Code Search — source.chromium.org/chromium/chromium/src | source navigation with cross-references and blame, no checkout |
Perfetto UI — ui.perfetto.dev | full traces across processes and threads |
| Node.js | the framework-track labs and their spec harnesses |
| The specs — WHATWG HTML/DOM, CSS, Web IDL | the 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):
| Item | Size |
|---|---|
~/chromium/src | 26 GB |
| git cache mirror | 24 GB |
| one component build, modest symbols | 15–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.
Pausing — ScopedPagePauser 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)
| Module | Status | Lab | Notes |
|---|---|---|---|
| bi-01-navigating-chromium | todo | ☐ drills | 8 timed drills; deliverable is the query log |
| bi-03-html-parsing | todo | ☐ parsing lab | start here; fixtures in src/ |
| Chromium checkout | ✅ done | ~/chromium/src @ 7e6a84f5165fd, 26 GB | |
| Chromium build | ⛔ blocked | toolchain floor — see build guide §2.0 | |
| Architecture map from memory, then corrected | todo | ☐ | |
| DevTools → Perfetto: name 5 trace events | todo | ☐ |
Phase 1 — Parser, DOM, Bindings
| Module | Status | Lab |
|---|---|---|
| bi-02-architecture-process-model | todo | ☐ |
| bi-04-dom-internals | todo | ☐ mini-browser M3 |
| bi-05-bindings-v8 | todo | ☐ mini-binding layer |
| fw-01-mini-redux | todo | ☐ + reading ladder L1 |
Phase 2 — Style
| Module | Status | Lab |
|---|---|---|
| bi-06-chromium-cpp | todo | ☐ (JIT reference) |
| bi-07-css-engine | todo | ☐ mini-browser M4–M6 |
| fw-02-mini-react-core | todo | ☐ stages 1–7 |
Vertical trace 1 — classList.add | todo | ☐ |
Phase 3 — Layout & Paint
| Module | Status | Lab |
|---|---|---|
| bi-08-layout | todo | ☐ mini-browser M7–M9 |
| bi-09-paint | todo | ☐ mini-browser M10–M11 |
| fw-03-reactivity-signals | todo | ☐ both, back to back |
Vertical traces 2, 3 — getBoundingClientRect, DOM insertion | todo | ☐ |
Phase 4 — Compositor, Scheduling, Debugging
| Module | Status | Lab |
|---|---|---|
| bi-10-compositor-gpu | todo | ☐ |
| bi-11-scheduling | todo | ☐ needs fe-01 first |
| bi-12-debugging-tracing | todo | ☐ needs a build |
| fw-04-mini-react-scheduling | todo | ☐ derive Fiber |
| fw-10-virtual-list | todo | ☐ |
| Vertical traces 4, 5, 8 — click, scroll, rAF | todo | ☐ |
Phase 5 — Compilers, Tooling, Production Source
| Module | Status |
|---|---|
| fw-05-vue-renderer | todo |
| fw-06-compilers | todo |
| fw-07-bundler | todo |
| fw-08-router | todo |
| fw-09-query-cache | todo |
| fw-11-production-source | todo |
| fw-12-testing-devtools | todo |
Vertical traces 6, 7 — setState, fetch | todo |
Phase 6 — Tests, WPT, Contribution
| Module | Status |
|---|---|
| bi-13-tests-wpt | todo — needs a build |
| bi-14-contribution | todo — needs a build |
Phase 7 — Capstone
| Module | Status |
|---|---|
| bi-15-vertical-traces | ongoing across phases |
| bi-16-capstone | todo |
2. Vertical trace ledger (§43)
Record the deepest layer genuinely resolved with evidence, not the deepest you can name.
| # | Operation | After | Status | Deepest resolved | Evidence type |
|---|---|---|---|---|---|
| 1 | classList.add | bi-07 | ☐ | ||
| 2 | getBoundingClientRect | bi-08 | ☐ | ||
| 3 | DOM insertion | bi-04 | ☐ | ||
| 4 | click | bi-10/11 | ☐ | ||
| 5 | scroll | bi-10 | ☐ | ||
| 6 | setCount(count+1) | fw-04 | ☐ | ||
| 7 | fetch | bi-02 | ☐ | ||
| 8 | requestAnimationFrame | bi-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)
| # | Topic | Module | Status |
|---|---|---|---|
| 1 | HTML parser insertion modes | bi-03 | ☐ |
| 2 | Redux middleware vs enhancers | fw-01 | ☐ |
| 3 | Style invalidation sets | bi-07 | ☐ |
| 4 | Layout fragmentation | bi-08 | ☐ |
| 5 | Property trees | bi-09 | ☐ |
| 6 | Pending/active tree split | bi-10 | ☐ |
| 7 | Mojo / multiprocess IPC | bi-02 | ☐ |
| 8 | Two garbage collectors | bi-04 | ☐ |
| 9 | Vue scheduler | fw-03 | ☐ |
| 10 | Fiber (flagship) | fw-04 | ☐ |
| 11 | Hydration | fw-11 | ☐ |
| 12 | Event delegation / synthetic events | fw-11 | ☐ |
4. Contribution ladder (§24)
| Rung | Target | Status | CL |
|---|---|---|---|
| 0 | Build Chromium | ⛔ blocked on toolchain | — |
| 1 | Docs or test-only | ☐ | |
| 2 | Small isolated correctness fix | ☐ | |
| 3 | Blink behaviour bug + regression test | ☐ | |
| 4 | Small rendering/style/layout improvement | ☐ | |
| 5 | Cross-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
| Lab | Date | Prediction correct? | Time | Biggest 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 |
| Host | macOS 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.
| Date | Claim | Verdict |
|---|---|---|
| 2026-08-10 | Chromium trunk builds with Xcode 16.2 / SDK 15.2 | FALSE — 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-10 | Blink layout classes are prefixed ng_ | STALE — prefix removed; block_layout_algorithm.cc, constraint_space.h. layout_ng.md doc remains. |
| 2026-08-10 | Chromium bugs live at bugs.chromium.org | STALE — now issues.chromium.org. In-tree docs/contributing.md still has the old reference. |
| 2026-08-10 | fetch --git-cache mirror lives in ~/.cache | FALSE — location is cache_dir in ~/chromium/.gclient; here ~/Library/Caches/depot_tools/git_cache. Roughly doubles peak disk. |
| 2026-08-10 | Blink's HTML fastpath accelerates document parsing | FALSE — TryParsingHTMLFragment is the fragment (innerHTML) path; bails out to the general algorithm on unsupported tags. |
| 2026-08-10 | BackgroundHTMLScanner == the old threaded HTML parser | FALSE — 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-11 | Blink 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-11 | css_properties.json5 declares per-property pipeline invalidation | Confirmed - 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-11 | Blink HTML tokenizer/tree-builder size | Confirmed - 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-11 | Chromium process-model vocabulary | Confirmed - SiteInfo (security principal), SiteInstance (principal instance, ~= agent cluster), BrowsingInstance (browsing context group), ProcessLock (enforcement). Source: docs/process_model_and_site_isolation.md. |
| 2026-08-11 | Blink scheduler policy constants | Confirmed - 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-11 | Oilpan heap partitioning | Confirmed - 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-11 | V8 has four execution tiers | Confirmed in-tree: v8/src/interpreter (Ignition), baseline (Sparkplug), maglev (Maglev), compiler (TurboFan). |
| 2026-08-11 | TestExpectations scale and policy | Confirmed - 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-11 | Paint architecture | Confirmed - 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-11 | cc vocabulary | Confirmed - 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-11 | Blink generated-input surface | Confirmed - 57 .json5 files under third_party/blink/renderer. runtime_enabled_features.json5 status vocabulary is exactly ["stable","experimental","test"], optionally per-platform. |
| 2026-08-11 | Trace categories are enumerable in-tree | Confirmed - 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-10 | fetch --git-cache chromium is the fast-checkout path on macOS | Confirmed — docs/mac_build_instructions.md |
| 2026-08-10 | Parser sources in third_party/blink/renderer/core/html/parser/ | Confirmed — 95 files incl. html_tokenizer.*, html_tree_builder.*, html_construction_site.* |
| 2026-08-10 | Foster parenting: HTMLConstructionSite::{FosterParent,FindFosterSite,ShouldFosterParent}, HTMLStackItem::CausesFosterParenting, HTMLTreeBuilder::ProcessStartTagForInTable | Confirmed against main |
| 2026-08-10 | Build targets //content/shell:content_shell, //:blink_tests, //third_party/blink/renderer/controller:blink_unittests | Confirmed via gn ls |
| 2026-08-10 | Style invalidation machinery: RuleFeatureSet, PendingInvalidationsMap, SelectorChecker::MatchSelector, Element::StyleForLayoutObject | Confirmed — 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
| Term | Definition |
|---|---|
| Module | A fe-NN-topic/ directory: concepts + runnable source + steps + verification |
| Phase | A group of modules sharing a dependency level and a competence target |
| Promotion gate | A demonstrable capability required to advance a phase; not a quiz |
| Load-bearing chain | A dependency path where skipping a link makes later work dishonest |
| Spine | Content applied continuously across all phases (AI-assisted engineering, §20–24) |
| Capstone | Integration deliverable at a phase boundary; six total, Foundation → Distinguished |
| Failure case study | One of 12 realistic incidents from the specification, run under a 7-question protocol |
| Verification checkpoint | A measurable pass/fail with expected output; the definition of module completion |
| Cross-track hook | A point where a frontend topic genuinely requires browser-framework-internals.md |
| Two-way door | A 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
-
"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.
-
"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.
-
"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.
-
"The internals track must come first to understand the platform." False. The two tracks are siblings, not stages. Cross into
browser-framework-internals.mdwhen a frontend topic stalls without it — the hooks are enumerated indocs/curriculum-map.md§2 — and not on principle. -
"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 indocs/curriculum-map.md§2. That track's own tracker is the repository-rootPROGRESS.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 specificationbrowser-framework-internals.md— the sibling depth trackREADME.md— track overview and intended agent usage
Engineering ladders and level definitions
- Dropbox Engineering Career Framework
- CircleCI Engineering Competency Matrix
- Rent the Runway Engineering Ladder
- Will Larson, StaffEng — staffeng.com — Staff archetypes and scope
- Tanya Reilly, The Staff Engineer's Path (O'Reilly, 2022)
Technical decision-making and architecture records
- Michael Nygard, Documenting Architecture Decisions
- adr.github.io — ADR templates and tooling
- Jeff Bezos, 1997 & 2015 shareholder letters — one-way vs two-way doors
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) andbrowser-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.
| Cluster | Spec areas | What it actually is | Dependency behaviour |
|---|---|---|---|
| A. Platform substrate | 1, 2, 3, 4, 10 | JS runtime, browser architecture, HTML, CSS, networking | Root. Nearly everything depends on it. Almost nothing depends on the rest. |
| B. Framework mechanics | 5, 6, 40 | React internals, framework comparison, UI algorithms | Depends on A. Gates C and parts of D. |
| C. Architecture & data | 7, 8, 9, 19, 31 | App architecture, state, API integration, unhappy path, rendering architectures | Depends on A + B. Gates E, F, G. |
| D. Quality attributes | 11, 12, 13, 36, 37, 38, 39, 41 | Performance, a11y, security, i18n, mobile, workers, WASM, large-scale UI | Depends on A; deepens with C. Mostly parallelisable within the cluster. |
| E. Quality engineering | 14, 15, 16, 17, 18, 25 | Test strategy through org-wide quality policy | Needs a real system (C) to be worth testing. 25 needs all of 14–18. |
| F. AI-assisted engineering | 20, 21, 22, 23, 24 | Agent workflows, test gen, edge-case discovery, exploratory testing, review | Spine, not a phase. Runs continuously from Phase 1. Quality gated by your own expertise. |
| G. Platform & delivery | 26, 27, 28, 29, 30, 32, 33, 34, 35 | Design systems, platform eng, build tooling, monorepos, microfrontends, observability, reliability, CI/CD, release | Depends on C + D + E. This is where Staff→Principal happens. |
| H. Principal judgment | 42, 43, 44, 45, 46 + case studies + capstones + heuristics | ADRs, decision-making, debt, migrations, principal skills | Depends 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:
-
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.
-
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.
-
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. -
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.
-
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 topic | Internals section | Cross when |
|---|---|---|
| §1 event loop / §11 INP | §16 Browser Scheduling | you need to know why a queue is prioritised, not just that it is |
| §2 rendering pipeline | §5 Complete Rendering Pipeline, §12–15 layout/paint/compositor | a layout/paint cost is inexplicable at the CSS level |
| §4 CSS containment | §10 CSS Engine Internals, §12 Layout Engine | you need invalidation scope, not spec prose |
| §5 React fiber | §27 Build a React-Like Runtime, §28 React Source Apprenticeship | reconciliation behaviour contradicts your model |
| §8 signals/stores | §29 Vue-like reactive runtime, §33 Redux-like store, §36 signals | comparing reactivity models architecturally |
| §8/§9 server state | §35 Build a Server-State Query Cache | designing a cache invalidation policy |
| §13 security | §19 Browser Security Architecture | reasoning about process/origin boundaries |
| §28 build tooling | §38 Minimal Bundler, §39 JSX compiler | evaluating bundler trade-offs |
| §41 large-scale UI | §37 Virtualized List Engine | virtualization correctness/perf |
| §16 E2E | §41 Browser Automation Layer | debugging 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.
| Module | Focus | Depth target |
|---|---|---|
| fe-01 | Execution model: event loop, task/microtask, rendering pipeline coupling | can predict ordering + explain scheduling priority |
| fe-02 | Memory model: closures, GC, retention, leak taxonomy, heap snapshots | can find a leak from a snapshot diff, unaided |
| fe-03 | Object shapes, hidden classes, ICs, JIT — and when it actually matters | can say when this is irrelevant, which is most of the time |
| fe-04 | HTML as a platform: semantics, forms, dialog/popover, web components | can delete a component and replace it with a primitive |
| fe-05 | CSS: cascade, layers, custom properties, containment, content-visibility | can reason about invalidation scope |
| fe-06 | Layout: flex, grid, subgrid, container queries, intrinsic sizing, stacking | can predict layout without running it |
| fe-07 | Networking: HTTP/1.1→3, connection reuse, cache headers, ETags, CDN, priorities | can read a waterfall and name the bottleneck class |
| fe-08 | TypeScript as a contract system: structural typing, variance, branded types, runtime validation boundary | can 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.
| Module | Focus |
|---|---|
| fe-09 | Component model, reconciliation, Fiber, render vs commit phase |
| fe-10 | Hooks mechanics: state, effects, stale closures, refs, the useEffect overuse pathology |
| fe-11 | Memoization economics: when useMemo/memo/compiler pays, when it costs |
| fe-12 | Concurrent rendering, transitions, Suspense — as scheduling, not magic |
| fe-13 | Reactivity models compared: VDOM vs signals vs compiled (Vue/Svelte/Solid/WC) |
| fe-14 | UI 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.
| Module | Focus |
|---|---|
| fe-15 | State taxonomy: local / lifted / URL / server / global / derived / cached / persistent |
| fe-16 | Server state ≠ client state: query caches, invalidation, dedup, SWR |
| fe-17 | Race conditions, cancellation, idempotency, optimistic update + rollback |
| fe-18 | API integration as distributed systems: retries, timeouts, backpressure, partial failure |
| fe-19 | Application architecture: feature slices, dependency direction, ports/adapters, state machines — and when this is overengineering |
| fe-20 | Designing for the unhappy path (§19 in full — the 29-item edge-case matrix) |
| fe-21 | Rendering 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.
| Module | Focus |
|---|---|
| fe-22 | Performance engineering: CWV, LCP/INP/CLS/TTFB, budgets, quantitative claims |
| fe-23 | Loading architecture: code splitting, tree shaking, preload/prefetch, Early Hints |
| fe-24 | Accessibility as engineering: a11y tree, names, focus, ARIA, the limits of automation |
| fe-25 | Frontend security: XSS taxonomy, CSP, CORS, cookies, tokens, Trusted Types, supply chain |
| fe-26 | i18n/l10n: Unicode, RTL, pluralization, text expansion, timezones |
| fe-27 | Mobile web: CPU/memory/network reality, viewport, virtual keyboards, PWA, service workers |
| fe-28 | Workers & parallelism: message passing, transferables, SAB/Atomics — and offload economics |
| fe-29 | WASM: enough for architectural judgment, not enough to write a compiler |
| fe-30 | Large-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).
| Module | Focus |
|---|---|
| fe-31 | Test strategy: for each type — what it catches, misses, costs, and how hard failures are to diagnose |
| fe-32 | UI testing: user-centric, async, forms, keyboard, a11y assertions; anti-patterns |
| fe-33 | E2E with Playwright: contexts, fixtures, interception, traces, flaky-test prevention |
| fe-34 | Integration & contract testing: schema validation, service virtualization, ephemeral envs |
| fe-35 | Visual regression: tolerance, false positives, browser differences |
| fe-36 | AI-assisted QA (§21–23): test matrices, edge-case discovery, exploratory agents |
| fe-37 | Principal 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.
| Module | Focus |
|---|---|
| fe-38 | Design systems: tokens, primitives, versioning, adoption, governance, contribution models |
| fe-39 | Build tooling: module graphs, tree shaking, source maps, incremental builds, HMR; bundler architectures compared |
| fe-40 | Monorepos: workspaces, boundaries, dependency graphs, remote caching, affected builds, ownership |
| fe-41 | Microfrontends — taught critically. Is this an organizational or a technical problem? |
| fe-42 | Frontend platform engineering: templates, standards, codegen, DX, feature flags, preview envs |
| fe-43 | Observability: RUM, Web Vitals, tracing, correlation IDs, session replay. Diagnose "it feels slow" with evidence |
| fe-44 | Reliability: SLIs/SLOs, error budgets, graceful degradation, kill switches, circuit breakers |
| fe-45 | CI/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.
| Module | Focus |
|---|---|
| fe-46 | ADRs: context, constraints, options, decision, consequences, reversibility, migration |
| fe-47 | Technical decision-making: one-way vs two-way doors, blast radius, optionality, build vs buy, standardisation vs autonomy |
| fe-48 | Technical debt: taxonomy, quantification, prioritisation, and how to sell paying it down |
| fe-49 | Migrations: strangler patterns, incremental cutover, the 8 migration projects in §45 |
| fe-50 | Principal skills: strategy, RFCs, influence without authority, incident analysis, radar, mentoring |
| fe-51 | Failure 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
| Group | Why 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–2 | TS is a contract discipline, not a runtime dependency |
| fe-13 (framework comparison) ‖ Phase 3 | Comparison 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) ‖ everything | By design |
| §46 writing practice (RFCs/ADRs) ‖ everything from Phase 3 | Start writing early; quality compounds |
Hard prerequisites — do not parallelise
| Blocked | Requires | Why the shortcut fails |
|---|---|---|
| fe-22 Performance | fe-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 / Suspense | fe-01 | "Concurrent" is meaningless without task/microtask/frame semantics |
| fe-16–fe-17 Server state & races | fe-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 architectures | fe-09 (render/commit), fe-07 (caching), fe-12 (streaming) | Hydration cost and streaming behaviour are unexplainable otherwise |
| fe-24 Accessibility | fe-04 (semantic HTML) | ARIA-first a11y produces compliant-looking, unusable UI |
| fe-30 Large-scale UI | fe-14 (algorithms), fe-22 (perf method), fe-28 (workers) | Virtualization without measurement is cargo cult |
| fe-37 Org quality policy | fe-31–fe-35 | You cannot set a policy over test types you haven't paid the cost of |
| fe-41 Microfrontends | fe-38, fe-39, fe-40 | Microfrontends are a consequence of build + design-system + org constraints |
| fe-43 Observability | fe-22 | You'll instrument metrics you can't interpret |
| fe-49 Migrations | fe-46, fe-47 | Migration without a decision record is a rewrite with better branding |
| Capstone 5/6 | Phases 1–6 | These 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.
| Incident | Phase | Anchor module |
|---|---|---|
| Rendering loop freezes browser | 1 | fe-01 |
| Memory leak after hours | 1 | fe-02 |
| Stale response overwrites newer data | 3 | fe-17 |
| Global rerenders from context misuse | 2 | fe-11 |
| Hydration mismatch | 3 | fe-21 |
| CSS bundle explosion | 4 | fe-23 |
| Accessibility regression | 4 | fe-24 |
| Cache poisoning | 4 | fe-25 |
| Flaky E2E suite | 5 | fe-33 |
| Design-system upgrade breaks many apps | 6 | fe-38 |
| Dependency compromise | 6 | fe-42 |
| Poor observability hides an outage | 6 | fe-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.
| Gate | Criterion |
|---|---|
| 1 → 2 | Predict 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 → 3 | Explain any re-render in a real app from evidence. Argue VDOM vs signals with measured data and stated limits of that data. |
| 3 → 4 | Ship a feature that survives the full unhappy-path matrix. Produce a state-ownership diagram a reviewer can attack. |
| 4 → 5 | Defend a performance claim quantitatively against a stated budget. Pass a keyboard-and-screen-reader review with no automated tooling. |
| 5 → 6 | Write a test policy that states, explicitly, what it does not give confidence in. |
| 6 → 7 | Own a platform decision with a documented blast radius and reversibility analysis. |
| 7 → done | Produce 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.
| Date | Module | Concept | Nature of the confusion | Resolved? | How |
|---|---|---|---|---|---|
| 2026-08-10 | fe-01 | Microtask checkpoint placement | Natural but wrong model: "checkpoint runs at the end of each task." Predicts interleaving for setTimeout(() => el.click()), which does not happen. | yes | Correct rule is stack-emptiness. Measured 4 dispatch paths. |
| 2026-08-11 | fe-02 | Leak detection statistics | Assumed 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. | yes | Report slope AND linearity, plus a control run. |
| 2026-08-11 | fe-02 | Harness isolation | Ran 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. | yes | Fresh page per variant. Same class as fe-01's realm reuse. |
| 2026-08-10 | fe-01 | Measurement trust | Two harness bugs produced clean, plausible, wrong data (realm reuse; per-event vs per-interaction INP). | yes | Harness 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 in | Question | Status | Answer / where it got resolved |
|---|---|---|---|---|
| 1 | fe-03 | Why 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 pursued | Magnitude 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.
| Date | Repo / file | Looking for | What I found | What surprised me |
|---|---|---|---|---|
| 2026-08-10 | WHATWG 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-10 | Chrome 149, measured | rAF vs setTimeout(0) ordering | not a property of the primitives at all | Input 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)
| Date | Claim | Verdict |
|---|---|---|
| 2026-08-11 | Flex/grid are slow; absolute positioning is fast | False, 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-11 | Container queries are expensive | False, 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-11 | Large DOM is inherently slow to render | False, measured — identical 261,011-node DOM took 401.9ms initial layout with no containment and 7.9ms with content-visibility: auto (−98%). |
| 2026-08-11 | contain speeds up initial rendering | False, measured — contain: 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-11 | Specificity decides the cascade | Incomplete, 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-11 | minlength/maxlength apply to any value | False, 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-11 | Semantic HTML is more verbose than divs | False, measured — div soup was 1.38x the markup of semantic HTML with 0 landmarks/headings/controls; div+ARIA 1.69x. |
| 2026-08-11 | Property order is the main hidden-class hazard | False, 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-11 | Megamorphic access is a performance crisis | Overstated, measured — 1.9x ratio but only 2.9ns absolute. One forced layout = 1,448 megamorphic reads; one 200KB JSON.parse = 136,552. |
| 2026-08-11 | V8 closures retain the whole Context, not just referenced variables | Confirmed 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-11 | performance.memory / Runtime.getHeapUsage can detect DOM leaks | False, measured — freeing 507.8KB of detached DOM moved the JS heap by 0.10MB. DOM is in Blink's C++ heap. |
| 2026-08-11 | Heap snapshots expose a per-node detachedness field (0/1/2) | Confirmed Chrome 149 — exact detached counts without name-prefix matching. |
| 2026-08-10 | await null costs 1 microtask tick | Confirmed Chrome 149 — A1 P1 A2 P2 A3 P3. The widely-cited "3 ticks" is pre-2019 and stale. |
| 2026-08-10 | Microtask chunking creates rendering opportunities | False, measured — 0 rAF frames over 400 ms vs 48 for MessageChannel, identical structure. |
| 2026-08-10 | scheduler.yield() available in Chrome | Confirmed 149; same INP as MessageChannel but 3.4× wall clock in the 200k-row lab. |
| 2026-08-10 | Workers reduce main-thread cost | Conditional — 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
| # | Date | Decision | Reversibility | Link |
|---|---|---|---|---|
Revisit queue
Topics marked revisit in PROGRESS.md, with the reason and the trigger for revisiting.
| Module | Why flagged | Revisit 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.
| Heuristic | Counterexample found | Limitation |
|---|---|---|
| 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). ThePROGRESS.mdat 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)
| # | Module | Status | Lab | Failure lab | Notes |
|---|---|---|---|---|---|
| fe-01 | Execution model: event loop, tasks/microtasks, rendering pipeline | in progress | ☑ measured | ☑ seeded | module |
| fe-02 | Memory model: closures, GC, retention, leak taxonomy | in progress | ☑ measured | ☑ seeded | module · results |
| fe-03 | Object shapes, hidden classes, ICs, JIT — and when it doesn't matter | in progress | ☑ measured | n/a | module · results |
| fe-04 | HTML as a platform: semantics, forms, dialog/popover, web components | in progress | ☑ measured | ☑ | module · results |
| fe-05 | CSS: cascade, layers, custom properties, containment | in progress | ☑ measured | ☐ | module · results |
| fe-06 | Layout: flex, grid, subgrid, container queries, intrinsic sizing | in progress | ☑ measured | ☐ | module · results |
| fe-07 | Networking: HTTP/1.1→3, caching, ETags, CDN, priorities | todo | ☐ | ☐ | |
| fe-08 | TypeScript as a contract system | todo | ☐ | ☐ | |
| — | 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)
| # | Module | Status |
|---|---|---|
| fe-09 | Component model, reconciliation, Fiber, render vs commit | todo |
| fe-10 | Hooks mechanics, stale closures, the useEffect pathology | todo |
| fe-11 | Memoization economics | todo |
| fe-12 | Concurrent rendering, transitions, Suspense as scheduling | todo |
| fe-13 | Reactivity models compared (Vue/Svelte/Solid/WC) | todo |
| fe-14 | UI algorithms: diff, LRU, tries, intervals, virtualization | todo |
Phase 3 — Architecture & Data (Senior → Staff)
| # | Module | Status |
|---|---|---|
| fe-15 | State taxonomy | todo |
| fe-16 | Server state ≠ client state; query caches & invalidation | todo |
| fe-17 | Races, cancellation, idempotency, optimistic update + rollback | todo |
| fe-18 | API integration as a distributed-systems problem | todo |
| fe-19 | Application architecture — and its overengineering critique | todo |
| fe-20 | Designing for the unhappy path | todo |
| fe-21 | Rendering architectures: CSR/SSR/SSG/ISR/streaming/islands/RSC | todo |
| — | Capstone 2 (Intermediate) | todo |
Phase 4 — Quality Attributes (Staff)
| # | Module | Status |
|---|---|---|
| fe-22 | Performance engineering & budgets | todo |
| fe-23 | Loading architecture | todo |
| fe-24 | Accessibility as engineering | todo |
| fe-25 | Frontend security | todo |
| fe-26 | i18n / l10n | todo |
| fe-27 | Mobile web & PWA | todo |
| fe-28 | Workers & parallelism | todo |
| fe-29 | WebAssembly | todo |
| fe-30 | Large-scale UI performance | todo |
| — | Capstone 3 (Senior) | todo |
Phase 5 — Quality Engineering (Staff)
| # | Module | Status |
|---|---|---|
| fe-31 | Test strategy economics | todo |
| fe-32 | UI testing | todo |
| fe-33 | E2E & flaky-test prevention | todo |
| fe-34 | Integration & contract testing | todo |
| fe-35 | Visual regression | todo |
| fe-36 | AI-assisted QA | todo |
| fe-37 | Principal quality engineering | todo |
Phase 6 — Platform & Delivery (Staff → Principal)
| # | Module | Status |
|---|---|---|
| fe-38 | Design systems | todo |
| fe-39 | Build tooling | todo |
| fe-40 | Monorepos | todo |
| fe-41 | Microfrontends (critically) | todo |
| fe-42 | Frontend platform engineering | todo |
| fe-43 | Observability | todo |
| fe-44 | Reliability | todo |
| fe-45 | CI/CD & release engineering | todo |
| — | Capstone 4 (Staff) | todo |
Phase 7 — Principal Judgment (Principal → Distinguished)
| # | Module | Status |
|---|---|---|
| fe-46 | ADRs | todo |
| fe-47 | Technical decision-making | todo |
| fe-48 | Technical debt | todo |
| fe-49 | Migrations | todo |
| fe-50 | Principal skills | todo |
| fe-51 | Failure case-study gauntlet (all 12, cold) | todo |
| — | Capstone 5 (Principal) | todo |
| — | Capstone 6 (Distinguished/SME) | todo |
Failure case studies
| Incident | Phase | Status |
|---|---|---|
| Rendering loop freezes browser | 1 | todo |
| Memory leak after hours | 1 | todo |
| Global rerenders from context misuse | 2 | todo |
| Stale response overwrites newer data | 3 | todo |
| Hydration mismatch | 3 | todo |
| CSS bundle explosion | 4 | todo |
| Accessibility regression | 4 | todo |
| Cache poisoning | 4 | todo |
| Flaky E2E suite | 5 | todo |
| Design-system upgrade breaks many apps | 6 | todo |
| Dependency compromise | 6 | todo |
| Poor observability hides an outage | 6 | todo |
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 (
requestAnimationFramecallbacks → 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? | Yes | No — not until the queue is empty |
| Unbounded chain starves rendering? | No — browser renders between them | Yes — permanent freeze, no error, no crash |
| Consistency guarantee | Weak; state may change between them | Strong; nothing interleaves |
| Correct use | chunking work, yielding to the user | batching 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 primitive | rAF frames rendered | wall clock |
|---|---|---|
await Promise.resolve() | 0 | 401 ms |
MessageChannel postMessage | 48 | 403 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 term | Real cause | Fix class |
|---|---|---|
| Input delay | Long task already running, often unrelated to the interaction | Chunk/yield elsewhere in the app |
| Processing | The handler does too much | Acknowledge visually now, defer the rest past a task boundary |
| Presentation | Huge DOM, expensive style recalc, forced sync layout, large paint | Containment, content-visibility, fewer invalidated elements, compositor-friendly properties |
4. Core terminology
| Term | Definition |
|---|---|
| Task (macrotask) | A unit of work the event loop services from a task queue; runs to completion |
| Task source / task queue | The spec's grouping of tasks; the browser chooses which queue to service — not global FIFO |
| Microtask | Work run at a microtask checkpoint; .then, queueMicrotask, await continuations |
| Microtask checkpoint | Point where the queue is drained exhaustively — when the JS execution context stack becomes empty |
| Rendering opportunity | A point between tasks where the browser may run "update the rendering" |
| Update the rendering | The fixed spec sequence: rAF → observers → style → layout → paint → composite |
| Long task | A task ≥ 50 ms; observable via PerformanceObserver type longtask, with poor attribution |
| LoAF | Long Animation Frames — supersedes long tasks; reports blockingDuration, renderStart, per-script attribution |
| TBT | Total Blocking Time — sum of (taskDuration − 50ms) over long tasks |
| INP | Interaction to Next Paint; per-interaction, the longest event duration in an interactionId group |
interactionId | Groups the events of one interaction (keydown+keypress+keyup) in Event Timing |
| Forced synchronous layout | Reading a geometric property after a style write, forcing style+layout mid-task |
| Compositor thread | Off-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 clone | Serialisation 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.
- Microtask boundary — consistency, no rendering, ~free.
- Task boundary — the browser may render and process input; costs scheduling latency.
- 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
-
"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.
-
"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, andel.click()inside asetTimeoutall 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()/fireEventhave different semantics from real users. -
"
awaitcosts three microtask ticks." Stale — that was pre-2019. Measured on Chrome 149: exactly one tick (sync A1 P1 A2 P2 A3 P3). -
"
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 isrAF; after layout,ResizeObserveror double-rAF. -
"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.
-
"Platform primitives are always preferable." A live counterexample to the specification's own heuristic:
scheduler.yield()and a hand-rolledMessageChannelyield 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. -
"Long tasks tell you what was slow."
longtaskattribution is famously poor (often just the containing frame). LoAF reportsblockingDuration,renderStart, and ascripts[]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, anddelayacross 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 viaMessageChannel." - "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
MessageChannelrather 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." - "
requestAnimationFrameisn't faster thansetTimeout; 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 (s5vss6) 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)
- WHATWG HTML Standard — §8.1.7 Event loops — task queues, "perform a microtask checkpoint", "update the rendering". The authoritative source; the update-the-rendering steps are shorter than expected and worth reading once in full.
- WHATWG HTML — Processing model — how a task queue is chosen. This is where "there is no global FIFO" is normative.
- WHATWG HTML — Queuing tasks — task sources.
- W3C Long Animation Frames API — LoAF,
blockingDuration, script attribution. - W3C Event Timing API —
PerformanceEventTiming,interactionId, the 16 msdurationThresholdfloor. - WICG Scheduling APIs —
scheduler.postTask,scheduler.yield, priority semantics. - CSSOM View —
offsetWidthetc. — why geometric reads force layout.
Explanatory (high quality)
- Jake Archibald, In The Loop (JSConf.Asia 2018) — still the best single explanation of tasks vs microtasks, and the origin of the listener-checkpoint example used in Step 1.
- Jake Archibald, Tasks, microtasks, queues and schedules.
- web.dev — Optimize INP
- web.dev — Optimize long tasks — the source of the "yield every ~5 ms" guidance.
- web.dev — Long animation frames API
- Chrome for Developers — Scheduler API
- Chrome for Developers — Avoid large, complex layouts and layout thrashing
- Paul Irish, What forces layout / reflow — the definitive list of layout-forcing properties.
Implementation / source
- Chromium:
docs/on the renderer main-thread scheduler;blink::scheduler::MainThreadSchedulerImpl— task queue priorities and policy transitions. Guided path inbrowser-framework-internals.md§16. - React:
packages/scheduler/src/forks/Scheduler.js—schedulePerformWorkUntilDeadline; whyMessageChannelover microtasks andsetTimeout. web-vitalslibrary —src/onINP.tsis the reference implementation of interaction grouping. Read it if you intend to compute INP yourself; the grouping subtlety indocs/analysis.mdis handled there.
Security-adjacent (why measurement is constrained)
- MDN —
SharedArrayBufferand cross-origin isolation - web.dev — Why you need cross-origin isolation — COOP/COEP, Spectre, timer coarsening.
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
| Primitive | Creates a rendering opportunity? | Priority on resume | Cost | Use when |
|---|---|---|---|---|
await Promise.resolve() / queueMicrotask | No | n/a | ~free | batching state; never for chunking |
setTimeout(fn, 0) | Yes | back of the queue; clamped (≥ 1 ms, 4 ms when nested ≥ 5 deep) | high latency | legacy fallback only |
MessageChannel.postMessage | Yes | ordinary task, no clamping | low | the reliable hand-rolled yield; what React's scheduler uses |
scheduler.postTask({priority}) | Yes | explicit user-blocking / user-visible / background, AbortSignal | low | genuinely competing work classes |
scheduler.yield() | Yes | continuation priority — ahead of new low-priority work, behind input | low per call, but resumes behind rendering | mid-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
SharedArrayBufferchange the arithmetic.ArrayBuffertransfer 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.SharedArrayBufferrequires 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.
| Dominant | The real question | Where the fix usually lives |
|---|---|---|
| input delay | what was already running? | somewhere else entirely — often a different team's code |
| processing | why is the handler doing this much? | split the handler; defer past a task boundary |
| presentation | how 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 thedominantcolumn: framework-level yielding movespres, and does nothing forprocin an effect. - Timer coarsening and cross-origin isolation.
performance.now()is deliberately coarsened andSharedArrayBufferrequires 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,
rAFstops entirely. Any chunked job that assumes it keeps running when the user switches tabs will silently stall. Usevisibilitychangeexplicitly rather than discovering this from a support ticket.
Execution Guide
Tool versions
| Tool | Version used | Notes |
|---|---|---|
| Node.js | 23.11.0 | any ≥ 18 with global fetch/ESM works |
| playwright-core | 1.62.1 | ships no browsers; see below |
| Chrome for Testing | 149.0.7827.55 (arm64) | reused from an existing Playwright cache |
| Python | 3.13 | only 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:
$CHROMEenvironment variable, if set- newest
chromium-NNNNin~/Library/Caches/ms-playwright(or~/.cache/ms-playwright) - 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
| URL | Purpose |
|---|---|
/starvation.html | Step 2 — feel the difference between microtask and task chunking |
/lab.html | Step 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
REPSruns. 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
jankcolumn 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
| Column | What it is | What a bad value means |
|---|---|---|
longTasks | tasks ≥ 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 tasks | the amount of unresponsiveness, not just its presence |
jank | rAF gaps > 32 ms | frames the user did not get. Directional only in headless |
INP p75 / max | interaction latency, per interaction | > 200 ms fails the "good" threshold; > 500 ms is "poor" |
dominant | largest of delay/proc/pres | the diagnosis. Different values need different fixes |
filter | wall clock inside the filter, summed | the throughput cost of your yielding strategy |
boundary | worker round-trip minus worker compute | structured clone + postMessage. The worker tax |
wall | total elapsed for the typing run | the 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: 0during interactionINP maxunder 200 ms; under 100 ms if the interaction is a keystrokeboundaryunder ~20 ms for any worker strategyfilterwithin ~2× of the unchunked baseline — you are paying a little for responsiveness
Unhealthy, and the specific diagnosis:
longTasks > 0with a chunked strategy → your yield is not a task boundary (this is s2)boundaryin the hundreds or thousands → you are cloning your dataset per operation (this is s5)filtermany times the baseline with no INP improvement → pure overhead; revertINPfine butjankhigh → the interaction is responsive but an animation is not; look at compositor-vs-main-thread properties- Every strategy shows the same
dominantterm → 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.
-
Main thread flame chart. Find the
inputevent 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. -
Interactions track. Each interaction appears as a bar with its three phases. This is the
dominantcolumn, visually. The whisker before the bar is input delay — if it is long, the cause is outside the handler. -
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. -
User Timing track.
performance.mark/measurearound 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. -
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 });longtasktells you that something blocked. LoAF tells you which script, plusrenderStartandblockingDuration. Prefer LoAF whenever it is available. -
Compositor confirmation. With paint flashing / Layers on, confirm a
transformanimation keeps running during a long task — then change it to animateleftand 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
jankcolumn 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 path | Observed order | Stability |
|---|---|---|
| Real trusted click | A F B C G H D E | 40/40 |
b.click() from a script | A F G B C H E D | 40/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 path | Result |
|---|---|
| Real trusted click | L1 m1 L2 m2 L3 m3 — checkpoint between listeners |
el.click() from script | L1 L2 L3 m1 m2 m3 — no checkpoint between listeners |
dispatchEvent(new MouseEvent) | L1 L2 L3 m1 m2 m3 |
el.click() inside setTimeout | L1 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 primitive | rAF frames during the work | wall clock |
|---|---|---|
await Promise.resolve() | 0 | 401 ms |
MessageChannel postMessage | 48 | 403 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
| Pattern | Time |
|---|---|
Interleaved offsetWidth read → style write, per element | 98 ms |
| Batched: read all, then write all | 1 ms |
98× . Same work, same elements, same final DOM. Only the ordering changed.
F. Why D and E swap order
| Dispatch path | rAF fired | setTimeout(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
| Strategy | long tasks | TBT | jank frames >32ms | INP p75 | INP max | dominant term | filter | clone | wall |
|---|---|---|---|---|---|---|---|---|---|
| s0 sync handler | 0 | 0 | 7 | 56 | 56 | proc | 221 | 0 | 2098 |
| s1 async, unchunked | 0 | 0 | 7 | 64 | 64 | pres | 228 | 0 | 2132 |
| s2 microtask-chunked | 8 | 865 | 8 | 184 | 192 | pres | 1219 | 0 | 3099 |
| s3 MessageChannel, 5 ms | 0 | 0 | 9 | 32 | 32 | delay | 466 | 0 | 1941 |
s4 scheduler.yield() | 0 | 0 | 17 | 32 | 32 | pres | 1562 | 0 | 1925 |
| s5 worker, naive | 8 | 881 | 8 | 184 | 200 | pres | 33 | 1502 | 3111 |
| s6 worker, resident data | 0 | 0 | 0 | 24 | 24 | proc | 80 | 4 | 1838 |
Worst-interaction decomposition (ms):
| input delay | processing | presentation | |
|---|---|---|---|
| s0 | 0 | 34 | 0 |
| s1 | 1 | 28 | 63 |
| s2 | 1 | 145 | 183 |
| s3 | 7 | 7 | 0 |
| s4 | 4 | 7 | 21 |
| s5 | 0 | 156 | 184 |
| s6 | 1 | 2 | 0 |
The four results that matter
-
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. -
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.
-
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. -
The dominant INP term moves between strategies. s0 is
proc-bound, s1/s2/s5 arepres-bound, s3 isdelay-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.
page.setContentreuses the JS realm. A top-levelconst bin trial 2 collides with trial 1's, the script throwsIdentifier '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.- INP is per interaction, not per event. One keystroke emits
keydown+keypress+keyupsharing aninteractionId. Taking the longest single event reportsprocessing: 0for a fully synchronous handler, because the longest event iskeydown, 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 byinteractionId, 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):
| Relationship | Why it must hold |
|---|---|
s2.longTasks > 0 and s2.tbt > 500 | microtask chunking does not chunk |
s2.filter ≥ 3× s0.filter | chunking overhead paid, no benefit received |
s3.longTasks == 0 and s4.longTasks == 0 | task-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 > 1000 | naive worker pays structured clone per keystroke |
s6.boundary < 20 | resident worker posts only a query string |
s6.inpMax is the lowest of all seven | correct worker design wins outright |
s5.inpMax > s0.inpMax | the 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.mdF1–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.mdupdated: 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:
| Approach | What it does to the task graph |
|---|---|
| Progressive / selective hydration | splits one long task into many, prioritised by interaction likelihood |
| Islands | eliminates hydration for subtrees that never needed it |
| Server Components | moves component work off the client entirely; nothing to hydrate |
| Streaming SSR | overlaps 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:
| Fix | Guarantees | Fails when |
|---|---|---|
AbortController cancellation | request is actually cancelled client-side | server already committed a side effect |
| Sequence number / last-write-wins | correct rendering | wasted work; server still processed both |
| Render-time guard (compare response to current state) | correct rendering, simplest | wasted 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 installinsrc/ - 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>
- Log order when a human clicks the button.
- 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. - When does the button actually turn red — relative to which log line?
- D vs E: which fires first? Separate what the specification guarantees from what Chrome will typically do, and name the condition that flips it.
- 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 installinsrc/
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:
- 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.
- This is the threading rule behind "animate
transform, notleft". It is not a style preference — it decides which thread owns the animation. The same mechanism explains why one non-passivewheellistener hands your scroll performance to your worst long task: the compositor must ask the main thread whetherpreventDefault()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.offsetHeightthere)
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.
- Find the red triangle forced-reflow warning. Read the stack it blames.
- 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."
- 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 installinsrc/
Predict first
Seven implementations of a filter over 200,000 rows, driven by 8 real keystrokes at 6× CPU throttle:
| strategy | |
|---|---|
| s0 | fully synchronous handler |
| s1 | async handler, unchunked |
| s2 | chunked with await Promise.resolve() |
| s3 | chunked with MessageChannel, 5 ms slices |
| s4 | chunked with scheduler.yield() |
| s5 | Web Worker, dataset posted per keystroke |
| s6 | Web 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:
- 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."
- Find the dataset size at which s5 stops losing to s0. Use
ROWS=to search for it. What dominates below the crossover? - Change the s3 slice budget from 5 ms to 50 ms, then to 0.5 ms. Both are worse, for different reasons. Name both.
- 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
-
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.
-
Name the violated invariant in one sentence.
-
Implement three fixes:
AbortControllercancellation- a request sequence number (last-write-wins on issue order)
- a render-time guard comparing the response's query against current state
-
Rank them and defend the ranking. All three "work" for a read.
-
Which is correct when the request has a server-side effect? This question is the seam into
fe-17(races, cancellation, idempotency) andfe-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:
- What do you investigate first?
- What evidence do you need?
- What hypotheses exist?
- What experiments distinguish them?
- What mitigation is appropriate?
- What permanent fix is appropriate?
- 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):
| Variant | Returned closure | Retained heap |
|---|---|---|
A — nothing else references big | () => 42 | 0.01 MB |
B — an unused, never-called sibling references big | () => 42 | 38.17 MB |
C — same as B, plus big = null before returning | () => 42 | 0.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
| Term | Definition |
|---|---|
| Reachability | Existence of any reference path from a GC root; the sole criterion for retention |
| GC root | Global object, stack, document tree, timers, listeners, pending microtasks |
| Retained size | Memory freed if this object became unreachable — the number that matters |
| Shallow size | Memory of the object itself, excluding what it references |
| Dominator | Node through which all paths to an object pass; removing it frees the subtree |
| Detached DOM | Node removed from the document but still referenced from JS |
detachedness | Per-node field in modern V8 heap snapshots: 0 unknown, 1 attached, 2 detached |
| Oilpan | Blink'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 |
| Context | V8's per-scope allocation holding captured variables shared by sibling closures |
WeakMap / WeakSet | Collections holding keys weakly; not enumerable, object keys only |
WeakRef / FinalizationRegistry | Explicit weak reference and post-collection callback; non-deterministic |
| Leak vs. cache | A 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
-
"
performance.memoryshows 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'sdetachednessflag, orperformance.measureUserAgentSpecificMemory(). -
"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.
-
"Setting the variable to
nullis cargo cult." Usually yes, and occasionally load-bearing — variant C above. The distinguishing question is whether a surviving closure shares the scope. -
"
WeakMapmakes memory concerns go away." It weakens the key, not the value. A value that references its own key creates a cycle aWeakMapcannot break. AndWeakMapis not enumerable, so the leak you do have becomes harder to see in a dump. -
"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.
-
"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.
-
"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.memorydoesn'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.
removeEventListenerrequires remembering a second call and keeping a reference to the exact function; anAbortControllersignal 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;useEffectteardown 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
- ECMAScript — WeakMap objects, WeakRef, FinalizationRegistry — note how little the spec guarantees about when anything is collected.
- WHATWG DOM —
AbortController/AbortSignalandaddEventListeneroptions — thesignaloption is the structural teardown primitive. - W3C —
measureUserAgentSpecificMemory()— the only in-page API spanning JS and DOM memory; requires cross-origin isolation.
Engine internals
- V8 blog — Trash talk: the Orinoco garbage collector — generational GC, scavenger vs mark-compact, why pause times behave as they do.
- V8 blog — Concurrent marking
- V8 — Memory analysis / heap snapshot format — node fields including
detachedness. - Chromium — Oilpan, Blink's GC — why DOM memory is not in the JS heap. Cross-track:
browser-framework-internals.md§8, §17.
Tooling
- Chrome DevTools — Memory panel reference — snapshot comparison, retainer paths, the three-snapshot technique.
- Chrome DevTools — Heap snapshot terminology — shallow vs retained size, dominators, distance.
- CDP — HeapProfiler domain —
collectGarbage,takeHeapSnapshot. memlab(Meta) — automated leak detection in CI; the closest production-grade version of experiment 5.
Analysis and practice
- web.dev — Fix memory problems
- Addy Osmani, JavaScript memory leaks and how to avoid them
- Nolan Lawson — Fixing memory leaks in web applications — unusually good on method rather than symptom lists, and on why the DevTools workflow is ordered the way it is.
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
| Shape | Root | Typical fix | Fix altitude |
|---|---|---|---|
| Detached DOM | a JS reference to a removed node | drop the reference on teardown | local |
| Listener | the long-lived event target | AbortController signal at registration | structural |
| Timer | the timer registry | clear on teardown; prefer AbortSignal-aware wrappers | structural |
| Side table | module-level Map/Set/registry | WeakMap, or an explicit eviction policy | architectural |
| Shared context | V8 Context shared by sibling closures | narrow the scope; null the binding | local, 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,
WeakMapimplements 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.
| slope | R² | Interpretation |
|---|---|---|
| high | high | real leak driven by the repeated operation |
| high | low | warming cache, lazy compilation, unsettled heap — not yet a finding |
| low | high | steady tiny growth; may be real but is not urgent — quantify against session length |
| low | low | noise |
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.
useEffectcleanup,ngOnDestroy,onUnmountedare 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
| Tool | Version used | Notes |
|---|---|---|
| Node.js | 23.11.0 | any ≥ 18 |
| playwright-core | 1.62.1 | ships no browsers; resolved via lib/browser.mjs |
| Chrome for Testing | 149.0.7827.55 (arm64) | detachedness snapshot field requires a modern V8 |
| Python | 3.13 | only 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 thedetachednessflag.
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.
| Instrument | Sees | Blind to | Use when |
|---|---|---|---|
performance.memory (Chrome, deprecated) | JS heap size | all DOM, workers, other frames | quick triage only; never as evidence |
Runtime.getHeapUsage (CDP) | JS heap used/total | same | scripted regression checks |
| Heap snapshot | JS heap + native/DOM nodes, detachedness, retainer paths | cross-process | the real tool; anything you will act on |
performance.measureUserAgentSpecificMemory() | whole renderer incl. DOM | needs COOP+COEP | production RUM, when you can isolate |
| DevTools Performance → Memory checkbox | heap over time, node/listener counts | no retainer paths | seeing 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:
- 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.
- 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.
- Snapshot 1. DevTools forces a GC before each snapshot, so you do not need to.
- Run the action N times (10–20).
- Snapshot 2.
- 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.
- Select an instance → Retainers panel. This is the finding. Walk the path to a root.
- Filter by
Detachedin 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-01link: 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.
| Variant | Returned closure | Retained heap |
|---|---|---|
A — no sibling references big | () => 42 | 0.01 MB |
B — unused, never-called sibling references big | () => 42 | 38.17 MB |
C — sibling present, plus big = null | () => 42 | 0.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.
| Stage | detached nodes | detached bytes | JS heap |
|---|---|---|---|
| built + attached | 0 | 0.0 KB | 0.84 MB |
| removed from DOM, JS refs held | 5000 | 507.8 KB | 0.84 MB |
| JS refs released | 0 | 0.0 KB | 0.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).
| Variant | retained JS heap | detached nodes |
|---|---|---|
| anonymous listener, never removed | 23.00 MB | 300 |
removeEventListener in destroy() | 0.08 MB | 0 |
AbortController signal + abort() | 0.09 MB | 0 |
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 table | retained JS heap | detached nodes still alive |
|---|---|---|
Map | 38.34 MB | 2000 |
WeakMap | 0.09 MB | 0 |
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:
| slope | R² | verdict | |
|---|---|---|---|
| clean | 0.001 MB/cycle | 0.742 | correlated noise at zero magnitude |
| leaky | 1.836 MB/cycle | 1.000 | linear growth driven by a repeated operation |
Rules this encodes:
- Force GC between cycles, or you are measuring GC scheduling, not retention.
- Use identical repeated cycles, or slope is meaningless.
- 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:
- the object class that grew,
- the retainer path from a GC root,
- the single edge you would break to fix it,
- 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.mdanswered 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.mdupdated with any prediction you got wrong -
A soak-test sketch: what you would assert, at what cadence, in CI (
fe-33picks 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:
| Policy | Mechanism | When it is right |
|---|---|---|
| key lifetime | WeakMap | entry is meaningful only while the key object lives |
| time | TTL / staleTime + gcTime | server data with a known freshness window |
| count | LRU | bounded memory is the hard constraint |
| event | clear on logout / navigation / tenant switch | correctness 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-01completecd src && npm install(or the symlinkednode_modulesfrom 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:
- "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.
x = nullis 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.- 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.
- After removal, are the nodes collectable?
- How much will the JS heap change when the array is finally cleared?
- Would
performance.memoryreveal 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:
| Instrument | Sees detached DOM? |
|---|---|
performance.memory / Runtime.getHeapUsage | No |
Heap snapshot (detachedness field) | Yes — exact counts |
performance.measureUserAgentSpecificMemory() | Yes, but needs COOP+COEP |
DevTools Memory panel, filter Detached | Yes — 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:
- 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. - Not enumerable. No
.size, no iteration, no dump during 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 asserting an entry was freed — only that growth is bounded, which is a weaker claim.
- It implements one policy: "when the key dies". If entries should expire on logout, on
navigation, or after 30 seconds,
WeakMapimplements none of that.
The decision rule:
| The entry should die when… | Use |
|---|---|
| its key object dies | WeakMap |
| a fixed time passes | TTL cache |
| memory is the binding constraint | LRU |
| 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
- Establish the repeated action. Navigating between routes. It is idempotent: after one navigation the app is in the same logical state.
- 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.
- Snapshot 1. DevTools → Memory → Heap snapshot. It forces a GC first, so you do not need to.
- Click Navigate ×50.
- Snapshot 2.
- 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.
- Select an instance → Retainers panel. Walk the path to a root. This is the finding.
- Filter the class list by
Detachedto isolate detached DOM directly.
Deliverable
For each of the four leaks, write down:
| # | Class that grew | Retainer path from a GC root | Edge to break | Local 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":
- What do you investigate first?
- What evidence do you need?
- What hypotheses exist? — include at least one that is not a leak
- What experiments distinguish them?
- What mitigation is appropriate? — before the fix ships
- What permanent fix is appropriate?
- 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.
- What slope (MB/cycle) do you expect for each?
- What R² 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:
| slope | R² | Interpretation |
|---|---|---|
| high | high | real leak driven by the repeated operation |
| high | low | warming / lazy compilation / unsettled heap — not yet a finding |
| low | high | steady tiny growth; quantify against session length before acting |
| low | low | noise |
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:
- Force GC between cycles, or you are measuring GC scheduling, not retention.
- Use identical repeated cycles, or slope is meaningless.
- 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:
- Which repeated action would you soak? Why that one?
- What is your budget in bytes per cycle, and how did you derive it from an assumed session length rather than from taste?
- Where does it run — and why not in PR CI?
- What does it assert besides slope? (Detached counts are exact; byte counts are not.)
- 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
| Effect | Measured | Notes |
|---|---|---|
| Megamorphic vs monomorphic read | 1.9× (3.8 ns vs 0.9 ns) | ratio looks alarming, absolute is 2.9 ns |
| Alternating property order | 1.14× | far smaller than folklore suggests |
delete o.tmp → dictionary mode | 11.9× | the one that genuinely bites |
o.tmp = undefined instead | 0.98× | keeps the shape; free |
| Array element kinds | 1.70× spread | ~1 ns/element; ordering counterintuitive |
4. Core terminology
| Term | Definition |
|---|---|
| Hidden class / Map / Shape | V8's internal descriptor of an object's layout |
| Transition chain | The path of shapes produced by adding properties in a given order |
| Inline cache (IC) | Per-site cache of shape → property offset |
| Monomorphic / polymorphic / megamorphic | 1 / 2–4 / 5+ shapes observed at one access site |
| Stub cache | The global hash table a megamorphic site falls back to |
| Dictionary mode | Hash-map storage after delete or too many properties; no shape, no IC |
| Element kind | Array storage specialisation: PACKED_SMI, PACKED_DOUBLE, PACKED_ELEMENTS, and holey variants |
| SMI | Small integer; a tagged 31-bit value stored without heap allocation |
| Deoptimisation | Bailing from optimised code back to the interpreter when speculation fails |
| Ignition / Sparkplug / Maglev / TurboFan | V8's interpreter and three compiler tiers |
| OSR | On-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
-
"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.
-
"
deleteis fine, it's just a keyword." The one folklore item that holds: 11.9×, from dictionary mode.o.tmp = undefinedmeasured 0.98×. If you must remove keys from hot objects, rebuild the object or use aMap. -
"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.
-
"
Mapis always slower than a plain object."Mapis 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. -
"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.
-
"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."
- "
Mapversus 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 aMapdoesn'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) andfe-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
- V8 — Hidden classes and inline caches (blog) — the canonical explanation of shapes and transition chains.
- Mathias Bynens, JavaScript engine fundamentals: Shapes and Inline Caches — the best single article on this topic.
- Mathias Bynens, Elements kinds in V8 — element-kind lattice and one-way transitions.
- V8 — Sparkplug, Maglev, TurboFan — the tiering story.
- V8 — Ignition interpreter
Measurement method
- Chrome for Developers — Performance panel reference
tachometer— statistically rigorous browser microbenchmarking, if you need real confidence intervals rather than medians.- Aleksey Shipilëv, JMH and the anatomy of a microbenchmark — JVM-focused and the best writing anywhere on why microbenchmarks lie. Every failure mode in
docs/measured-results.mdappears here first.
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.
| Situation | Why the arithmetic changes | Example |
|---|---|---|
| Per-element cost × 10⁵–10⁷ | 3 ns becomes 30 ms | virtualized table over 10M cells (fe-30) |
| Per-frame budget of 16.7 ms | small constants recur 60×/s | canvas/WebGL render loop, physics |
| Hot library internals | your users' users pay it | reconciler, parser, diff, state store |
| Worker transforms | dominated by data representation | columnar processing, structured clone (fe-28) |
| Data representation decisions | one-way and expensive to reverse | array-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.
| Use | Better choice | Why |
|---|---|---|
| Fixed, known keys | plain object | stable shape, monomorphic ICs |
| Dynamic keys from data | Map | no shape churn, no dictionary-mode cliff |
| Keys added and deleted | Map | delete on an object costs 11.9× |
| Non-string keys | Map | objects coerce keys to strings |
| Needs size / iteration order | Map | .size is O(1); insertion order guaranteed |
| Keyed by object lifetime | WeakMap | see 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
| Tool | Version used |
|---|---|
| Node.js | 23.11.0 |
| playwright-core | 1.62.1 |
| Chrome for Testing | 149.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:
| Failure | Mitigation |
|---|---|
| Dead-code elimination removes the work | every benchmark returns a value, accumulated into window.__sink |
| Cold JIT dominates the first sample | 3 warm-up runs before measurement |
| Single-sample noise | median 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
'
| Flag | Shows |
|---|---|
--allow-natives-syntax | enables %HaveSameMap, %HasFastProperties, %DebugPrint |
--trace-ic | every IC state transition (very verbose) |
--trace-deopt | deoptimisations and their reasons |
--trace-opt | which functions get optimised, and when |
--print-opt-code | generated 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:
| Signal | Where | Meaning |
|---|---|---|
| Large "Scripting" blocks | flame chart | worth investigating — but usually algorithmic, not shapes |
| Forced reflow warnings | red triangle | 4,200 ns each. Fix these first |
Long JSON.parse / Recalculate Style | flame chart | ~400,000 ns and up. Fix these before anything |
| GC blocks | Memory track | see 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
deleteon objects in hot paths - Numeric arrays that stay numeric
Mapused 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
deletein 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_DOUBLEresult 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 shapes | IC state | median ms | ns/read | vs monomorphic |
|---|---|---|---|---|
| 1 | monomorphic | 5.30 | 1.767 | 1.00× |
| 2 | polymorphic | 5.30 | 1.767 | 1.00× |
| 4 | polymorphic | 6.50 | 2.167 | 1.23× |
| 8 | megamorphic | 10.40 | 3.467 | 1.96× |
| 16 | megamorphic | 10.30 | 3.433 | 1.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
| variant | median ms | vs A |
|---|---|---|
| A — same property order | 5.60 | 1.00× |
| B — alternating property order | 6.40 | 1.14× |
C — delete o.tmp (dictionary mode) | 66.80 | 11.93× |
D — o.tmp = undefined instead | 5.50 | 0.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 kind | median ms | ns/elem | vs SMI |
|---|---|---|---|
PACKED_SMI — all small integers | 7.80 | 1.560 | 1.00× |
PACKED_DOUBLE — all doubles | 4.70 | 0.940 | 0.60× |
PACKED_ELEMENTS — 1% nulls | 7.70 | 1.540 | 0.99× |
HOLEY_SMI — 1% holes | 8.00 | 1.600 | 1.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
| operation | ns each |
|---|---|
| monomorphic property read | 0.9 |
| megamorphic property read | 3.8 |
createElement + textContent + appendChild | 360 |
addEventListener + removeEventListener | 130 |
style write + getBoundingClientRect (forced layout) | 4,200 |
JSON.parse of a ~200 KB API response | 396,000 |
Megamorphic penalty: 2.9 ns per read.
Property reads you must de-megamorphise to save the cost of one:
| operation | equivalent reads |
|---|---|
addEventListener + removeEventListener | 45 |
| create + append one element | 124 |
| one forced layout | 1,448 |
one JSON.parse of a 200 KB response | 136,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
-
Shared IC state across cases. One page for all four element kinds meant the shared
sumaccumulated 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. -
DOM contamination between cases.
createAppendleft 5,000–20,000 children in the host element, so every subsequentgetBoundingClientRectmeasured 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. -
Accumulator type transition. Summing raw
iover 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.mdanswered 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_DOUBLEresult 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:
| Claim | Ratio offered | What to ask |
|---|---|---|
| "This library is 3× faster" | 3× | 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 → 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.
- 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+appendChildaddEventListener+removeEventListener- one style write +
getBoundingClientRect(forced layout) JSON.parseof 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:
- What is the operation count?
- What is the absolute per-operation saving?
- 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 nodes | named | landmarks | headings | interactive | table roles | focusable | markup | |
|---|---|---|---|---|---|---|---|---|
| semantic HTML | 56 | 38 | 4 | 2 | 3 | 7 | 3 | 1.00× |
| div soup | 46 | 24 | 0 | 0 | 0 | 0 | 0 | 1.38× |
| div + ARIA | 48 | 33 | 4 | 2 | 3 | 7 | 3 | 1.69× |
Measured: dialog behaviours
| behaviour | <dialog> | 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 bytes | 222 | 241 | 1200 (5.4×) |
4. Core terminology
| Term | Definition |
|---|---|
| Accessibility tree | The browser-computed tree exposed to assistive technology; derived from DOM + CSS + ARIA |
| Accessible name | The computed label for an element (from content, aria-label, <label>, etc.) |
| Landmark | A navigable region: banner, navigation, main, contentinfo, complementary |
| Implicit role | The ARIA role an element has by virtue of being that element |
| Progressive enhancement | Working baseline in HTML, improved by CSS and JS rather than depending on them |
<dialog> / showModal() | Native modal with focus trap, inertness, ::backdrop, Escape handling |
inert | Attribute removing a subtree from focus, hit-testing and the accessibility tree |
| Popover API | popover + popovertarget: top-layer, light-dismiss, no JS positioning state |
| Constraint validation | Native required/type/pattern/min/max/minlength + ValidityState |
| Dirty value flag | HTML-spec flag set when the user edits a field; gates minlength/maxlength |
:user-invalid | Matches only after user interaction — the selector you usually want, not :invalid |
| Top layer | Rendering layer above all content, used by modal dialogs and popovers; escapes clipping and z-index |
| Shadow DOM | Encapsulated 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
-
"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.
-
"ARIA makes divs accessible." It makes them announced. Measured: the ARIA variant matched semantic HTML on roles and landmarks but required manual
tabindexfor focus, and still lacks Enter/Space activation,disabledsemantics, and form participation. -
"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. -
"Native form validation isn't good enough." It correctly enforced
type=email,type=url,pattern, andmin/max, blocked submission, and exposed preciseValidityStateflags — 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. -
"
:invalidis the styling hook.":invalidmatches before the user has typed anything, so an empty required field is styled as an error on first paint.:user-invalidis the one you want, and it is supported. -
"Setting
.valuein a test is equivalent to typing." Measured false, and it matters:minlengthreportedvalid=true, tooShort=falsefor a programmatically-set short value andvalid=false, tooShort=truefor the same string typed.minlength/maxlengthare gated on the spec's dirty value flag. A test that sets.valuepasses 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:
minlengthonly applies to user-edited values because of the dirty value flag. So a test that sets.valuedirectly never seestooShortand 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
- WHATWG HTML Standard — the source. Particularly §4.10 Forms, constraint validation, and the dirty value flag (search that term — it explains experiment 3's trap).
- WHATWG HTML —
dialog— modality, top layer, focus behaviour. - HTML — Popover API
- WAI-ARIA 1.2 and ARIA in HTML — which ARIA is allowed on which element.
- Accessible Name and Description Computation — how the
namecolumn in experiment 1 is derived. - HTML AAM — the normative HTML-element → accessibility-API mapping.
Practice
- ARIA Authoring Practices Guide — patterns for controls with no native equivalent. Read the keyboard interaction sections before committing to a custom control; that list is what you are agreeing to own.
- The First Rule of ARIA Use
- MDN — Client-side form validation
- MDN —
ValidityState - Adrian Roselli, Under-Engineered series — restyling native controls instead of replacing them; unusually rigorous and tested with real AT.
- Scott O'Hara, component write-ups — especially
<dialog>and disclosure widgets, including where native still falls short. - Inclusive Components — Heydon Pickering; the reasoning behind each pattern.
Tooling
- Chrome DevTools — Accessibility features — the Accessibility pane and full-page tree.
- CDP — Accessibility domain —
getFullAXTree, used by experiment 1. - NVDA (free, Windows) · VoiceOver (built into macOS, ⌘F5)
- axe-core — useful as a regression guard; see
docs/observation.mdon its limits.
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:
| Situation | Why native loses | What you are accepting |
|---|---|---|
| Design requires styling the browser refuses | ::backdrop, <select> internals, validation bubbles | permanent behaviour maintenance |
| Behaviour genuinely differs | multi-select combobox, virtualised listbox, rich text | you now own a WAI-ARIA pattern end to end |
| Cross-browser inconsistency is unacceptable | date/time inputs vary widely between engines | you own date semantics, i18n and keyboard |
| The element does not exist | tree grid, split button, complex data grid | the 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:
| Want | Modern option |
|---|---|
| styled modal backdrop | dialog::backdrop |
| light-dismiss overlays without JS state | Popover API (popover, popovertarget) |
styled <select> | appearance: base-select (emerging; check support before relying) |
| styled checkbox/radio | accent-color, or appearance:none + custom visuals on the real input |
| positioned anchored UI | CSS 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:
| Failure | Native-first form | JS-validation form |
|---|---|---|
| Bundle fails to load | works, validates, submits | inert inputs, no submit |
| JS throws during hydration | works | broken, often silently |
| Slow network, JS pending | usable immediately | visible but non-functional |
| Extension/CSP blocks a script | works | broken |
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 styleddiv, 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-expandedon 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::partexposure, 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
| Tool | Version used |
|---|---|
| Node.js | 23.11.0 |
| playwright-core | 1.62.1 |
| Chrome for Testing | 149.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 serve → http://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:
| Approach | Problem |
|---|---|
| Reading the markup | tells you what you wrote, not what the browser computed |
axe/lighthouse | finds violations of specific rules; does not show the tree |
| DevTools Accessibility pane | shows one node at a time, manually |
Accessibility.getFullAXTree | the 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.
::backdropis 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 withpage.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:
- Landmarks. Screen-reader users navigate by region first. No landmarks means no navigation.
- Headings and their levels. The second navigation mode. Skipped levels and
role=headingwithoutaria-levelboth break it. - The controls list. Anything interactive that is not in it is invisible to assistive tech —
regardless of how it looks or what
onclickit has.
Keyboard-only testing, which finds more than any tool
Unplug the mouse for ten minutes on a page you own:
| Check | Failure signal |
|---|---|
| Tab through the whole page | focus disappears, or lands on nothing visible |
| Visible focus indicator everywhere | outline: none with no replacement |
| Enter and Space on every control | works on <button>, silently fails on div role="button" |
| Escape closes overlays | custom modals routinely miss this — measured 1/7 |
| Focus returns after closing an overlay | focus lost to <body>; screen reader restarts at the top |
| Tab cannot escape an open modal | background 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:
| Platform | Reader | Start |
|---|---|---|
| macOS | VoiceOver | ⌘F5; VO = Ctrl+Option; rotor = VO+U |
| Windows | NVDA (free) | Insert = NVDA key; elements list = NVDA+F7 |
| Windows | JAWS | still 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 find | Automated tools miss |
|---|---|
missing alt | alt that describes the wrong thing |
| contrast ratios | focus order that makes no sense |
| duplicate ids, invalid ARIA | a div that should have been a button |
| missing form labels | error messages not associated with fields |
| missing lang | keyboard 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
| 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 |
Landmarks exposed:
- semantic —
banner, navigation, main, contentinfo - div soup — (none)
- div + ARIA —
banner, navigation, main, contentinfo
Markup size, normalised:
| variant | bytes | vs semantic |
|---|---|---|
| semantic HTML | 459 | 1.00× |
| div soup | 634 | 1.38× |
| div + ARIA | 778 | 1.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, naive | custom, careful |
|---|---|---|---|
| opens | yes | yes | yes |
| focus moves in | yes | NO | yes |
| focus trapped (8 × Tab) | yes | NO | yes |
| background inert | yes | NO | yes |
| Escape closes | yes | NO | yes |
| focus restored to trigger | yes | NO | yes |
::backdrop available | yes | NO | NO |
| implementation | JS bytes | vs native |
|---|---|---|
<dialog> showModal() | 222 | 1.0× |
| custom, naive | 241 | 1.1× |
| custom, careful | 1200 | 5.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
| field | constraint | value | valid | ValidityState flag |
|---|---|---|---|---|
type=email | "not-an-email" | NO | typeMismatch | |
type=email | "a@b.co" | yes | — | |
| age | min=18 | "9" | NO | rangeUnderflow |
| age | min=18 | "30" | yes | — |
| code | pattern | "abc-12" | NO | patternMismatch |
| code | pattern | "ABC-1234" | yes | — |
| site | type=url | "nope" | NO | typeMismatch |
| site | type=url | "https://x.dev" | yes | — |
- submission blocked while invalid: yes
- submission allowed once valid: yes
:invalidmatches: yes:user-invalidsupported: yes — and it is the one you want
The dirty-value trap
| how the value was set | valid | tooShort |
|---|---|---|
el.value = 'short' (programmatic) | true | false |
user types "short" (real keystrokes) | false | true |
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-describedbywiring 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.mdanswered 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:
- What element does each primitive actually render? (
asprops make this non-obvious) - Are native behaviours preserved, or reimplemented?
- Is there a behavioural test suite — focus, keyboard, Escape — or only visual regression?
- 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:
| Objection | Platform answer |
|---|---|
| can't style the modal backdrop | dialog::backdrop |
| need light-dismiss without JS state | Popover API |
can't style <select> | appearance: base-select (emerging) |
| can't position anchored UI | CSS Anchor Positioning |
| can't style checkboxes | accent-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):
| Failure | Native-first | JS-dependent |
|---|---|---|
| bundle fails / CDN blocked | works | inert |
| hydration throws | works | broken, often silently |
| slow network | usable immediately | visible but non-functional |
| CSP or extension blocks a script | works | broken |
"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)
disabledsemantics- 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.
- Which constraints are enforced?
- Is submission blocked while invalid?
- Does
el.validity.tooShortbecome true when aminlength=10field 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 ValidityState — typeMismatch, 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
.valuedirectly never seestooShort, 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-describedbywiring 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 / menu | popover + popovertarget, or <select> |
| custom accordion | <details> / <summary> |
| custom tooltip | popover + CSS anchor positioning |
| custom checkbox/radio | real <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 nodes | initial style | initial layout | |
|---|---|---|---|
| no containment | 261,011 | 99.5 ms | 401.9 ms |
content-visibility: auto | 261,011 | 10.9 ms | 7.9 ms |
contain: strict | 261,011 | 89.6 ms | 398.4 ms |
| mutation inside one of 500 cards | layout time | vs none |
|---|---|---|
| no containment | 12.7 ms | — |
contain: layout | 10.5 ms | −18% |
contain: strict + fixed height | 2.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
| Term | Definition |
|---|---|
| Cascade | The algorithm selecting a winning declaration: origin/importance → layer → specificity → source order |
| Cascade layer | @layer group; later layers win regardless of specificity |
| Unlayered styles | Form 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 |
| Containment | Promise that limits how far a change can propagate |
content-visibility: auto | Skip rendering for offscreen subtrees; implies contain: layout style paint |
contain-intrinsic-size | Placeholder size for skipped subtrees; prevents scrollbar jumping |
| Style recalculation | Matching selectors and computing values |
| Layout / reflow | Computing geometry |
| Stacking context | Isolated z-ordering scope, created by transform, opacity < 1, will-change, etc. |
| Logical property | Flow-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
-
"Large DOM is inherently slow." Measured false. 261,011 nodes rendered in 7.9 ms of layout with
content-visibility: autoand 401.9 ms without. The node count was identical. -
"
containmakes things faster." It bounds propagation. Measured:contain: strictgave no meaningful initial-render improvement, and 80% on the update path. Wrong tool → no result → wrong conclusion. -
"Specificity determines the winner." Only within a layer. Measured:
0,1,0in a later layer beat1,1,0in an earlier one. And unlayered styles beat everything layered. -
"
!importantis always last resort but at least it's predictable." Measured: author!importantreverses layer order, so the earliest layer wins. A design system's!importantdefeats the application's. -
"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.
-
"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." - "
containandcontent-visibilitysolve different problems and teams conflate them.containbounds 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
!importantwar structurally rather than by convention." - "The catch is
!importantinverts layer order — so a design system using!importantfor 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: sizeandcontain-intrinsic-sizeare the bridge.fe-11(performance) — style and layout are thepresentationterm of INP.fe-26(i18n) — logical properties are the difference between an RTL-capable stylesheet and a rewrite.fe-30(large-scale UI) —content-visibilityis 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
- CSS Cascading and Inheritance Level 5 — the normative cascade, including
@layer. §6 (Cascading) is the source for the resolution order verified in experiment 3. - CSS Containment Level 2 —
contain, and the normative definition of each containment type. - CSS Containment Level 3 —
content-visibility,contain-intrinsic-size, container queries. - CSS Custom Properties Level 1 — inheritance and computed-value-time resolution.
- CSS Properties and Values API (
@property) — typed custom properties; why registration is required for transitions. - CSS Logical Properties
Explanatory
- MDN — Cascade layers and Cascade, specificity and inheritance
- web.dev —
content-visibility— includes thecontain-intrinsic-sizecaveat that experiment 1 depends on. - web.dev — CSS containment
- Miriam Suzanne, Cascade Layers — by one of the spec editors; strongest treatment of layers as an ownership model.
- Una Kravets / Adam Argyle, State of CSS posts — for tracking capability changes that invalidate past decisions (fe-50).
- Josh Comeau — Stacking contexts — the z-index-stopped-working class of bug.
Tooling
- Chrome DevTools — Performance: style and layout
- CDP — Performance domain —
getMetrics, used throughout these experiments. - DevTools Rendering pane — paint flashing, layer borders, layout shift regions.
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.
| Question | Tool | Measured effect |
|---|---|---|
| "Most of this is offscreen — can the browser skip it?" | content-visibility: auto | 98% 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: paint | enables clipping optimisations, creates a containing block |
| "This box's size is independent of its content" | contain: size | the 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.
!importantinverts. Measured. A design system using!importantfor 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
:rootinvalidates 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
| Approach | Runtime cost | Scoping | Dynamic theming | Where it hurts |
|---|---|---|---|---|
| Global CSS + BEM | none | convention only | custom properties | discipline decays across teams |
| CSS Modules | none | build-time hashing | custom properties | composition across packages is awkward |
| Runtime CSS-in-JS | style injection + serialisation per render | automatic | trivial | SSR complexity, hydration cost, RSC friction |
| Zero-runtime CSS-in-JS | none (extracted) | automatic | limited to what is statically known | build complexity; dynamic values fall back to inline styles or custom properties |
| Atomic / utility-first | none | n/a (no cascade to manage) | custom properties | markup verbosity; ejecting is a rewrite |
Two observations that matter more than the table:
- 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.
- 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-changeoveruse. 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: paintall create one. A z-index that "stops working" is nearly always an ancestor that quietly became a stacking context — andcontain: 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
!importantcount over time; it only ever goes up. - Custom property invalidation on the root. Theme switching by rewriting
:rootvariables 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-leftis a rewrite when RTL arrives;margin-inline-startis 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: sizewith 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
| Tool | Version |
|---|---|
| Node.js | 23.11.0 · playwright-core 1.62.1 |
| Chrome for Testing | 149.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: autoresults 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
| Where | What it tells you |
|---|---|
| Performance → Recalculate Style blocks | how long selector matching + value computation took, and how many elements |
| Performance → Layout blocks | geometry cost; the "Nodes That Need Layout" line is the scope |
| Performance → red triangle | forced synchronous layout, with the causing stack (fe-01) |
| Rendering → Paint flashing | what actually repainted; large flashes on small changes mean bad containment |
| Rendering → Layer borders | compositor layers; a sea of borders means will-change overuse |
| Rendering → Layout Shift Regions | CLS contributors |
| Elements → Computed → the funnel icon | which rule won, and what it beat |
| Elements → Styles → strikethrough | overridden 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:
- Is most of it offscreen? If everything is visible there is nothing to skip.
- Can you estimate a size? Without
contain-intrinsic-sizethe scrollbar will be wrong and scroll position will jump. - Does anything inside need to be measured while offscreen? Sticky headers, IntersectionObserver targets and scroll-spy will behave differently.
- 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
!importantcount is flat or falling over time- Long lists carry
content-visibilityor are virtualized deliberately
Unhealthy:
- Recalculate Style blocks over ~10 ms on interaction — usually a
:rootcustom-property change invalidating everything, or a very expensive selector - Layout blocks touching most of the document for a local change — missing containment
will-changein a base component class rather than applied around an animation- Rising
!importantcount — 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-visibilityresult 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: paintbenefits are not captured byRecalcStyleDurationorLayoutDurationat 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)
| variant | DOM nodes | initial style | initial layout |
|---|---|---|---|
| no containment | 261,011 | 99.5 ms | 401.9 ms |
content-visibility: auto | 261,011 | 10.9 ms | 7.9 ms |
contain: strict | 261,011 | 89.6 ms | 398.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
| variant | style recalc | layout | layout count |
|---|---|---|---|
| no containment | 0.5 ms | 12.7 ms | 120 |
contain: layout | 0.5 ms | 10.5 ms (−18%) | 120 |
contain: strict + fixed height | 1.5 ms | 2.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
| case | winner | why |
|---|---|---|
| specificity only, no layers | RED (1,1,0) | highest specificity wins, as expected |
@layer reset, base, app — app rule 0,1,0, base rule 1,1,0 | BLUE (0,1,0) | later layer wins; specificity never consulted |
unlayered .btn vs layered #widget .btn.primary | BLUE (unlayered) | unlayered styles form an implicit final layer |
!important in an earlier layer | RED (earlier layer) | author !important reverses layer order |
Resolution order confirmed (first difference decides):
- origin + importance — author
!importantreverses layer order - layer order — later layer wins, beats specificity entirely
- specificity
- 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.getMetricsis cumulative, so every measurement is a delta around the operation, withrequestAnimationFrame×2 plus a forcedoffsetHeightto 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.logstrings 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:
| Symptom | Your 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.mdanswered -
content-visibilityapplied 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: auto | virtualization | |
|---|---|---|
| Code | one CSS declaration | scroll handling, measurement, windowing, key management |
| DOM | full (261,011 nodes, measured) | small |
| Memory | full DOM retained | bounded |
| Find-in-page | works | broken for unrendered rows |
| Sequential focus / a11y | works | needs care |
| Scroll restoration | browser handles it | you handle it |
| Measured initial layout | 7.9 ms vs 401.9 ms | comparable, 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:
| Question | Tool |
|---|---|
| "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?
#widget .btn { red }(1,1,0) vs.btn.primary { green }(0,2,0) — no layers@layer reset, base, app;with@layer app { .btn { blue } }(0,1,0) and@layer base { #widget .btn { red } }(1,1,0)@layer b { #widget .btn.primary { red } }vs an unlayered.btn { blue }@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:
- origin + importance
- layer order — later wins, and specificity is never consulted
- specificity
- 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
!importantinside 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 layout | relayout |
|---|---|---|
| block | 55.1 ms | 23.5 ms |
| inline-block | 55.7 ms | 9.5 ms |
| flex | 59.3 ms | 7.3 ms |
| grid | 62.5 ms | 14.7 ms |
| float | 75.2 ms | 36.2 ms |
| absolute | 75.5 ms | 9.8 ms |
Spread: 1.4×.
| responsive strategy (8,000 items) | initial layout | initial style |
|---|---|---|
| none | 31.8 ms | 4.3 ms |
@media | 31.8 ms | 4.8 ms |
@container | 33.2 ms | 7.3 ms |
| container narrowed to 400 px, viewport unchanged | padding |
|---|---|
@container | 8px → 0px — responded |
@media | 8px → 8px — ignored |
4. Core terminology
| Term | Definition |
|---|---|
| Formatting context | The layout algorithm governing a box's children (block, inline, flex, grid) |
| Intrinsic sizing | Sizing from content: min-content, max-content, fit-content |
min-content | Width of the largest unbreakable unit; requires measuring every word |
| Containing block | The ancestor box a percentage or absolute offset resolves against |
| Stacking context | Isolated z-ordering scope; created by transform, opacity<1, contain: paint, … |
| Container query | @container; matches against an ancestor with container-type |
container-type: inline-size | Enables inline-axis container queries; implies size containment on that axis |
| Container query unit | cqw, cqh, cqi, cqb — relative to the query container |
| Subgrid | Child grid inheriting its parent's tracks, so nested content can align across items |
aspect-ratio | Reserves space before content loads; a CLS tool (fe-11) |
| Gap | Spacing 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
-
"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).
-
"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).
-
"Intrinsic sizing is too expensive." 1.6× worst case, and only
min-contentis notably dearer.fit-contentandmax-contentmeasured within ~7% of a fixed width. -
"Container queries are expensive." +1.4 ms of layout across 8,000 items. The real cost is the containment constraint, not the query.
-
"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.
-
"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
@containerstyles and left@mediauntouched 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-contentis the one intrinsic keyword with a real cost, because the browser measures every unbreakable unit.fit-contentis 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-11—aspect-ratioand explicit sizing are the primary CLS tools.fe-26(i18n) — logical properties, and whygap/gridsurvive 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
- CSS Flexible Box Layout Level 1 — including §4.5, the
min-width: autobehaviour behind "my flex item won't shrink". - CSS Grid Layout Level 2 — grid plus
subgrid. - CSS Box Sizing Level 3 —
min-content,max-content,fit-content; the normative definitions behind experiment 2A. - CSS Containment Level 3 —
@container,container-type, container query units, and why containment is required. - CSS Display Level 3 — formatting contexts.
- CSS Positioned Layout Level 3 — containing blocks and stacking.
Explanatory
- MDN — CSS Grid and Flexbox guides — Rachel Andrew's material; the most reliable on sizing.
- web.dev — Container queries
- Ahmad Shadeed — Container Queries and Min, Max, Fit-content
- CSS Tricks — Complete Guide to Grid / Flexbox — reference tables, not architecture advice.
- web.dev — Optimize CLS — the layout-stability half of this module.
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:
| Need | Mode | Why |
|---|---|---|
| Two-dimensional relationships, alignment across rows and columns | grid | the only mode that expresses both axes; subgrid extends it to children |
| One-dimensional distribution, unknown item count | flex | gap, flex-wrap, and content-based sizing without magic numbers |
| Document flow, prose | block | cheapest, and what the content actually is |
| Overlay, tooltip, precise placement relative to a containing block | absolute | but measure — it was slowest here, with 2.4× the style cost |
| Legacy compatibility only | float | slowest 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:
- 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. - 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-contentwrapper, 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
@containerapply 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:
| Decision | Query |
|---|---|
| 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.
subgridis 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-ratioand 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.
cqwinside a component makes it depend on being inside a query container. If a consumer forgetscontainer-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, andcontain: paintcreates one;z-indexthen 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
| Tool | Version |
|---|---|
| Node.js 23.11.0 · playwright-core 1.62.1 | Chrome 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.getMetricsdeltas, as in fe-05, with a two-rAF+ forced-offsetHeightsettle before each sample. Style/layout happen after your JS returns; aperformance.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.widthand 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
| Where | Use |
|---|---|
| Elements → Layout pane | toggle grid/flex overlays; shows line numbers, areas, gaps |
| Grid overlay badges | the grid/flex badge next to an element in Elements |
| Performance → Layout blocks | duration and "Nodes That Need Layout: N of M" |
| Performance → red triangle | forced synchronous layout, with the causing stack |
| Rendering → Layout Shift Regions | what moved; the CLS diagnostic |
Elements → Computed → container | which 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:
- No container.
@containerwith no ancestor carryingcontainer-typenever matches. Check Computed →container-typeon ancestors. - 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. - Container sized by its contents.
container-type: inline-sizemeans the inline size may not depend on contents. Afit-contentor floated wrapper will change behaviour when it becomes a container — this reads as "addingcontainer-typebroke my layout", and it is the containment promise being enforced.
Debugging flex and grid
| Symptom | Cause |
|---|---|
| Flex item will not shrink below its content | min-width: auto → min-content. Fix: min-width: 0 |
| Grid item overflows its track | same, on min-width/min-height |
justify-content does nothing | items already fill the line; you probably want flex: 1 or justify-items |
| Gap ignored | not a flex/grid container, or using an old multicol context |
| Nested content will not align across cards | needs 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.
RecalcStyleDurationandLayoutDurationonly. - 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
| mode | initial layout | initial style | relayout (width change) |
|---|---|---|---|
| block | 55.1 ms | 11.7 ms | 23.5 ms |
| inline-block | 55.7 ms | 11.3 ms | 9.5 ms |
| flex | 59.3 ms | 11.3 ms | 7.3 ms |
| grid | 62.5 ms | 10.5 ms | 14.7 ms |
| float | 75.2 ms | 11.0 ms | 36.2 ms |
| absolute | 75.5 ms | 27.7 ms | 9.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/topdeclarations. "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
width | initial layout | relayout |
|---|---|---|
200px | 33.1 ms | 1.5 ms |
20% | 31.9 ms | 14.6 ms |
min-content | 52.1 ms | 2.1 ms |
max-content | 35.4 ms | 1.4 ms |
fit-content | 35.2 ms | 1.7 ms |
auto | 33.9 ms | 9.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
| strategy | initial layout | initial style | relayout |
|---|---|---|---|
| none | 31.8 ms | 4.3 ms | 9.1 ms |
@media | 31.8 ms | 4.8 ms | 9.7 ms |
@container | 33.2 ms | 7.3 ms | 9.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 before | padding after | |
|---|---|---|
@container (min-width: 700px) | 8px | 0px — responded |
@media (min-width: 700px) | 8px | 8px — 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:
- Which element carries
container-type, and why not the card root? - What happens if a consumer forgets it?
- What sizing behaviour did you give up by adding it?
- When would
@mediastill 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.mdanswered - 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:
| Cause | Fix, at design time |
|---|---|
| images without dimensions | width/height attributes, or aspect-ratio |
| ads/embeds of unknown size | reserve with min-height or aspect-ratio |
| web fonts swapping metrics | size-adjust, ascent-override on @font-face |
| content injected above the fold | reserve space, or inject below |
content-visibility without contain-intrinsic-size | always 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:
| Module | Folklore | Measured |
|---|---|---|
| fe-03 | property order is the hidden-class hazard | 1.14×; delete is 11.9× |
| fe-04 | semantic HTML is verbose | div soup is 1.38× larger |
| fe-06 | flex/grid are slow, absolute is fast | 1.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 mode | 1.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 "addingcontainer-typebroke 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
| Decision | Query |
|---|---|
| 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 setup | Build setup | |
|---|---|---|
| When | day 1 | needed by Phase 3 |
| Cost | zero | ~100 GB, hours, a current toolchain |
| Unlocks | source reading, archaeology, spec mapping, tracing | native debugging, tests, WPT, contribution |
| Tools | Code Search, checkout + git grep/git log -S, DevTools, Perfetto | depot_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
| Module | Covers spec areas | Phase | Build needed |
|---|---|---|---|
bi-01-navigating-chromium | §2, §25, §44 | 0 | no |
bi-02-architecture-process-model | §2, §18, §19 | 1 | no |
bi-03-html-parsing | §6, §7 | 1 | no |
bi-04-dom-internals | §8 | 1 | no |
bi-05-bindings-v8 | §9, §17 | 1 | no |
bi-06-chromium-cpp | §4 | 2 (JIT) | no |
bi-07-css-engine | §10, §11 | 2 | no |
bi-08-layout | §12, §13 | 3 | helpful |
bi-09-paint | §14 | 3 | helpful |
bi-10-compositor-gpu | §15 | 4 | helpful |
bi-11-scheduling | §16 | 4 | helpful |
bi-12-debugging-tracing | §20, §21 | 4 | yes |
bi-13-tests-wpt | §22, §23 | 6 | yes |
bi-14-contribution | §24, §49 | 6 | yes |
fw-01…fw-12 | §27–§42 | 1–5, parallel | no |
bi-15-vertical-traces | §43 | join | partly |
bi-16-capstone | §46–§48, §50 | 7 | yes |
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 module | This track | Direction |
|---|---|---|
fe-01 execution model | bi-11 scheduling | theirs first — the JS-observable model before the Blink implementation |
| their rendering pipeline | bi-08–bi-10 | theirs first, shallow; this track supplies the mechanism |
| their React/Fiber module | fw-02/fw-04 mini-react | this track first — derive before reading |
| their state-management module | fw-01, fw-03 | this 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)
| What | Predicted | Actual | Gap 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:
| Item | Size | Where |
|---|---|---|
src + .git (full history) | ~80–100 GB | ~/chromium/src |
| git cache mirror | ~24 GB | ~/Library/Caches/depot_tools/git_cache ← measured; 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
srcis synced it can be deleted; the cost is that a futurefetchof a second checkout loses its speedup, and the nextgclient syncre-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
Recommended args.gn for this track
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 = trueis 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:0is fastest but stack traces become useless addresses.1gives you function names and line numbers — enough for almost all of this track.2gives full type info for variable inspection, and costs a lot of disk.blink_symbol_level = 2is the compromise: full fidelity exactly where you'll be setting breakpoints.dcheck_always_on = truekeepsDCHECKs 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 = trueis deliberately not recommended as your default. It is far slower to build and to run, and its main benefit (assertions) you already have fromdcheck_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 -nover 30M lines - faster than Code Search, and it sees generated inputsgit log -S'...'archaeology (section 25) - the most valuable local capability- reading
docs/,DEPS,OWNERS,.json5,.idl,.mojomin-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
| Option | Cost | Consequence | |
|---|---|---|---|
| A | Upgrade 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. |
| B | Check out a revision from before 2026-05-13 and gclient sync | another long sync; ~15 months of source drift | Builds today, but local source no longer matches Code Search or these modules - actively confusing while learning, and it forecloses section 24 contribution. |
| C | Defer the build; work from the checkout + stock Chrome | none | Zero 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.
- 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 modulemapshowedDarwinFoundation.modulemapbut no numbered variants — they exist only in newer SDKs. xcodebuild -version→ Xcode 16.2, SDK 15.2.grep mac_sdk_official_version build/config/mac/mac_sdk.gni→26.5. Chromium trunk had moved to the macOS 26 SDK.- Who wants those files?
grep -rn DarwinFoundation1 build/ buildtools/→buildtools/third_party/libc++/modules.gni, insideif (use_clang_modules). - 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 assertsxcode_version_int >= 2600anyway, so both branches require Xcode 26. - Is there an escape?
grep -n "use_clang_modules =" build/config/c++/c++.gnishowed it inside adeclare_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 generror naming a nonexistent SDK file is a toolchain-version problem. Checkmac_sdk_official_versionagainstxcrun --show-sdk-versionbefore 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/**/*.gniand look for the enclosingdeclare_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_shelloverchromefor 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,chromeis 90 % irrelevant code.
Navigating the build graph
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:
| Flag | Use |
|---|---|
--user-data-dir=<path> | isolate profile — always |
--renderer-startup-dialog | pause each renderer at startup so you can attach |
--disable-hang-monitor | stop Chrome killing a renderer you're stopped in |
--enable-blink-features=Foo | turn on a runtime-enabled feature by name |
--disable-blink-features=Foo | ...and off, to A/B a behaviour |
--enable-logging=stderr --v=1 | see LOG()/VLOG() output |
--single-process | one process — convenient, frequently broken, never trust it for behaviour |
--no-sandbox | last 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:
- You attached to the browser process, not a renderer.
- The function was inlined. Set the breakpoint on the caller, or build that file with less optimisation.
- 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:
- DevTools → Performance. Curated view, JS-centric, good for main-thread work.
- Perfetto UI (
ui.perfetto.dev), orchrome://tracingin older builds. All categories, all processes, all threads. This is where you see the compositor thread, the GPU process, and cross-process flow arrows. - 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,toplevelanddevtools.timelinecategories 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_EVENTmacros, 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:
is_component_build = true— do not build without it.- Build the smallest target that answers your question:
content_shelloverchrome; a single unit-test target overblink_tests. - Use
gn refsto learn which targets a file belongs to, and build only those. symbol_level = 1+blink_symbol_level = 2rather thansymbol_level = 2everywhere.- ccache, if you frequently switch branches. Little benefit for linear work.
- Do not run
gclient syncmore 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.gnvalues still valid (gn args out/Default --list) -
blink_tests,content_shelltarget names still current (gn ls) -
run_web_tests.pypath still current -
TestExpectationspath still current -
docs/lldbinit.mdcontents still match what you put in~/.lldbinit -
--renderer-startup-dialogstill 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.
| Rung | Tool | Cost | Answers |
|---|---|---|---|
| 1 | Code Search (source.chromium.org) | seconds | Where is it? Who calls it? When did it change? |
| 2 | Local grep on a checkout | seconds | Same, plus generated files, plus git log -S |
| 3 | Tracing (DevTools → Perfetto) | minutes | Does this code even run? On which thread? How often? |
| 4 | Local build + logging | ~minutes/iteration | What are the actual values? |
| 5 | Debugger (lldb) | slow, high value | Exact 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.
- Which process? Browser, renderer, GPU, network service, or utility.
- Which thread? Main, compositor, raster, IO, worker.
- Is it web-exposed? If yes, there is an
.idlfile and a spec, and both are entry points. - 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.
| Path | Process | Notes |
|---|---|---|
//content/browser/ | Browser | privileged; trusts nothing from renderers |
//content/renderer/ | Renderer | the content-layer side of the renderer |
//content/common/ | both | IPC/shared definitions only |
//content/public/ | both | the embedder-facing API surface |
//third_party/blink/renderer/ | Renderer | the engine implementation |
//third_party/blink/public/ | boundary | what //content is allowed to see of Blink |
//cc/ | Renderer (+ viz) | compositor; runs on main and impl threads |
//gpu/, //components/viz/ | GPU | command buffer, display compositor |
//services/network/ | Network service | its own process |
//net/ | network service | the stack itself |
//v8/ | renderer | separate project, separate repo, separate bug tracker |
//base/ | all | threading, callbacks, containers |
//mojo/ | all | the 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.
Inside Blink: core vs modules vs platform
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 thatcoredoes 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 incore/.
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 / prefix | Means |
|---|---|
FooImpl | the concrete implementation of interface Foo, often a Mojo interface |
FooClient | callback interface into the layer above (inverted dependency) |
FooObserver | multi-subscriber notification |
FooDelegate | single-subscriber policy hook, usually embedder-provided |
FooBase | shared base for several implementations |
FooTraits | compile-time policy/customisation |
FooBuilder | staged construction of an immutable object |
ScopedFoo | RAII — does something in the ctor, undoes it in the dtor |
FooHandle, FooToken | opaque identity, safe to pass across processes |
blink::Foo vs Foo | Blink 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:
| Input | Generates |
|---|---|
*.idl (Web IDL) | V8 bindings — the JS-visible surface of every Web API |
*.mojom | IPC interfaces, both ends, for every language |
css_properties.json5 | property IDs, parsing, computed-style storage, longhand expansion |
css_value_keywords.json5 | every CSS keyword identifier |
html_tag_names.json5, html_attribute_names.json5 | tag/attribute atoms |
runtime_enabled_features.json5 | RuntimeEnabledFeatures::FooEnabled() for every flag |
computed_style_extra_fields.json5 + friends | ComputedStyle field storage |
Consequences, in order of how often they bite:
- A CSS property's parsing/storage is declared, not written. Looking for where
containis handled? Start atcss_properties.json5, not at a.ccfile. The entry there tells you the property's inheritance, initial value, whether it is animatable, and which custom parsing function (if any) it uses. - A Web API's entry point is its
.idl.document.querySelectoris declared in an.idl; the generated binding calls a Blink method whose name is derived by rule (querySelector→QuerySelector). Searching for"querySelector"in.ccfiles finds usage; searching.idlfinds the definition. 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.- 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 --followsurvives renames — necessary in a tree that renamed everyng_*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.
| Kind | Location shape | Runs |
|---|---|---|
| Unit test | foo_test.cc next to foo.cc (Blink), foo_unittest.cc (rest of Chromium) | in-process, fast |
| Browser test | *_browsertest.cc | real multi-process browser |
| Web test | third_party/blink/web_tests/ | content_shell, HTML+expectations |
| WPT | third_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
OWNERStells 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.DEPSdeclares legal include directions. Reading aDEPSfile tells you the intended layering faster than reading any code, and aninclude_rulesentry 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
| File | Generates | Open it when you want to know |
|---|---|---|
core/css/css_properties.json5 | property IDs, parsing, computed-style storage, longhand expansion | anything about a CSS property |
core/css/css_value_keywords.json5 | every CSS keyword identifier | why a keyword is/isn't recognised |
core/css/computed_style_field_aliases.json5 | ComputedStyle field storage | how a property is stored |
platform/runtime_enabled_features.json5 | RuntimeEnabledFeatures::XEnabled() | whether a behaviour is flag-gated |
core/html/html_tag_names.json5-style name tables | tag/attribute AtomicString atoms | why tag comparison is a pointer compare |
core/events/event_type_names.json5 | event type atoms | the canonical list of event names |
core/events/event_target_names.json5 | event target names | what can be an event target |
core/svg/svg_tag_names.json5, mathml_tag_names.json5 | foreign-content element tables | parser foreign-content behaviour |
platform/fonts/font_family_names.json5 | generic family atoms | font fallback plumbing |
core/probe/core_probes.json5 | DevTools instrumentation probes | how 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:vsBug:— 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.
README.mdin the directory, if it exists. Blink's better subsystems have unusually good ones (core/css/,core/layout/,core/paint/,platform/scheduler/,platform/heap/).DEPS— what may this depend on? That is the layering, stated.OWNERS— who is accountable; adjacent directories with disjoint owners are different teams.BUILD.gn— what is the component, what is public, what is internal.- The
.json5/.idl/.mojominputs, if any — the declarative surface. - The test files —
*_test.ccnames enumerate the edge cases the code exists to handle. Read these before the implementation; they turn an opaque function into a checklist. - 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:
- 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.) - 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.
- 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
.ccfile, 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
DEPSandOWNERSfile 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.
-
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.
-
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.
-
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?
-
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.
-
Why does
//third_party/blink/publicexist 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? -
Argue that Chromium's heavy use of generated code from
.json5/.idl/.mojomis 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? -
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.
-
DCHECKis 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 ofCHECKvsDCHECKfor a given invariant tell you about how the author classified the failure? -
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.
-
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
DEPSandOWNERSfile 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
- 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.
- 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.
- 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.
- 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
| Failure | Symptom | Correction |
|---|---|---|
| Concept instead of token | "layout" returns tens of thousands of hits | convert to symbol: or a spec phrase |
| Wrong layer | two hours in //content for Blink internals | answer "which process?" before searching |
| Assuming dead code | "this is never called" | check generated code, .mojom, feature flags |
| Depth-first descent | four hours in //base/containers | keep a deferred list; stop when the answer stops changing |
| Trusting a remembered path | confidently wrong | check 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 1 | hits: ? 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:
- the
.idldeclaration - the Blink C++ method the binding calls
- 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_rulesexception with a comment justifying it. What compromise is documented there? - From
OWNERSfiles 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
| Drill | Predicted subsystem | Correct? | Queries to converge | Time | Technique 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
| DCHECK | Invariant 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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.
| Technique | Generalisation |
|---|---|
| Spec-phrase search | any implementation of a written standard: TCP stacks, PDF renderers, compilers, SQL engines |
| Path ⇒ layer ⇒ process | any layered system with enforced dependency direction |
| Generated-code detection | Protobuf, GraphQL codegen, ORM models, OpenAPI clients — the same "grep finds nothing" failure |
git log -S | the fastest way to date any behaviour in any repository |
| Tests as enumerated fears | universal |
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-02gives you the process/thread map that makes "which layer?" answerable rather than guessed.bi-12adds tracing as rung 3 — the instrument that tells you where to look when you have no hypothesis at all.bi-14is 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
| Concern | Process | Why there |
|---|---|---|
| DOM, CSS, layout, paint, JS | Renderer | untrusted content; must be sandboxed |
| Compositing (main-thread half) | Renderer | needs layout output |
| Compositing (impl half), raster | Renderer + GPU | must survive a busy main thread |
| GL/Vulkan command execution | GPU | drivers are fragile and a large attack surface |
| Sockets, TLS, HTTP cache | Network service | shared across renderers; must not be per-origin-controlled |
| Cookie authority | Browser | a compromised renderer must not read another site's cookies |
| Permissions, navigation, URL bar | Browser | the user-trust surface |
| Disk / profile | Browser | sandboxed 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/touchstartlistener — you have forced the compositor to consult a thread that is busy. requestAnimationFrameis 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.comandhttps://b.example.comare 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:
- 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.
- 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.
- The request goes to the network service, not the renderer. The renderer never holds the socket.
- 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. - The body is streamed to the chosen renderer, which begins parsing (bi-03) on the main thread while the preload scanner speculatively requests subresources.
- Blink builds DOM → style → layout → paint (bi-04–bi-09), producing display items and property trees rather than pixels.
- The compositor turns that into layers/tiles; raster produces pixels, potentially in the GPU process.
- 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.
| Concept | Class | What it answers |
|---|---|---|
| Security principal | SiteInfo | Which data is this execution context allowed to access? |
| Principal instance | SiteInstance | Which specific process should this document use? |
| Browsing context group | BrowsingInstance | Which documents can find each other by name? |
| Process lock | ProcessLock | Which 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()withoutnoopenerkeeps 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-Policyheader, 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 aReceiver(receives). Replies flow back fromReceivertoRemote. - A
Receivermust 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:
- process untrustworthy inputs,
- in an unsafe language (C/C++),
- 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.
| Quantity | Rough scale | Why it matters |
|---|---|---|
| Renderer process baseline memory | tens of MB before your page | why process-per-site has a real cost |
| Process startup | milliseconds, not microseconds | why a spare renderer is kept warm |
| Mojo message | a task hop, not a function call | why chatty IPC designs fail |
| Frame budget at 60 Hz | 16.7 ms total across all stages | your script is one term |
| Frame budget at 120 Hz | 8.3 ms | raster 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:
- 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?
- 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. - 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.
- 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.ccthat 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.mddocs/security/compromised-renderers.md— read this one twicedocs/mojo_and_services.md,docs/mojo_ipc_conversion.mddocs/threading_and_tasks.md(+_faq.md) — sequences vs threadsthird_party/blink/renderer/README.md§"Threading model", §"Dependencies"content/README.mdand theDEPSfiles 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
-
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.
-
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? -
Explain to a senior engineer why an XSS on
a.example.comis not automatically a total compromise ofb.example.com, given both are the same site. Then explain the case where your reassurance is wrong. -
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?
-
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.
-
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?
-
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.
-
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?
-
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.
-
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.mddocs/security/compromised-renderers.md— read this one twicedocs/mojo_and_services.md,docs/mojo_ipc_conversion.mddocs/threading_and_tasks.md(+_faq.md) — sequences vs threadsthird_party/blink/renderer/README.md§"Threading model", §"Dependencies"content/README.mdand theDEPSfiles 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
- 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. - The browser process validates everything a renderer says. A renderer can lie about its
origin;
ProcessLockis what makes the lie useless. - Ordering is guaranteed per Mojo pipe and not across pipes. Code assuming cross-interface ordering is assuming something no layer promises.
- 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.
- Rule of Two: untrustworthy input + unsafe language + no sandbox — at most two.
Failure modes this architecture is designed against
| Threat | Defence | What it costs |
|---|---|---|
| Renderer exploit reads another site's data | site isolation; data never enters the process | memory, ~1 process per site |
| Speculative-execution side channel | same — "never delivered" beats "checked" | the entire CORB/ORB layer |
| Compromised renderer lies about identity | ProcessLock enforcement | validation code in every browser-side handler |
| Malicious framing | frame-ancestors/XFO enforced browser-side | cannot be done in the framed renderer |
| Driver bug or GPU exploit | separate GPU process | an 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:
- 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?
- 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. - 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.
- 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)
| What | Predicted | Actual | Gap 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 allrun; 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
| Browser | Your system |
|---|---|
| Renderer is untrusted | the browser client is untrusted |
ProcessLock enforces identity | server-side authorisation, not client-side checks |
frame-ancestors enforced browser-side | authorisation decided server-side, never by the caller |
| Capability handed as a Mojo endpoint | scoped tokens rather than repeated permission checks |
| Rule of Two | untrusted 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
postMessagetargets.
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:
- Insertion mode — "in body", "in table", "in head", "after body", … The mode decides how a token is interpreted.
- Stack of open elements — the currently-open ancestry.
- List of active formatting elements — the machinery that makes
<b>a<p>b</b>cproduce 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 —
TryParsingHTMLFragmentinhtml_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 (kStripInitialWhitespaceForBodyforDOMParser,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:
HTMLPreloadScanner | BackgroundHTMLScanner | |
|---|---|---|
| Thread | main | worker |
| Purpose | discover subresources to preload | find inline scripts to stream-compile |
| Coverage | first chunk of body only, unless the parser is paused | all 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.
| Concern | Where to look |
|---|---|
| Driving the parse, chunking, yielding | the document-parser class |
| Tokenizer state machine | the tokenizer + its .json5 name table |
| Token representation | token / atomic-token headers |
| Insertion modes | the tree builder |
| Stack of open elements, insertion location, foster parenting | the construction site + element stack |
| Formatting elements / adoption agency | the formatting element list (its test file is named after the algorithm) |
| Speculation | preload 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:
| Family | Examples | Why it exists |
|---|---|---|
| Core | kDataState, kTagOpenState, kTagNameState, kEndTagOpenState | the actual parsing job |
| Attribute | attribute name / before-value / quoted / unquoted value states | HTML permits unquoted and single-quoted values |
| Content models | kRCDATAState, kRAWTEXTState, kScriptDataState, kPLAINTEXTState | inside <title>, <textarea>, <style>, <script> the rules change |
| Content-model exits | kRCDATALessThanSignState, kRAWTEXTEndTagNameState, … | you must recognise only the matching end tag |
| Script escaping | kScriptDataEscapeStartState, …EscapedState, …DoubleEscaped… | <!-- inside <script>, a 1990s idiom |
| Character references | kCharacterReferenceInDataState, …InRCDATAState | entities, per content model |
| Markup declaration | comment, 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>hiproduces a fullhtml/head/bodytree. - 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:
| Speculation | Can be wrong about | Never affects |
|---|---|---|
| Preload scanner | which resources are needed | the DOM |
| Background scanner | which scripts will run | parser 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 branch | architectural 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.
| Form | Parser behaviour | Executes |
|---|---|---|
<script> (classic, inline or external) | blocks parsing; external also blocks on fetch | immediately, in order |
<script defer> | does not block | after parsing, before DOMContentLoaded, in order |
<script async> | does not block | as soon as fetched — order not guaranteed |
<script type="module"> | deferred by default | after parsing, in dependency order |
Two consequences that show up in real products:
asyncscripts are an ordering hazard, not just a loading strategy. Twoasyncscripts 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 beforedeferexisted, and whydeferis 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 — theimgis not in the document.- Template content survives table parsing rules that would otherwise foster-parent it, because
the tree builder tracks templates separately.
FindFosterSitechecks 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:
- 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.
- 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. - Error recovery for: unclosed
<p>, unclosed<li>, stray</div>, text in tables. - Tokenizer/tree-builder coupling — implement
<title>/<textarea>switching the tokenizer's content model. This is the stage that teaches the real lesson. - 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
- Remove foster parenting. Which real-world markup breaks? Predict, then test against a corpus of your own saved pages.
- 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.)
- 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.
- 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:
- 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? - 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.
- Trace a real page load with the
blinkcategory. Find the parser's trace events, and identify: how many chunks the parse was split into, and where scripts blocked it. - 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 fromhtml_document_parser.h, and readhtml_document_parser_fastpath.hfor the fragment fast path.- html5lib test data format (used by WPT and by most non-browser HTML parsers).
- Blink's
SpecMapping.mdfor the spec→directory mapping.
Principal Engineer Review
-
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?
-
element.innerHTML = element.innerHTMLcan change the DOM. Explain the mechanism, and give a realistic production bug this causes. -
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.
-
Argue that the tokenizer and tree builder should be fully decoupled for maintainability. Then defeat your own argument with a concrete markup example.
-
document.writeis 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? -
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?
-
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?
-
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.
-
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?
-
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 fromhtml_document_parser.h, and readhtml_document_parser_fastpath.hfor the fragment fast path.- html5lib test data format (used by WPT and by most non-browser HTML parsers).
- Blink's
SpecMapping.mdfor the spec→directory mapping.
When a claim here proves stale, record it in PROGRESS.md section 7.
bi-03 — Analysis
Required invariants
- Every byte sequence produces a DOM. There is no parse-failure path. "Parse error" is a diagnostic annotation, not control flow.
- 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.
- 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.
- The tokenizer's state depends on the tree builder. Content models (
RCDATA,RAWTEXT, script data) mean the two machines cannot be decoupled. - Speculation may be wrong; it may never be observable. Preload and background scanners create no nodes, run no script, and change no parser state.
- Author code never observes a half-built tree. The construction site queues tasks rather than mutating directly.
The measurable shape of the problem
| Quantity | Value | What it tells you |
|---|---|---|
| Tokenizer states | 76 | content models and legacy escaping, not core parsing |
| Insertion modes | 21 | 7 are tables; 3 are framesets |
| Parser directory files | ~95 | you 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
| Break | Consequence | First test to fail |
|---|---|---|
| Remove foster parenting | legacy table markup renders inside-out | html5lib table cases |
| Remove character batching | whitespace handling in tables becomes wrong | in-table-text cases |
| Decouple tokenizer from tree builder | <title>/<textarea> content parsed as markup | RCDATA cases |
Remove the <input type=hidden> exception | hidden form fields leave their <form> on submit | compat, not conformance |
| Sanitiser disagrees with the parser | mutation XSS | none — 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
- Write every prediction down before running anything. Predictions made after
observation are worthless. Put them in
docs/observation.mdbefore you open a browser. - Do not use
innerHTMLto verify. It invokes the fragment parsing algorithm with a different context element and can produce a different tree. Load real documents. - No source paths will be given to you. Record the search that worked, not the path.
- 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.
Step 4 — Navigate to the Blink implementation
Tool: Chromium Code Search — https://source.chromium.org/chromium/chromium/src
Strategies, in the order to try them:
- 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.
- Symbol search. Once you have a plausible name,
symbol:Namefinds the declaration rather than every mention. - 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.
- Scoping.
file:third_party/blink/renderer/core/html/parser/narrows noise.case:yesmatters for C++ identifiers. - 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):
- Delete the relocation step: every node goes into the current node. Which of your four cases changes? What class of real-world page breaks?
- Delete the Case B exception. What breaks, and for whom?
- 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?
- 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:
- Why does this code exist?
- What invariant does it maintain?
- Who calls it?
- What does it call?
- 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.)
- What happens if it is removed?
- How is it tested?
- 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.jspasses 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.mdlab 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
| Case | Prediction | Verdict (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:
Step 4 — Blink implementation
| What | Name found | Search 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
| Kind | Path | What 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
- Remove relocation entirely → First test to fail:
- Remove the Case B exception → First test to fail:
- 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)
-
Why does this code exist?
-
What invariant does it maintain?
-
Who calls it?
-
What does it call?
-
Which process and thread executes it? (is the answer always the same one?)
-
What happens if it is removed?
-
How is it tested?
-
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.jspasses 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
<table>inserted in in body; insertion mode → in table."hello"character token, current node istable→ pending table character tokens cleared, original insertion mode saved, mode → in table text.- 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. <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."world": mode is still in table, but the current node is nowdiv, not a table-ish element — so the foster-parenting redirect does not apply (it only fires when the target istable/tbody/tfoot/thead/tr). The text lands inside thediv.
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 inbodywhen 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 popbodyoff the stack, so whitespace after</body>and after</html>still appends tobody'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.
Step 4 — Blink implementation
Verified against main on 2026-08-10. Everything below lives in
third_party/blink/renderer/core/html/parser/.
| Role | Name | File |
|---|---|---|
| Performs relocation | HTMLConstructionSite::FosterParent(Node*) | html_construction_site.cc |
| Locates the site | HTMLConstructionSite::FindFosterSite(HTMLConstructionSiteTask&) | html_construction_site.cc |
| Decides relocation | HTMLConstructionSite::ShouldFosterParent() | html_construction_site.cc |
| Per-element predicate | HTMLStackItem::CausesFosterParenting() | html_stack_item.h |
| In-table start tags | HTMLTreeBuilder::ProcessStartTagForInTable(AtomicHTMLToken*) | html_tree_builder.cc |
| Flushes buffered chars | HTMLTreeBuilder::DefaultForInTableText() | html_tree_builder.cc |
| Buffer | pending_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 foranything elsefinds 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:
| System | Speculative pass | Permitted to be wrong about |
|---|---|---|
| HTML parser | preload / background scanners | which resources are needed |
| Compositor | scroll before the main thread answers | whether JS wanted preventDefault |
| CPU | branch prediction | the branch |
| Databases | prefetch, read-ahead | which pages are needed |
| Your app | prefetch on hover, speculative render | what 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-04picks up where the parser hands off: what happens to a node after it is created.fw-06reuses 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 oncppgc, which lives in V8, so one collector traces C++ and JavaScript objects together. In current code you use plainMember<T>for managed pointers regardless of whether a JS object is transitively reachable, andTraceWrapperV8Reference<T>for references into V8 that this object must keep alive. A blog post aboutTraceWrapperMemberis describing a browser that no longer exists.
Oilpan's handle vocabulary
You cannot read core/dom/ without these. From platform/heap/BlinkGCAPIReference.md:
| Type | Meaning |
|---|---|
GarbageCollected<T> | base class: this type lives on Oilpan's heap |
Member<T> | strong reference from one GC object to another |
WeakMember<T> | weak; cleared automatically when the target dies |
UntracedMember<T> | not traced — you are asserting lifetime some other way |
Persistent<T> | strong root from non-GC code into the heap |
WeakPersistent<T> | weak root |
CrossThreadPersistent<T> | root usable from another thread |
STACK_ALLOCATED() | this class may only exist on the stack |
DISALLOW_NEW() | may only exist inline in another object |
Trace(Visitor*) | how an object declares its outgoing edges |
Reading rules that pay off immediately:
Member<T>in a class means "part of the object graph." TheTracemethod is the authoritative list of what an object keeps alive — read it first when reasoning about leaks. It is the ownership documentation, and unlike a comment it cannot silently rot.Persistent<T>held by something long-lived is the classic leak shape. A root that is never dropped keeps an arbitrary subgraph alive.STACK_ALLOCATED()is a strong hint about intended lifetime, enforced by a clang plugin. If you find yourself wanting to store one, your design is fighting the framework.
Blink also constrains ordinary types: String/AtomicString and WTF containers rather
than STL, KURL not GURL, SecurityOrigin not url::Origin. This is enforced by
DEPS, by audit_non_blink_usage.py, and by a clang plugin. When a codebase enforces a
convention with three separate mechanisms, that is a signal about how often people got it
wrong — and about how seriously reviewers will take it in your first CL.
Mutation → invalidation
The step that connects this module to the rest of the track:
DOM mutation
→ node marked dirty (style invalidation: "which elements might need recalculation?")
→ ancestors/descendants marked as needing work, per invalidation sets
→ at the next rendering opportunity: style recalc → layout → paint
The essential insight is that mutation does not recompute anything. It records that something must be recomputed later. This is why a thousand DOM writes in a loop are not a thousand layouts — and why one interleaved read of a geometry property forces a synchronous flush and turns the loop quadratic. That is the forced-synchronous-layout bug, and it is Vertical Trace #2.
Under the Hood
Re-derive rather than memorise (bi-01 Techniques 1–4). Places to be able to find:
| Concern | What to look for |
|---|---|
| Node/Element/Document base classes | core/dom/ |
| Child list manipulation and its checks | the container-node insertion/removal paths |
| Attribute storage | element attribute collections; note the space optimisation for few attributes |
| Shadow trees, slotting, flat-tree traversal | shadow root + flat tree traversal helpers |
| Mutation observers | the mutation observer registration/queueing machinery |
| Custom element reactions | the reaction stack/queue |
| Lifetime | Trace() methods, platform/heap/ |
Two questions worth answering by reading, because they teach the design:
- Why does
appendChilddo so much work before mutating? (Hierarchy checks, adoption into the right document, removal from the old parent, ordering guarantees around observers and custom element reactions.) The naive "set a pointer" model is wrong in about six ways, each of which is a spec requirement. - Why are custom element reactions queued rather than run immediately? Because running author code in the middle of a DOM mutation would let it observe — and mutate — a tree in an inconsistent intermediate state. This is the same reason bi-03's construction site queues tasks. Notice that you have now met the same design pressure twice. That pattern-recognition is the actual skill.
Deep dive: Oilpan, as actually configured
From platform/heap/BlinkGCAPIReference.md, with the general design living in V8's cppgc README —
that inheritance is the whole story: Blink's GC is V8's C++ GC with Blink-specific extensions,
which is exactly what makes one unified collector over both heaps possible.
Threading
"Oilpan assumes heaps are not shared among threads."
Threads that allocate Oilpan objects must be attached (ThreadState::AttachMainThread() /
AttachCurrentThread()). Blink creates heaps and root sets per thread, and an object belongs
to the thread it was allocated on.
This is the enforcement behind renderer/README.md's rule that cross-thread communication is
message passing, not shared memory. It is not a style preference — the heap is structured so that
sharing objects across threads is the exceptional path, requiring explicit CrossThreadPersistent
/ CrossThreadHandle types that announce themselves in the code.
Heap partitioning — and what the partitions disclose
Blink assigns certain types to custom spaces:
- collection backings to compactable custom spaces (so vectors and hash tables can be moved and the heap defragmented),
Node,CSSValue, andLayoutObjectto typed custom spaces.
Read that second line as a performance disclosure. Those three types get dedicated spaces because
they are the highest-volume allocations in the renderer. A page is, in allocation terms, mostly
nodes, CSS values, and layout objects — a useful corrective to any mental model where "the DOM" is
the only thing that costs memory. Your stylesheet allocates too, and CSSValue earning its own
heap space is the evidence.
Mode of operation
- Concurrent marking and sweeping (except during thread termination and heap destruction).
- GCs are scheduled through the message loop, at points where no objects are referenced from the native stack — so the collector can be precise rather than conservative.
- Under memory pressure, Blink triggers a conservative GC on allocation.
The precise/conservative distinction is worth holding. A precise GC knows exactly which words are
pointers; a conservative one must treat anything that looks like a pointer as one, and therefore
retains garbage. Blink prefers precise collection and schedules for it — the message loop is not
only a scheduler, it is a GC safepoint mechanism. That is a genuinely non-obvious coupling
between bi-11 and this module, and it is the kind of cross-subsystem constraint that never
appears in documentation about either one alone.
ActiveScriptWrappable: reachability is not the whole story
/**
* Classes deriving from ActiveScriptWrappable will be kept alive as long as
* they have a pending activity. Destroying the corresponding ExecutionContext
* implicitly releases them to avoid leaks.
*/
This solves a problem pure reachability cannot:
new XMLHttpRequest().addEventListener('load', () => console.log('done'));
// nothing holds a reference to the XHR
Reachability says garbage. Correctness says it must survive until the request completes and fires
its event. ActiveScriptWrappable is the mechanism — "I have pending activity, keep me alive."
The same applies to a playing <audio>, an open WebSocket, a pending animation.
Two transferable lessons:
- "Unreachable" and "collectable" are not synonyms in a system with external observable
effects. Any framework with subscriptions has this problem in miniature — which is why
fw-02's effect-cleanup lab matters. - The
ExecutionContextteardown clause is the leak guard: when the document goes away, pending activity is released regardless. Without it a navigated-away page could pin memory forever. Every "keep alive" mechanism needs a matching "and here is when we stop."
Deep dive: wrappers are lazy, and what that costs
A DOM node has no JavaScript object until JavaScript observes it. Wrappers are created on demand.
This is the detail most people miss, and it changes the cost model:
- A 50,000-node document that script never touches costs far less on the V8 side than one where a framework has walked the whole tree.
- A gratuitous
document.querySelectorAll('*')does not merely cost the query — it materialises wrappers for everything it returns, permanently (until GC), on both heaps. - Frameworks that hold references to every DOM node they created keep every wrapper alive.
Rules of thumb (anchors, not measurements — verify on your own pages):
| Quantity | Rough scale |
|---|---|
| Per-element C++ cost | hundreds of bytes to low kB, depending on what is attached |
| Per-element JS wrapper | zero until touched, then a real object on V8's heap |
| Style recalc | proportional to invalidated elements, not total (bi-07) |
| Layout | proportional to dirty boxes plus intrinsic-sizing pre-passes (bi-08) |
Deep dive: the mutation-observation family, ordered
Several mechanisms observe DOM change and they do not run at the same time. Getting the order right explains a large class of "why did my callback see stale state" bugs.
| Mechanism | When it runs |
|---|---|
| Custom element reactions | reaction queue, drained at defined checkpoints during the mutating algorithm |
MutationObserver | microtask checkpoint after the current task |
ResizeObserver | inside update the rendering, after layout — and may loop |
IntersectionObserver | inside update-the-rendering, delivered asynchronously |
| mutation events (legacy) | synchronously, mid-mutation — which is why they were deprecated |
The historical arc is the lesson. Mutation events fired synchronously in the middle of DOM
algorithms, so author code could observe and mutate a tree in an inconsistent intermediate state.
They were catastrophic for correctness and performance alike, and MutationObserver replaced them
with batched, microtask-delivered records.
You have now met the same design pressure three times: the parser's construction site queues tasks (
bi-03), custom element reactions are queued, and mutation observation is batched. Each time the requirement is identical — author code must never observe a half-built tree. When you design any API that notifies on change, this is the first question to ask.
ResizeObserver is the instructive outlier: it deliberately runs inside the rendering steps and
is permitted to loop, with a depth limit and an error when exceeded, because resize-driven layout
changes genuinely need to reach a fixed point before paint. Sometimes the correct answer is a
bounded loop, not a ban — and the bound is what makes it shippable.
Deep dive: the trees, precisely
"The DOM tree" is ambiguous. Name which one you mean:
| Tree | What it is | Who consumes it |
|---|---|---|
| Node tree | as authored; parentNode / childNodes | DOM APIs |
| Shadow tree | a ShadowRoot and its descendants | encapsulation |
| Flat tree | node tree with shadow content slotted in | style and layout |
| Layout tree | boxes; no display:none; anonymous boxes added | layout (bi-08) |
| Fragment tree | immutable geometry results | paint (bi-09) |
| Accessibility tree | semantic representation | assistive tech, and testing (fw-12) |
Behaviours that make no sense without the flat tree:
- Inheritance crosses shadow boundaries — inherited properties flow host to shadow content, because inheritance follows the flat tree.
::slotted()and::part()exist because ordinary selectors cannot cross the boundary, even though the flat tree has already merged the content.- Event retargeting — a listener outside a shadow root sees the host as
target, not the internal element.composedPath()reveals the real path.
And the fact that surprises people most: slot assignment does not move nodes. parentNode is
unchanged; only the flat tree differs. Any code reasoning about layout from parentNode is
reasoning about the wrong tree.
Deep dive: attributes, properties, and the reflection table
| Case | Attribute | Property | Relationship |
|---|---|---|---|
id, class | id, class | id, className | reflected both ways |
<input value> | the default value | the current value | diverges after user input |
<input checked> | default checkedness | current checkedness | same divergence |
href on <a> | as authored | resolved absolute URL | property is not the attribute |
data-* | attribute | dataset | live view |
style | serialised text | CSSStyleDeclaration | an object, not a string |
The href row is the classic gotcha: el.getAttribute('href') and el.href differ for a relative
URL, because the property resolves against the document base.
Why frameworks disagree here. A framework that sets properties and one that sets attributes
behave differently on exactly the diverging rows. "It works in React but not in my web component"
is very often this: React setting a property where the component expected an attribute change
and an attributeChangedCallback that consequently never fired.
Anti-Patterns
"The DOM is slow." DOM writes are cheap; they mark dirty. What is expensive is forcing a synchronous recomputation, and creating enormous trees. A model that says "DOM = slow" cannot predict which of two loops is 100× worse.
Reasoning about shadow DOM using the node tree. Style and layout use the flat tree.
Assuming property ↔ attribute equivalence. Especially for form controls.
Treating MutationObserver as free. It queues microtasks; a chatty observer on a hot
subtree can dominate a frame.
Using detached DOM nodes as a cache. A detached subtree held by a JS variable keeps the whole subtree — and everything its listeners close over — alive across both heaps.
Trade-offs
Two GCs vs one. Unifying them would simplify lifetime enormously. It is not done because V8 is a separate project with its own release cadence, embedders other than Blink, and its own performance constraints. The cost of the split is the entire wrapper-tracing mechanism — a large, permanent complexity budget spent on an organisational boundary as much as a technical one. Notice that: some production complexity exists because of who owns what, not because of what the machine needs. That is a §46 entry worth writing carefully.
Queued reactions vs immediate callbacks. Queuing preserves tree consistency but makes ordering subtle and hard to reason about. Immediate callbacks would be simpler to explain and impossible to make safe.
Attribute storage compactness vs access speed. Elements overwhelmingly have few attributes, so Blink optimises for the small case. Find the actual data structure and decide whether you would have made the same call.
Lab
mini-browser M3 — a real DOM.
Node,Element,Text,Document; parent/child/sibling links.appendChild,insertBefore,removeChild,removewith correct ordering and hierarchy checks (including: appending a node that already has a parent removes it first; appending an ancestor to its own descendant must throw).- Attributes with a separate reflected-property layer for at least
classandid. - A dirty-marking scheme: mutations mark nodes, and nothing is recomputed until a
flush(). MutationObserver-alike with microtask-queued delivery.- A deliberate second implementation of dirty-marking: naive (recompute everything) vs scoped. Measure both on a 10,000-node tree.
Deliverable: a table showing operations/second for both schemes, and a written explanation of where the crossover is and why.
Failure Lab
- Forced synchronous layout. Write a loop that writes then reads geometry each iteration. Measure. Then batch reads and writes. Explain the complexity change, not just the wall clock.
- The detached-subtree leak. Build one deliberately. Find it in DevTools' heap snapshot. Then find the retaining path — the skill is reading the retainer chain, not noticing the growth.
- Observer storm. Attach a
MutationObserverthat mutates the DOM in response to mutations. Predict the outcome before running: infinite loop, or something subtler? Explain in terms of the microtask checkpoint. - Property/attribute divergence. Build a form where setting the attribute after user input does nothing visible. Explain to a hypothetical junior engineer in three sentences.
Debugging Exercise
- Breakpoint on the container-node insertion path. From
document.body.appendChild(el)in the console, capture the full stack: how many frames between the binding and the actual pointer update? - Find the
Trace()method of anElement. List what it keeps alive. Predict what happens to a listener closure when the element is removed but a JS reference is retained — then verify with a heap snapshot. - Use tracing to observe that a batch of DOM mutations produces exactly one style recalc. Then insert a geometry read into the loop and observe the change.
Testing & QA Considerations
- Find the WPT for
appendChildhierarchy-check errors. What exception types are specified, and does Blink match on every branch? - Find a
core/dom/unit test that asserts an ordering property (observers vs custom element reactions). What would break if the order changed? - Write a test that would catch a regression in the property/attribute reflection of one form control.
Further Reading (primary sources first)
- DOM Standard (WHATWG) — node tree, mutation algorithms,
MutationObserver. - HTML Standard — reflection of IDL attributes to content attributes; form control state.
- DOM Parts / shadow DOM sections for the flat tree and slotting.
third_party/blink/renderer/platform/heap/BlinkGCAPIReference.md— the Oilpan reference. Read "Handles" and "Tracing" properly; skim the rest.third_party/blink/renderer/README.md§"Type dependencies".third_party/blink/renderer/core/dom/— and itsTrace()methods specifically.
Principal Engineer Review
-
"The DOM is slow" — replace this with a model precise enough to predict which of two implementations is faster. Give a case where the folk model predicts the wrong answer.
-
Explain wrapper tracing to a senior engineer who knows JS GC but not Blink. Why can't the two collectors run independently? What breaks first if they do?
-
A long-lived SPA grows 40 MB per hour. Enumerate causes in order of likelihood, and give the fastest discriminating observation for each.
-
Style and layout use the flat tree, not the node tree. Give two developer-visible behaviours that make no sense without this fact.
-
Custom element reactions and mutation observers are both queued rather than immediate. Reconstruct the requirement that forces this. What would a synchronous design break — specifically?
-
Argue for merging V8's heap and Oilpan. What would improve, what would become impossible, and what non-technical constraint is doing most of the work in the actual decision?
-
A framework claims it "batches DOM updates for performance." Given this module, what is it actually saving — and in what situation does it save nothing at all?
-
Design a DOM API that makes forced synchronous layout impossible to express. What do you lose? Would you ship it as a replacement, or alongside?
-
Blink enforces its type conventions with
DEPS, a presubmit script, and a clang plugin. What does three-mechanism enforcement tell you, and when is that level of enforcement worth it on a team you lead? -
You must explain to a product manager why a "simple" DOM change caused a 30 % regression in a rendering benchmark. Do it in five sentences, without using the words "reflow" or "repaint."
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 itsTrace()methods specifically.
When a claim here proves stale, record it in PROGRESS.md section 7.
bi-04 — Analysis
Required invariants
- Wrapper identity is stable.
document.body === document.body, and expandos survive a round trip through C++. This single requirement forces the unified heap. - 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. - Mutation records, it does not recompute. A thousand DOM writes are not a thousand layouts; one interleaved geometry read makes them so.
- Author code never observes an inconsistent tree. Custom element reactions are queued;
MutationObserverdelivers on a microtask; synchronous mutation events were removed for exactly this reason. - Style and layout use the flat tree. Not the node tree. Slot assignment does not change
parentNode. - Reachability is not sufficient for liveness.
ActiveScriptWrappablekeeps objects with pending activity alive; theExecutionContextteardown clause stops that becoming a leak.
The lifetime failure modes
| Leak shape | Mechanism | How you find it |
|---|---|---|
| Detached subtree | a JS reference holds a removed node and everything its listeners close over | heap snapshot, retainer chain |
| Never-disconnected observer | observer holds target, target holds document scope | audit disconnect() in cleanup |
Long-lived Persistent<T> | a root from non-GC code that is never dropped | read the Trace() methods |
| Listener on a shared object | closure retains the component | remove 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 too — CSSValue 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.
Node,Element,Text,Document; parent/child/sibling links.appendChild,insertBefore,removeChild,removewith correct ordering and hierarchy checks (including: appending a node that already has a parent removes it first; appending an ancestor to its own descendant must throw).- Attributes with a separate reflected-property layer for at least
classandid. - A dirty-marking scheme: mutations mark nodes, and nothing is recomputed until a
flush(). MutationObserver-alike with microtask-queued delivery.- A deliberate second implementation of dirty-marking: naive (recompute everything) vs scoped. Measure both on a 10,000-node tree.
Deliverable: a table showing operations/second for both schemes, and a written explanation of where the crossover is and why.
Failure Lab
- Forced synchronous layout. Write a loop that writes then reads geometry each iteration. Measure. Then batch reads and writes. Explain the complexity change, not just the wall clock.
- The detached-subtree leak. Build one deliberately. Find it in DevTools' heap snapshot. Then find the retaining path — the skill is reading the retainer chain, not noticing the growth.
- Observer storm. Attach a
MutationObserverthat mutates the DOM in response to mutations. Predict the outcome before running: infinite loop, or something subtler? Explain in terms of the microtask checkpoint. - Property/attribute divergence. Build a form where setting the attribute after user input does nothing visible. Explain to a hypothetical junior engineer in three sentences.
Debugging Exercise
- Breakpoint on the container-node insertion path. From
document.body.appendChild(el)in the console, capture the full stack: how many frames between the binding and the actual pointer update? - Find the
Trace()method of anElement. List what it keeps alive. Predict what happens to a listener closure when the element is removed but a JS reference is retained — then verify with a heap snapshot. - Use tracing to observe that a batch of DOM mutations produces exactly one style recalc. Then insert a geometry read into the loop and observe the change.
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)
| What | Predicted | Actual | Gap 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
appendChildordering 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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-05explains why the wrapper exists and what crossing it costs.bi-07picks 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:
- Crossing — the call itself is not inlineable into V8's optimised code the way a plain JS property is.
- Conversion — every string, number, dictionary and sequence is translated between representations. Strings are the expensive one.
- Checks — type coercion per Web IDL rules, plus security checks on cross-realm objects.
- Side effects — some getters flush style/layout.
offsetWidthis not a field read; it is a command to make layout current.
Cost 4 is the one that produces order-of-magnitude surprises, and it is invisible in any JS-level mental model.
Web IDL is the contract, and it is executable
Every web-exposed interface is declared in .idl. The IDL compiler turns it into C++ that
V8 calls. This means:
- The
.idlfile is the authoritative statement of a Web API's surface. Argument types, nullability, whether something throws, whether it is[Replaceable],[SameObject],[CEReactions]— all declared, not implemented ad hoc. - Extended attributes are where the semantics hide. Blink documents these in
bindings/IDLExtendedAttributes.md(~69 KB — that size is itself informative). A few that change behaviour dramatically:
| Extended attribute | Effect |
|---|---|
[CEReactions] | wrap the operation in the custom-element reaction stack |
[CrossOrigin] | accessible across origins — a deliberate hole in the wall |
[SameObject] | must return the identical object every time (identity, not equality) |
[Replaceable] | assignment shadows rather than calls a setter |
[RuntimeEnabled=Foo] | exists only when the feature flag is on |
[Unforgeable] / [LegacyUnforgeable] | non-configurable — cannot be shadowed by page script |
[Unforgeable] is a security primitive: it is why a page cannot redefine
window.location to fool code that reads it. When you see it, you are looking at a decision
someone made because an attacker would otherwise have a hole.
Wrappers, identity and expandos
Each C++ DOM object has at most one JS wrapper per world, and that wrapper must be stable:
document.body === document.body // true — same wrapper object
el.myExpando = 1; // stored on the wrapper
If the wrapper were recreated on each access, expandos would vanish and identity comparisons would fail. So the binding layer maintains a wrapper map and keeps the pair alive together (bi-04's two-GC problem). Wrapper identity is the requirement that forces most of the complexity in this layer — it is the answer to "why not just create a fresh object each time."
Worlds and realms
- A realm/context is roughly one global object — one
window. - A world is Blink's separation between the page's JS and isolated worlds, notably extension content scripts. The same C++ DOM node has a different wrapper per world, so expandos set by an extension are invisible to the page. This is a security feature and it is why "just use a content script" is a real isolation boundary rather than a convention.
Cross-origin window/location access goes through remote-object proxies with per-property
checks — only the [CrossOrigin] members get through. This is the mechanism behind every
"blocked a frame with origin ... from accessing a cross-origin frame" message.
V8, precisely bounded
You need only enough V8 to reason about the boundary. Know these and stop:
- Isolate — one instance of the engine, one heap. One per thread; a worker has its own.
- Context — one global object within an isolate.
- Hidden classes / shapes — V8's representation of object layout. Monomorphic call sites are fast; polymorphic ones degrade. This is why consistent object shapes matter.
- Inline caches — per-call-site memoisation of "what shape did I see here." Bindings interact with these: a DOM accessor is not a plain property, so it optimises differently.
- Deoptimisation — the optimising compiler bails back to bytecode when an assumption breaks.
- The GC boundary — V8's heap vs Oilpan's heap (bi-04).
The distinction you must never blur: V8 executes JavaScript. Blink implements the Web Platform.
documentis not a JavaScript concept — it is a Blink object with a JS wrapper. Node.js has V8 and nodocument; that is the cleanest demonstration of the split.
Under the Hood
Navigation targets (derive them; do not memorise):
| Concern | Where to look |
|---|---|
| IDL definitions | .idl files throughout core/ and modules/ |
| The IDL compiler | bindings/scripts/, documented in bindings/IDLCompiler.md |
| Extended attribute semantics | bindings/IDLExtendedAttributes.md |
| Generated output listing | bindings/generated_in_core.gni, generated_in_modules.gni |
| Actual generated C++ | out/Default/gen/third_party/blink/renderer/bindings/ |
| Wrapper base class | the script-wrappable type in platform/bindings/ |
| Wrapper tracing / GC integration | platform/bindings/ + platform/heap/ |
Do this once and it will pay for itself: open a generated binding for a simple interface and read it end to end. You will see the unwrap, the security check, the argument conversion, the exception handling, and the return conversion, all explicit. After that, every performance claim about "DOM access is slow" becomes concrete rather than folkloric.
Deriving the mapping rules
Given partial interface Document { attribute DOMString title; }, you should be able to
predict the generated C++ names and find Document::title() / Document::setTitle(). The
mangling is mechanical (IDL camelCase → C++ PascalCase, attributes → getter/setter pairs).
Once you know the rule, you can jump from any Web API name to its implementation in one step
— which is bi-01 Technique 4 applied at its highest leverage.
Deep dive: V8's execution tiers, bounded to what you need
Verified present in the checkout (v8/src/): interpreter/, baseline/, maglev/, compiler/.
That is four tiers, and knowing their names and trade-offs is the right amount of V8 for a
browser engineer.
| Tier | Directory | What it is | Trade |
|---|---|---|---|
| Ignition | src/interpreter | bytecode interpreter | starts instantly, runs slowest |
| Sparkplug | src/baseline | non-optimising baseline compiler | near-instant compile, modest speedup |
| Maglev | src/maglev | mid-tier optimising compiler | fast compile, good code |
| TurboFan | src/compiler | top-tier optimising compiler | slow compile, best code |
Code moves up tiers as it gets hot, and falls back down on deoptimisation when an assumption the optimiser relied on turns out false.
Why four tiers rather than two
This is a scheduling problem wearing a compiler costume, and the shape should be familiar by now.
Compiling is work that competes with running. A single optimising compiler is either too eager
(you pay compile time for code that runs twice) or too lazy (hot code stays slow). Tiering is the
same "spend a little to decide whether to spend a lot" pattern as tile prioritisation (bi-10)
and speculative parsing (bi-03).
What this means for you as a web engineer: almost nothing directly, and that is the point. You cannot control tier-up. What you can control is whether your code is optimisable at all — which is about object shapes.
Hidden classes and inline caches, precisely enough
V8 does not store JS objects as hash maps when it can avoid it. Objects with the same shape (same properties, added in the same order) share a hidden class (a "map"), so a property access compiles to an offset load rather than a lookup.
A property-access site records the shapes it has seen — that is the inline cache:
| Site state | Shapes seen | Speed |
|---|---|---|
| monomorphic | 1 | fastest — direct offset |
| polymorphic | a few (~2–4) | a small check chain |
| megamorphic | many | falls back to a generic lookup |
Consequences that are actually actionable:
- Initialise all fields in the constructor, in a consistent order. Adding a property later creates a new shape and transitions the object.
delete obj.xis expensive — it can force the object into dictionary mode, losing the shape entirely.- Passing objects of many different shapes to one hot function makes its access sites megamorphic.
But the honest framing matters more than the tips:
This almost never matters in application code. It matters in library and framework internals — a reconciler's hot loop, a reactivity system's dependency map. Optimising object shapes in product code is usually a misuse of your time. Knowing the mechanism is for the day you are reading
fw-11's production source and wondering why React's Fiber objects initialise every field tonullin the constructor. That is why.
Deoptimisation
TurboFan compiles on assumptions ("this is always a small integer," "this object always has this shape"). When an assumption breaks, the code deoptimises: execution transfers back to the interpreter mid-function. Deopt loops — optimise, deopt, re-optimise — are a real pathology, and the reason a function can be mysteriously slow only in production data.
You will not usually debug this. You should recognise the name when it appears in a trace.
Deep dive: the unified heap, and why this layer exists
bi-04 established the two-heap problem. Here is the machinery, current as of 2026-08-10.
Oilpan is built on cppgc, which lives in V8 (v8/src/heap/cppgc-internal/,
cppgc-js/). That location is the whole design: because the C++ collector is part of V8, one
collector can trace both heaps and correctly collect cycles that span them.
JS object ──references──► C++ DOM object ──references──► JS callback
▲ │
└────────────────────────────────────────────────────────────┘
a cycle across two heaps
Two independent collectors can never collect that cycle: each sees an external root. A unified collector can. This is not an optimisation — it is the difference between "we leak every DOM node with an event listener" and not.
The historical mechanism was wrapper tracing with TraceWrapperMember<T>; it is deprecated and
the type is gone. Today:
Member<T>for managed pointers, regardless of JS reachability,TraceWrapperV8Reference<T>for references into V8 that this object must keep alive.
Verification lesson. This is the second time in this track a widely-cited mechanism turned out to be retired (
ng_prefixes were the first). Both would have been repeated confidently from memory. Both took one minute to check in the tree. That ratio is whybi-01's navigation discipline is the first module.
Deep dive: worlds, and the isolation you can observe
A world is Blink's separation between the page's JavaScript and isolated worlds — notably
extension content scripts. DOMWrapperWorld is the type.
The same C++ DOM node has a different wrapper per world:
// page script
document.body.pageOnly = 1;
// extension content script, same document, different world
document.body.pageOnly // undefined
Both see the same <body>; neither sees the other's expandos. Prototype pollution in one world
does not reach the other.
This is a real security boundary, not a convention, and it explains several things at once:
- why a content script cannot be trivially detected by page script reading its expandos,
- why extensions must use
postMessageorwindow.wrappedJSObject-style bridges to interact, - why "just inject a script tag" behaves differently from a content script — injected script runs in the main world.
Realms, and cross-origin access
A realm/context is roughly one global object. Cross-origin window and location access goes
through remote-object proxies that permit only the [CrossOrigin]-marked members. Everything else
throws. That is the mechanism behind:
Blocked a frame with origin "https://a.example" from accessing a cross-origin frame.
The allow-list is small and deliberate: window.postMessage, window.location.href (write-only
for cross-origin), window.closed, window.frames, window.top, and a handful more. Every entry
is a decision someone defended.
Deep dive: extended attributes that change behaviour
bindings/IDLExtendedAttributes.md runs to roughly 69 KB. That size is informative: it is two
decades of accumulated requirements. The ones worth recognising on sight:
| Attribute | Effect | Why you care |
|---|---|---|
[CEReactions] | wraps the operation in the custom-element reaction stack | ordering guarantees (bi-04) |
[Unforgeable] / [LegacyUnforgeable] | non-configurable, non-shadowable | security primitive |
[CrossOrigin] | reachable across origins | a deliberate hole in the wall |
[SameObject] | must return the identical object each time | identity, not equality |
[Replaceable] | assignment shadows instead of calling a setter | legacy compatibility |
[RuntimeEnabled=X] | exists only when the flag is on | (bi-01 Technique 4) |
[SecureContext] | only in secure contexts | HTTPS-gated APIs |
[Exposed=Window,Worker] | which global(s) expose it | why an API is missing in a worker |
[PutForwards] | assignment forwards to a member of the returned object | e.g. location = url |
[LegacyLenientThis] | tolerate wrong this instead of throwing | pure compatibility |
[Exposed=...] is the one that saves application engineers the most time. "Why is
document undefined in my worker?" is answered by an IDL annotation, not by a bug.
[Unforgeable] on window.location is worth internalising as a security pattern: a page
cannot redefine it, so code that reads location to make a trust decision cannot be fooled by
script that ran earlier. When you design an API whose value is used for a security decision, ask
whether an attacker can shadow it.
Deep dive: what a generated binding actually does
Read one once and this whole layer stops being mysterious. Every generated accessor performs roughly the same sequence:
1. Unwrap: v8::Object → blink::C++ object (type check; throw TypeError if wrong)
2. Check: is this realm/world allowed to touch this object? (cross-origin)
3. Convert: JS values → IDL types, per Web IDL conversion algorithms
(this is where "3" becomes 3, where undefined becomes "undefined", etc.)
4. Call: the Blink implementation method
5. Convert: the result back to a V8 value (may allocate — strings especially)
6. Exceptions: translate Blink exceptions into JS exceptions on the right realm
Two things become obvious once you have seen this written out:
- Type coercion is specified, not incidental. Web IDL defines exactly how a JS value becomes a
long, aDOMString, a dictionary. The surprising coercions in DOM APIs are conformance, not sloppiness. - Strings cost. Every
DOMStringcrossing the boundary is a conversion between V8's representation andWTF::String, potentially with allocation. In a hot loop readingel.className, that conversion is the cost.
The size of the generated surface
bindings/generated_in_core.gni is ~197 KB and generated_in_modules.gni ~338 KB — and those are
merely the lists of generated files, not the files themselves. Half a megabyte of filenames.
Sit with that number for a moment. It is the most direct evidence available that bi-01
Technique 4 is not a curiosity: the majority of the Web Platform's C++ surface is not written by
hand. Any navigation strategy that assumes otherwise fails on most of the platform.
Anti-Patterns
Profiling only the JS flame chart. Binding cost, style flushes triggered by getters, and GC across two heaps do not appear as "your function."
Assuming DOM property access is a field read. Some are. offsetWidth, getComputedStyle
reads, and getBoundingClientRect are commands that may force layout.
Caching a live collection and treating it as a snapshot. getElementsByTagName returns a
live view; mutating the DOM while iterating it is a classic infinite loop.
Assuming a feature exists because you found its C++. [RuntimeEnabled] means it may not
be exposed. Check the flag before concluding anything about shipping behaviour.
Reasoning about extension content scripts as "same page." Different world, different wrappers, deliberately.
Trade-offs
Generated bindings vs hand-written. Generation guarantees uniform security checks, type coercion and exception behaviour across thousands of interfaces — no one forgets a check. The cost is navigability (bi-01 Technique 4) and build time. Given the security stakes, this is one of the clearest "complexity that is obviously worth it" cases in the tree, and worth contrasting with the two-GC split (bi-04), which is much less obviously worth it.
Wrapper-per-world vs shared wrappers. Per-world isolation is a real security boundary and costs memory plus a more complex wrapper map. Shared wrappers would be simpler and would let a page tamper with extension state.
IDL expressiveness vs implementability. Every extended attribute is a feature someone needed and a permanent cost in the compiler. 69 KB of documented attributes is what that accretion looks like after two decades — a good §46 subject.
Lab
Mini-binding layer. Build a toy that demonstrates the boundary rather than reimplementing V8:
- A tiny IDL-like schema format: interface name, attributes with types, methods with argument
types, plus a
[ForcesLayout]and a[CrossOrigin]marker. - A generator that emits JS accessor definitions from the schema.
- A "C++ side" (plain JS objects standing in) with a wrapper map enforcing identity.
- Type coercion per your schema, including throwing on invalid input.
- A
[ForcesLayout]attribute whose getter runs aflush()— reuse the dirty-marking scheme from mini-browser M3. - Two worlds: two wrapper maps over the same backing objects. Demonstrate that expandos do not leak between them.
Then measure: property access through your wrapper vs a plain JS property, over 1e6 iterations. Explain the gap in terms of the four costs above.
Failure Lab
- Break wrapper identity — create a new wrapper per access. Show two failures: expandos
vanishing, and
===returning false. Explain which is worse and why. - Remove the world separation — show an "extension" expando becoming visible to "page" code. Write two sentences on what an attacker does with that.
- Layout thrash through a getter — mark an attribute
[ForcesLayout], read it in a loop that also writes. Measure the complexity change. - Live vs static collections — implement both; write the infinite loop; then write the version that terminates and explain the difference precisely.
Debugging Exercise
- In DevTools, profile a loop that reads
offsetWidtheach iteration. Find the layout cost. Then find it again in a Perfetto trace with theblinkcategory, and note which view made it obvious faster. - In the local checkout: pick a Web API, find its
.idl, predict the generated C++ name, then confirm withgit grep. Do this for one attribute and one method. - Find an interface member with
[Unforgeable]. Explain the attack it prevents. - Find an interface member with
[RuntimeEnabled]that is not on by default, and verify with--enable-blink-featureson stock Chrome that the behaviour appears.
Testing & QA Considerations
- WPT has extensive IDL-conformance tests (
idlharness). Find them. What do they check that a behavioural test does not? - Find a test asserting cross-origin access is blocked on
window. What exact error is specified? - If you changed an IDL file, what would need rebuilding? Answer with
gn refs.
Further Reading (primary sources first)
- Web IDL specification (WHATWG) — types, extended attributes, conversion algorithms.
- HTML Standard —
WindowProxy, cross-origin property access,[Unforgeable]members. third_party/blink/renderer/bindings/README.md,IDLCompiler.md,IDLExtendedAttributes.md.third_party/blink/renderer/platform/bindings/— the wrapper machinery.- V8 docs/blog on hidden classes, inline caches, and the interpreter/optimiser pipeline. Read only enough to reason about the boundary; V8's internals are a separate discipline.
Principal Engineer Review
-
A colleague says "we removed jQuery so DOM access is fast now." What is right and wrong about this? Give a specific case where the change made no difference at all.
-
Explain wrapper identity to a strong JS engineer in four sentences, including why the naive design fails.
-
Isolated worlds give extensions a separate view of the same DOM. Enumerate what this protects and what it explicitly does not.
-
[Unforgeable]makes a property non-configurable. Give the concrete attack it stops, and explain the cost of applying it liberally to every security-relevant property. -
Argue that generated bindings are essential architecture. Then identify the specific navigability cost they impose and how you would onboard an engineer despite it.
-
offsetWidthforces layout. Design an alternative API for the same information that cannot cause forced synchronous layout. What do callers lose, and would you ship it? -
Given only a
.idlfile, what can you determine about a Web API's performance characteristics? What can you not? -
A team proposes exposing a new capability to the web. Walk through the layers touched, from IDL to browser process, and name the review gates it must pass.
-
Live collections (
HTMLCollection) were a design decision. Make the case they should never have existed, then the case they are the right default for the APIs that have them. -
You must explain to a backend engineer why "it's just a property read" took 400 ms in production. Do it in five sentences, and say what you would add to make it visible next time.
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
- One wrapper per object per world. Identity and expando survival depend on it.
- Cross-origin access is allow-listed, not filtered. Only
[CrossOrigin]members pass; the default is deny. [Unforgeable]properties cannot be shadowed by page script. This is what makes readingwindow.locationfor a trust decision sound.- Type coercion follows Web IDL exactly. The surprising coercions in DOM APIs are conformance, not sloppiness.
- 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:
| Cost | When it dominates |
|---|---|
| Crossing — not inlineable into optimised JS | tight loops over many small accesses |
| Conversion — JS ↔ WTF representations | anything string-heavy (className, textContent) |
| Checks — IDL coercion + security | cross-realm access |
| Side effects — some getters flush style/layout | offsetWidth, 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:
- A tiny IDL-like schema format: interface name, attributes with types, methods with argument
types, plus a
[ForcesLayout]and a[CrossOrigin]marker. - A generator that emits JS accessor definitions from the schema.
- A "C++ side" (plain JS objects standing in) with a wrapper map enforcing identity.
- Type coercion per your schema, including throwing on invalid input.
- A
[ForcesLayout]attribute whose getter runs aflush()— reuse the dirty-marking scheme from mini-browser M3. - Two worlds: two wrapper maps over the same backing objects. Demonstrate that expandos do not leak between them.
Then measure: property access through your wrapper vs a plain JS property, over 1e6 iterations. Explain the gap in terms of the four costs above.
Failure Lab
- Break wrapper identity — create a new wrapper per access. Show two failures: expandos
vanishing, and
===returning false. Explain which is worse and why. - Remove the world separation — show an "extension" expando becoming visible to "page" code. Write two sentences on what an attacker does with that.
- Layout thrash through a getter — mark an attribute
[ForcesLayout], read it in a loop that also writes. Measure the complexity change. - Live vs static collections — implement both; write the infinite loop; then write the version that terminates and explain the difference precisely.
Debugging Exercise
- In DevTools, profile a loop that reads
offsetWidtheach iteration. Find the layout cost. Then find it again in a Perfetto trace with theblinkcategory, and note which view made it obvious faster. - In the local checkout: pick a Web API, find its
.idl, predict the generated C++ name, then confirm withgit grep. Do this for one attribute and one method. - Find an interface member with
[Unforgeable]. Explain the attack it prevents. - Find an interface member with
[RuntimeEnabled]that is not on by default, and verify with--enable-blink-featureson stock Chrome that the behaviour appears.
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)
| What | Predicted | Actual | Gap 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
.idltraced 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
| Boundary | Conversion cost | Hidden side effect |
|---|---|---|
| JS ↔ DOM | string representations | forced layout |
| App ↔ database | serialisation, type mapping | a query, a lock |
| Service ↔ service | JSON encode/decode | a network round trip |
| Main thread ↔ worker | structured clone | none, but the clone dominates small jobs |
| Native ↔ WASM | memory copies at the boundary | none |
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."
| Type | Meaning | Where |
|---|---|---|
std::unique_ptr<T> | sole ownership; moved, never copied | everywhere 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-free | member fields in non-GC code |
T* | non-owning, no guarantees | locals, parameters |
T& | non-owning, non-null | parameters 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.OnceCallbackruns 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 everyUnretainedas 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::HashSetin Blink;base::flat_map,base::span,std::optionalelsewhere.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:
- Match the file you are editing. Style is enforced by
git cl format— run it and stop thinking about formatting. - Correct handle type. In Blink GC classes,
Member<T>for owned graph edges. Getting this wrong is the most likely substantive review comment. DCHECKyour assumptions. Adding aDCHECKstating your new invariant is usually welcomed, and it is how you document intent in a codebase that discourages comments restating code.- Tests in the right style. Blink
foo_test.cc, Chromiumfoo_unittest.cc. Copy the nearest existing test's structure exactly. - 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:
| Document | Size | What it governs |
|---|---|---|
c++-features.md | ~78 KB | which C++ language/library features are allowed, banned, or under discussion |
c++.md | ~20 KB | the general Chromium C++ style |
c++-dos-and-donts.md | ~18 KB | accumulated guidance and anti-patterns |
blink-c++.md | ~8 KB | Blink-specific rules on top |
const.md, checks.md | const 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.
The Blink-specific rules
blink-c++.md is short and its section headings are the whole message:
- Prefer WTF types over STL and base types
- Do not use
newanddelete - 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.
| Macro | Retained 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_CHECK | staged | a 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:
- A
CHECKis 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. DUMP_WILL_BE_CHECKis a migration in progress — someone wants this to be aCHECKbut 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.
| Mechanism | What 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 |
| PartitionAlloc | the allocator, with partitioning that makes some exploitation harder |
| Rust interop | new, isolated, untrusted-input parsers written in a safe language |
The Rule of Two (bi-02) | the architectural constraint that drives sandboxing decisions |
| Clang plugins | mechanical 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.
- Is it
GarbageCollected? That decides the entire ownership vocabulary. - Read
Trace()first — the authoritative list of what it keeps alive. - Scan the member types:
Member(graph edge),raw_ptr(non-owning),unique_ptr(owned),scoped_refptr(shared),WeakPtr(may vanish). - Look for
SEQUENCE_CHECKER/THREAD_CHECKER— which thread does this belong to? - Read the
DCHECKs andCHECKs — the invariants, stated executably. - Find the
Create()factory orMakeGarbageCollectedcall sites — who constructs it, and who therefore owns its lifetime? - Open the
_test.cc— the enumerated edge cases. - 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.
- 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).
- Find one
base::Unretainedin the tree. Determine what guarantees the receiver outlives the callback. If you cannot in ten minutes, note that — say why it was hard. - Find a class with
SEQUENCE_CHECKER. Name the sequence it belongs to, and how you know. - Find one
raw_ptr<T>member and oneMember<T>member. Explain why each is right in its context and what breaks if swapped. - 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.mdand_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
-
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? -
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? -
Chromium builds without exceptions. What does this simplify, what does it make more verbose, and how are error paths expressed instead?
-
Blink has two ownership vocabularies (Oilpan and
//base). Argue for unifying them. What is the actual obstacle? -
AtomicStringmakes name comparison a pointer compare. What does interning cost, and when is it the wrong choice? -
You are reviewing a first-time contributor's CL that adds a raw
T*member to aGarbageCollectedclass. Write the review comment: correct, specific, not discouraging. -
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. -
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.mdand_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
| Bar | Requires |
|---|---|
| Reading Chromium C++ | ownership idioms, callbacks, threading annotations, Oilpan handles, the ability to skip |
| Writing a landable CL | the 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
- Every pointer answers "who owns this."
unique_ptrsole,scoped_refptrshared,raw_ptrnon-owning member,Member<T>graph edge inside the GC heap. - Every callback answers "what keeps the receiver alive." Ownership,
scoped_refptr,WeakPtr(may not run), orUnretained(a promise you must verify). - Every object with a
SEQUENCE_CHECKERbelongs to one sequence. Touching it elsewhere is a bug the checker will catch in adcheck_always_onbuild. CHECKvsDCHECKclassifies 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.
- 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).
- Find one
base::Unretainedin the tree. Determine what guarantees the receiver outlives the callback. If you cannot in ten minutes, note that — say why it was hard. - Find a class with
SEQUENCE_CHECKER. Name the sequence it belongs to, and how you know. - Find one
raw_ptr<T>member and oneMember<T>member. Explain why each is right in its context and what breaks if swapped. - 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)
| What | Predicted | Actual | Gap 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::Unretainedfound and its lifetime guarantee established (or the difficulty noted) -
One
raw_ptr<T>and oneMember<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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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 .cstarts at.cand 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:
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.- DOM change → pending invalidations. A mutation (say, adding class
foo) looks upfooin the feature set and produces invalidation sets, stored in aPendingInvalidationsMap— deliberately not applied yet. - 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
activein theRuleFeatureSet. - 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
--foochange 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.
| Concern | What to look for |
|---|---|
| Parsing | CSS parser + tokenizer in core/css/ |
| Property definitions | css_properties.json5 — declared, not written (bi-01 Technique 4) |
| Keywords | css_value_keywords.json5 |
| Rule indexing | RuleSet, RuleFeatureSet |
| Selector matching | SelectorChecker::MatchSelector |
| Computed style production | Element::StyleForLayoutObject |
| Pending invalidation state | PendingInvalidationsMap |
| Style storage fields | computed_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: value | Properties | Reading |
|---|---|---|
["layout", "paint"] | 95 | geometry and appearance — the expensive class |
["paint"] | 50 | appearance only — no geometry work |
["layout"] | 34 | geometry only |
["layout", "scroll-anchor"] | 13 | also disturbs scroll anchoring |
["color"] | 10 | a dedicated colour-only path |
["text-decoration"] | 8 | narrower than "paint" |
["border-radius", "paint"] | 8 | |
["reshape"] | 7 | text shaping must be redone |
["transform-data", "transform-other"] | 5 | property-tree data, not paint |
["compositing"] | 4 | compositor-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
styleattributes (each is unique), - unique ids referenced by a rule,
- sibling-position-dependent selectors (
:nth-child,+,~), :hover/:focusstate 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.
| Value | What it is | Example |
|---|---|---|
| Specified | what the cascade selected, after inherit/initial | width: 50% |
| Computed | resolved as far as possible without layout | 50% stays 50%; em → px |
| Used | after layout, when geometry is known | 50% → 320px |
| Resolved | what getComputedStyle() returns — used value for some properties, computed for others | varies 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 {}.
- Tokenizer + parser producing a rule list.
- Selector representation and right-to-left matching.
- Specificity, cascade, inheritance, computed style.
- Deliberately naive first: on any DOM change, recompute every element's style. Measure on a 10,000-node tree.
- Then build an index. Bucket rules by key selector. Measure again.
- Then build invalidation sets. On
classList.add(x), consult a precomputed map from class → affected-descendant descriptors. Measure again. - 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
- Invalidate too little. Deliberately omit sibling invalidation. Construct markup that renders incorrectly. This is the bug class the complexity exists to prevent.
- Invalidate too much. Fall back to whole-document invalidation always. Show it is correct and unusable.
- 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.
- 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
- DevTools → Performance: capture a
classList.addthat triggers recalc. Find the number of elements affected. Find where DevTools reports it. - Construct two changes with identical visual effect but 100× different recalc scope. Explain the difference by selector shape alone.
- Trace with the
blinkcategory and locate style-recalc trace events. Correlate one back to source by grepping its literal event name (bi-01, rung 3 → rung 1). - 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.mdthird_party/blink/renderer/core/css/style-calculation.mdthird_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
-
A team reports style recalc regressed 5×. Give three mechanisms, and the single cheapest observation that discriminates them.
-
Explain right-to-left selector matching to a senior engineer, then explain precisely why "avoid deep selectors" is a half-truth.
-
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?
-
Argue that whole-subtree invalidation fallback is a bug. Then argue it is the correct engineering decision. What evidence would settle it?
-
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.
-
:has()was added despite known performance risk. Reconstruct the argument for shipping it. What would you have required before enabling it by default? -
Container queries create a style→layout→style dependency. Design a rule that makes this terminate. What does your rule forbid, and would authors notice?
-
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.
-
An engineer proposes banning descendant selectors org-wide via lint. Strongest case for, strongest case against, and what you actually do.
-
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.mdthird_party/blink/renderer/core/css/style-calculation.mdthird_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
- 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.
- The stylesheet is compiled into an index before any element is matched.
RuleFeatureSetmaps feature → invalidation sets;RuleSetbuckets rules by key selector. - Selectors match right-to-left, from the key selector upward.
- Identical computed styles are shared. Anything that distinguishes elements defeats it.
- 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.
| Declaration | Count |
|---|---|
["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
| Break | Consequence |
|---|---|
| Omit sibling invalidation | +/~ selectors render stale — the bug the complexity prevents |
| Always fall back to subtree | correct and unusable |
| Defeat style sharing (inline styles on 10k rows) | memory and recalc cliff, not slope |
:has() used casually | inverts matching direction; can widen invalidation dramatically |
getComputedStyle in a write loop | forced 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 {}.
- Tokenizer + parser producing a rule list.
- Selector representation and right-to-left matching.
- Specificity, cascade, inheritance, computed style.
- Deliberately naive first: on any DOM change, recompute every element's style. Measure on a 10,000-node tree.
- Then build an index. Bucket rules by key selector. Measure again.
- Then build invalidation sets. On
classList.add(x), consult a precomputed map from class → affected-descendant descriptors. Measure again. - 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
- Invalidate too little. Deliberately omit sibling invalidation. Construct markup that renders incorrectly. This is the bug class the complexity exists to prevent.
- Invalidate too much. Fall back to whole-document invalidation always. Show it is correct and unusable.
- 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.
- 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
- DevTools → Performance: capture a
classList.addthat triggers recalc. Find the number of elements affected. Find where DevTools reports it. - Construct two changes with identical visual effect but 100× different recalc scope. Explain the difference by selector shape alone.
- Trace with the
blinkcategory and locate style-recalc trace events. Correlate one back to source by grepping its literal event name (bi-01, rung 3 → rung 1). - 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
| System | Index | Invalidated by |
|---|---|---|
| Blink style | RuleFeatureSet | a class/id/attribute change |
Reactive runtime (fw-03) | dependency sets | a signal write |
Build system (fw-07) | dependency graph | a file change |
Query cache (fw-09) | key → subscribers | a mutation |
| Database | secondary index | a row write |
| CDN | cache tags | a 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: noneproduces no layout object at all. This is why it is cheaper thanvisibility: 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.mddevotes 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
| Concern | What to look for |
|---|---|
| Box tree nodes | LayoutObject, LayoutBox, LayoutBlock |
| Layout entry point | the node type that drives an algorithm (block_node.*) |
| Inputs | constraint_space.h (very large — read the comments, not the whole file) |
| Algorithms | layout_algorithm.h and the per-formatting-context algorithms |
| Outputs | fragment builders and physical fragments |
| Per-mode subdirectories | flex/, grid/, inline/, table/, svg/, mathml/ |
| Docs | core/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.
| Space | Used by | Named with |
|---|---|---|
| Physical | paint, and anything display-facing | top, right, bottom, left |
| Logical | layout, generalised over writing mode and direction | before, after, start, end |
| Logical without inline flipping ("logical block") | layout internals | LogicalLeft, 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,
clientWidthandoffsetWidthdiffer 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-rootis 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
- Adjacent siblings — bottom margin of one collapses with top margin of the next.
- Parent and first/last child — collapse through if no border, padding, inline content, or BFC separates them.
- 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-contentchain 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-sizeandcontent-visibilityhelp 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
| Fact | Value | Consequence |
|---|---|---|
| Coordinate spaces in layout/paint | 4 | why the code reads oddly |
| Layout subdirectories by formatting context | flex/, grid/, inline/, table/, svg/, mathml/, … | each is a distinct algorithm |
constraint_space.h | ~69 KB | the inputs alone are that complex |
layout_box.cc | ~165 KB | the box is the workhorse |
Properties invalidating layout (bi-07) | 34 layout-only + 95 layout+paint | 129 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.
- Layout tree construction: skip
display:none, synthesise an anonymous box for at least one case. - Block layout: width from containing block, height from content, margins/padding/border.
- Nested boxes and margin behaviour. Implement margin collapsing, then write down the three rules you had to encode.
- Inline layout: line boxes, text measurement, line breaking. Use a fixed-width font metric so it is deterministic.
- Basic flex:
flex-direction: row,flex-grow,flex-basis. - Intrinsic sizing: implement
max-contentfor a subtree. Observe that you now need a second pass. - 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
- 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.
- 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.
- Mutate a fragment after publishing it. Show a downstream consumer reading stale or inconsistent geometry. This is the bug immutability prevents.
- Physical-thinking bug. Hard-code left/right somewhere, then run your engine in RTL.
8. Debugging Exercise
- In DevTools, find layout events for a forced synchronous layout; confirm the count matches your loop's iterations.
- Construct two DOM changes with the same visual result, one triggering layout and one not. Explain by property.
- 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.
- 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
-
Explain forced synchronous layout to a senior engineer without using the word "reflow", and give the code smell that predicts it.
-
Blink's layout takes immutable inputs and produces immutable outputs. Name the specific capability this buys, and the equivalent decision in application state management.
-
display:nonevsvisibility:hiddenvscontent-visibility— compare by which pipeline stages each skips, and give a case where the cheapest one is the wrong choice. -
Intrinsic sizing requires a pre-pass. Design an API that would let authors opt out. What would break?
-
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?
-
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.
-
Fragmentation exists for pagination and multicol — features few sites use. Argue for removing support; then argue that the architecture is better for having it.
-
You must decide whether a
content-visibility: autorollout is safe across a large app. What do you measure, and what would make you stop? -
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.
-
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
- 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.
- Fragments are immutable. They can be referenced from paint without defensive copying.
- The cache key must be complete. An omitted input produces a wrong render with no error.
- Layout is expressed logically.
inline/block,start/end— physical thinking breaks in RTL and vertical writing modes. - 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
| Break | Consequence | Where it shows |
|---|---|---|
| Incomplete constraint-space key | stale geometry, no error | resize, container queries |
| Read geometry inside a write loop | O(n²) forced synchronous layout | the classic |
| Assume one element = one box | wrong for anonymous boxes, fragments, display:none | tables, multicol |
| Physical coordinates | breaks in RTL / vertical | i18n rollout |
Animate width/top | layout every frame | any 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'theight: 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.
- Layout tree construction: skip
display:none, synthesise an anonymous box for at least one case. - Block layout: width from containing block, height from content, margins/padding/border.
- Nested boxes and margin behaviour. Implement margin collapsing, then write down the three rules you had to encode.
- Inline layout: line boxes, text measurement, line breaking. Use a fixed-width font metric so it is deterministic.
- Basic flex:
flex-direction: row,flex-grow,flex-basis. - Intrinsic sizing: implement
max-contentfor a subtree. Observe that you now need a second pass. - 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
- 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.
- 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.
- Mutate a fragment after publishing it. Show a downstream consumer reading stale or inconsistent geometry. This is the bug immutability prevents.
- Physical-thinking bug. Hard-code left/right somewhere, then run your engine in RTL.
8. Debugging Exercise
- In DevTools, find layout events for a forced synchronous layout; confirm the count matches your loop's iterations.
- Construct two DOM changes with the same visual result, one triggering layout and one not. Explain by property.
- 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.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
| Concern | What to look for |
|---|---|
| Paint entry, painters per box type | core/paint/ |
| Display items and the display list | display item + list types in platform/graphics/ and core/paint/ |
| Paint chunks | chunk types alongside the display list |
| Property trees | property-tree node types; pre-paint tree builder |
| Paint invalidation | the paint invalidator |
| Hit testing | hit-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:
- Paint invalidation — mark what must be painted differently from the cached painting.
- 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
PaintControllerfor the entireLocalFrameView. 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:
| Layer | Mechanism | Skips |
|---|---|---|
| Display item caching | if a painter would create a DrawingDisplayItem identical to last time, reuse it | one item |
| Subsequence caching | SubsequenceRecorder in PaintLayerPainter::PaintContents() records all items in a scope; if the layer would produce identical items, reuse the whole run | an 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:
positionother thanstaticwith az-indexother thanautoposition: fixedorsticky(always)opacityless than 1transform,scale,rotate,translate,perspectiveother thannonefilter,backdrop-filter,mask,clip-pathother thannonemix-blend-modeother thannormalisolation: isolatewill-changenaming any property that would create onecontain: paint,contain: layout,content-visibilityother thanvisible- flex/grid items with
z-indexother thanauto view-transition-nameother thannone
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: transformpromotes 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: noneare 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:
| Tree | Question | Changed by |
|---|---|---|
| Transform | where is it? | transform, scroll offsets, device scale |
| Clip | what is it clipped to? | overflow, clip-path, border-radius clipping |
| Effect | how is it composited? | opacity, filter, mask, mix-blend-mode |
| Scroll | which 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
- Display list. Walk your layout tree in paint order and emit display items
(
{type, rect, color, …}). Do not draw yet. - Stacking contexts. Implement
z-indexand at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule. - Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
- Raster. Draw the display list to a canvas.
- 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.
- 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
- 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.
- Break paint order. Paint in DOM order instead. Find markup that renders wrong.
- Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
- Layer explosion. Put
will-change: transformon 5,000 elements. Measure memory. Explain why the "optimisation" lost.
8. Debugging Exercise
- DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
- Compare a
top-animated element with atransform-animated one in a Perfetto trace. Name the stages present in one and absent in the other. - Find a hit-test bug you can only explain via stacking contexts.
- 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
-
Explain to a senior engineer why
transformanimations are cheap, without saying "GPU." -
Property trees add a whole subsystem. Reconstruct the argument that justified them, and name what would be impossible without them.
-
will-changeis 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. -
Paint produces a display list rather than pixels. Name three capabilities this enables.
-
A designer wants a blur-heavy UI. Predict the pipeline consequences, and say what you would measure before agreeing.
-
Stacking contexts make
z-indexlocal. Argue this is good design; then describe the most common way it confuses engineers and how you would teach around it. -
Hit testing walks paint order, not DOM order. Give a realistic accessibility or UX bug this causes, and how you would detect it systematically.
-
The browser decides compositing layers heuristically. Argue for exposing full manual control to authors; then argue against. What do you actually want?
-
Your app drops frames only while scrolling on low-end Android. Enumerate paint- and raster-side causes, and the order you would investigate them.
-
You are asked whether a design system should ban
opacitytransitions 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
- Paint produces a recording, not pixels. Display items are drawing commands; raster happens later, elsewhere, possibly on another thread or process.
- A paint chunk is a run of display items sharing one property tree state. That definition is what makes property-tree-only updates cheap.
- Paint order is not DOM order. Stacking contexts are atomic;
z-indexis scoped to them. - Cached paint output is reused when nothing that affects it changed — at display-item and at subsequence granularity.
- 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
| Break | Consequence |
|---|---|
| Bake transforms into display items | the cheap animation path becomes impossible |
| Paint in DOM order | overlapping content renders wrong |
| Skip paint invalidation for a property | stale pixels |
will-change on thousands of elements | memory blow-up; the "optimisation" loses |
| Independent snapping of adjacent boxes | 1px 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
- Display list. Walk your layout tree in paint order and emit display items
(
{type, rect, color, …}). Do not draw yet. - Stacking contexts. Implement
z-indexand at least one property that creates a stacking context. Prove ordering with a test case that changes when you remove the stacking-context rule. - Property trees. Add a transform tree. Represent a translated subtree as a node reference rather than by baking coordinates into items.
- Raster. Draw the display list to a canvas.
- 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.
- 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
- 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.
- Break paint order. Paint in DOM order instead. Find markup that renders wrong.
- Invalidate too little. Skip paint invalidation for a property that needs it; produce a stale-pixels bug.
- Layer explosion. Put
will-change: transformon 5,000 elements. Measure memory. Explain why the "optimisation" lost.
8. Debugging Exercise
- DevTools: enable paint flashing and layer borders. Find one change that repaints and one that does not.
- Compare a
top-animated element with atransform-animated one in a Perfetto trace. Name the stages present in one and absent in the other. - Find a hit-test bug you can only explain via stacking contexts.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
| System | Recording | Executor |
|---|---|---|
| Blink paint | display list | raster / GPU |
| Compositor | CompositorFrame (quads) | viz / DirectRenderer |
| Virtual DOM | element tree | reconciler |
| SQL | query plan | execution engine |
| Graphics APIs | command buffer | GPU |
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/touchstartlistener that might callpreventDefault(), - certain
position: fixed/stickyandbackground-attachment: fixedsituations, - scroll-linked effects implemented in JS
scrollhandlers.
{ 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
| Concern | Where |
|---|---|
| Compositor | //cc — layer trees, tiling, raster scheduling, animation |
| Display compositor, command buffer | //components/viz, //gpu |
| Blink side of compositing | core/paint/ + the compositing decisions in pre-paint |
| Scheduling frames | the 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.
| Term | Definition |
|---|---|
| Layer | a conceptual piece of content with a known position relative to the viewport. Main thread only. |
| LayerImpl | the same thing, on the compositor thread |
| Active tree | the layers + property trees used to submit a CompositorFrame. Composited effects — scrolling, pinch, animations — are done by modifying the active tree |
| CompositorFrame | a set of RenderPasses (each a list of DrawQuads) plus metadata — "the instructions for how to draw an entire scene presented in a surface" |
| DrawQuad | one primitive draw instruction (a textured rect, a solid colour, …) |
| RenderPass | a group of quads drawn to an intermediate target |
| ElementID | a stable identifier across updates, chosen by cc's clients; Blink uses it to identify the object responsible for a composited animation |
| DirectRenderer | abstraction for drawing an aggregated CompositorFrame to a physical output; backends are GL, Skia, or Software |
| CopyOutputRequest | a 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 overruns | Symptom | Usual cause |
|---|---|---|
| main-thread frame | jank, input delay | long tasks, forced sync layout, heavy rAF |
| commit | periodic hitches | huge layer trees, many property changes |
| raster | checkerboarding on scroll | expensive paint, large layers, many tiles |
| activate | stale content | tiles not ready; raster outran |
| draw / aggregate | dropped frames on a busy system | GPU 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: transformon 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:
- Non-passive
wheel/touchstart/touchmovelisteners — the compositor must wait to see whether you callpreventDefault(). scrollevent 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.background-attachment: fixedand someposition: fixed/stickyconfigurations.- 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:
transformandopacitymap to property-tree node changes (bi-09).- A property-tree change does not require re-recording display items.
- Not re-recording means not re-rastering.
- The compositor can therefore produce a new frame from already-rastered tiles.
- 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.
- 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.
- Passive vs non-passive. Same page, one non-passive
touchstart/wheellistener. Measure scroll latency with and without. Explain the mechanism, then explain the number. - Compositor vs main-thread animation. Animate the same visual effect via
transformand viatop. Block the main thread with a 500 ms task mid-animation. Record both. Describe what the user sees in each case. - 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.
- 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
- Add a non-passive wheel listener that does nothing. Show the cost.
- Animate
box-shadow(paint-heavy) vstransform. Compare frames. - Force a giant layer (
will-change: transformon a very large element). Measure memory. - Write a
scrollhandler 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
- Perfetto: capture a scroll. Identify the compositor thread, the raster workers, and the GPU process. Follow one frame's flow arrows end to end.
- Find a frame that was dropped. Determine which stage overran, from the trace alone.
- DevTools: use layer borders and the rendering panel to find an unexpected composited layer. Determine what created it.
- Determine whether a given animation is running on the compositor. State your evidence.
9. References
//ccdocumentation in-tree;//components/vizdocs.- Life of a Pixel; the Chromium rendering team's compositor documentation.
- CSS Transforms, Web Animations, Scroll-driven Animations specs.
EventTarget.addEventListenerpassive listeners (DOM spec) and the interventions that made some listeners passive by default.
10. Principal Engineer Review
-
Explain why a page with a blocked main thread still scrolls, in four sentences, mechanically.
-
{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. -
Your app is smooth at 60 Hz and janky at 120 Hz. Give the likeliest causes in order and how you would confirm each.
-
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?
-
A team proposes implementing a parallax effect with a
scrolllistener. Give the mechanism- level objection and two alternatives, with their limitations. -
Compositing decisions are heuristic. Design the API you would give authors instead. What would go wrong when they use it?
-
Checkerboarding is user-visible incorrectness that browsers ship deliberately. Justify it. Under what conditions would blocking be better?
-
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?
-
Raster can happen on CPU or GPU, with a fallback path. Argue for removing the CPU path. What breaks, and for whom?
-
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.
//ccdocumentation in-tree;//components/vizdocs.- Life of a Pixel; the Chromium rendering team's compositor documentation.
- CSS Transforms, Web Animations, Scroll-driven Animations specs.
EventTarget.addEventListenerpassive 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
- The active tree can produce frames without the main thread. This is the property that makes scroll and compositor animations survive jank.
- Half-rastered content is never shown. The pending tree activates only when its tiles are ready.
- A
CompositorFrameis instructions, not pixels — render passes of draw quads. - 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.
- Damage is tracked, so the GPU redraws only what changed.
Where a frame is lost
| Stage overruns | Symptom | Usual cause |
|---|---|---|
| main-thread frame | jank, input delay | long tasks, forced sync layout, heavy rAF |
| commit | periodic hitches | huge layer trees, many property changes |
| raster | checkerboarding | expensive paint, large layers |
| activation | stale content | raster outran |
| draw / aggregate | dropped frames | GPU 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.
- 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.
- Passive vs non-passive. Same page, one non-passive
touchstart/wheellistener. Measure scroll latency with and without. Explain the mechanism, then explain the number. - Compositor vs main-thread animation. Animate the same visual effect via
transformand viatop. Block the main thread with a 500 ms task mid-animation. Record both. Describe what the user sees in each case. - 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.
- 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
- Add a non-passive wheel listener that does nothing. Show the cost.
- Animate
box-shadow(paint-heavy) vstransform. Compare frames. - Force a giant layer (
will-change: transformon a very large element). Measure memory. - Write a
scrollhandler 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
- Perfetto: capture a scroll. Identify the compositor thread, the raster workers, and the GPU process. Follow one frame's flow arrows end to end.
- Find a frame that was dropped. Determine which stage overran, from the trace alone.
- DevTools: use layer borders and the rendering panel to find an unexpected composited layer. Determine what created it.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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-01teaches 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
| Primitive | Yields to rendering? | Yields to input? |
|---|---|---|
queueMicrotask / await | no | no |
setTimeout(f, 0) | yes | yes, but low priority and clamped |
MessageChannel postMessage | yes | yes; historically the lowest-latency macrotask yield |
scheduler.postTask({priority}) | yes | yes, with explicit priority |
scheduler.yield() | yes | yes, and resumes with continuation priority |
requestIdleCallback | yes | runs 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
| Concern | Where |
|---|---|
| Main-thread scheduler | platform/scheduler/main_thread/ |
| Per-frame scheduling / throttling | frame_scheduler_impl.* |
| Agent group scheduling | agent_group_scheduler_impl.* |
| Idle estimation | idle_time_estimator.* |
| Task queue plumbing | platform/scheduler/base/ |
| Docs | platform/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.
ResizeObserverruns 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
| Primitive | Yields to render? | Yields to input? | Continuation priority | Notes |
|---|---|---|---|---|
queueMicrotask, await | no | no | n/a | not a yield at all |
setTimeout(f, 0) | yes | yes | low; clamped | 4 ms clamping after nesting depth 5 |
MessageChannel | yes | yes | back of the queue | historically the lowest-latency macrotask yield |
scheduler.postTask({priority}) | yes | yes | as specified | explicit user-blocking / user-visible / background |
scheduler.yield() | yes | yes | continuation — ahead of newly-arrived same-priority work | the point of the API |
requestIdleCallback | yes | yes | idle only | may 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
MessageChannelyielding, 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
- Priority inversion experiment. Post work at several
scheduler.postTaskpriorities plussetTimeoutandMessageChannel. Register a click handler. Measure ordering and input delay under a synthetic main-thread load. Predict the ordering first. - Yield-cost curve. Take a 200 ms computation. Chunk it yielding every 0.5/5/50 ms via
MessageChannel, then viascheduler.yield(). Plot total time and p75 input delay. Find where each curve turns. - rAF vs timer. Animate with both under load. Measure frame alignment and dropped frames.
- Continuation priority. Construct a case where
MessageChannelyielding loses to unrelated tasks andscheduler.yield()does not. This is the experiment that proves why the API exists. - 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
- Recursive
queueMicrotask— freeze the tab. Confirm the debugger cannot break in cleanly, and explain why in terms of the checkpoint. - Starve rendering with a chain of high-priority tasks. Find where anti-starvation kicks in.
- 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
- Perfetto with
toplevel+blink+cc: identify task boundaries, the microtask checkpoint, and the rendering opportunity in one trace. - Find a long task and attribute it to a task queue/source.
- Correlate a
TRACE_EVENTname from the trace back to its source (bi-01, rung 3 → rung 1). - 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
-
A timer registered before a click handler runs after it. Explain, then say what this implies for code that assumes registration order.
-
scheduler.yield()resumes with continuation priority. Reconstruct the problem this solves from first principles, and describe the bug you would see without it. -
React's scheduler prefers
MessageChannel. Reconstruct that decision. What would break with microtasks, and withsetTimeout? -
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?
-
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?
-
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.
-
Throttling is per-frame. What does this enable that per-page throttling would not, and what abuse does it invite?
-
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? -
When is a long task the right engineering decision? Give a concrete case and the invariant yielding would violate.
-
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
- 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.
- Input has the highest priority, so a flood of timers cannot starve a click.
- Anti-starvation bounds prioritisation. Strict priority without it produces a browser where a busy animation permanently blocks lower-priority work.
- A microtask checkpoint drains exhaustively, including microtasks enqueued during the drain —
so
awaitis not a yield. - Rendering happens between tasks, never inside one. DOM mutations are invisible until the current task ends.
- Policies are per-frame, not per-page. A background iframe can be throttled independently.
The five policies, with their constants
| Policy | Mechanism | Constant |
|---|---|---|
| Priorities | input highest; compositor high during gestures | default normal |
| Pausing | ScopedPagePauser, nested run loop | no JS during alert()/print()/breakpoints |
| Deferring | after a user gesture | 2 seconds |
| Freezing | background pages | 5 minutes on mobile; heuristics on desktop |
| Throttling | JS 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
| Mistake | Consequence |
|---|---|
| Chunk with microtasks | no responsiveness gain; a runaway chain freezes the tab |
setTimeout(f, 0) in a hot loop | 4 ms clamp after nesting depth 5 → ~250 chunks/s ceiling |
| Heavy work in rAF | runs before rendering, so it directly delays the frame |
Yield too often via MessageChannel | continuation goes behind unrelated tasks; your own work starves |
| Benchmark unthrottled on desktop | priority 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
- Priority inversion experiment. Post work at several
scheduler.postTaskpriorities plussetTimeoutandMessageChannel. Register a click handler. Measure ordering and input delay under a synthetic main-thread load. Predict the ordering first. - Yield-cost curve. Take a 200 ms computation. Chunk it yielding every 0.5/5/50 ms via
MessageChannel, then viascheduler.yield(). Plot total time and p75 input delay. Find where each curve turns. - rAF vs timer. Animate with both under load. Measure frame alignment and dropped frames.
- Continuation priority. Construct a case where
MessageChannelyielding loses to unrelated tasks andscheduler.yield()does not. This is the experiment that proves why the API exists. - 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
- Recursive
queueMicrotask— freeze the tab. Confirm the debugger cannot break in cleanly, and explain why in terms of the checkpoint. - Starve rendering with a chain of high-priority tasks. Find where anti-starvation kicks in.
- 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
- Perfetto with
toplevel+blink+cc: identify task boundaries, the microtask checkpoint, and the rendering opportunity in one trace. - Find a long task and attribute it to a task queue/source.
- Correlate a
TRACE_EVENTname from the trace back to its source (bi-01, rung 3 → rung 1). - 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)
| What | Predicted | Actual | Gap 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-01completed 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
| Question | Instrument |
|---|---|
| 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:
- wrong process,
- the function was inlined (set it on the caller),
- 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:
| Category | Uses in core/ |
|---|---|
blink | 204 |
navigation | 41 |
loading | 20 |
ime | 19 |
input | 17 |
blink.worker | 17 |
devtools | 11 |
devtools.timeline | 4 |
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:
| Question | Categories |
|---|---|
| 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 |
| Memory | memory-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-10concrete 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.
| Situation | Instrument | Why |
|---|---|---|
| No hypothesis at all | tracing | a breakpoint requires knowing where to put it |
| "Does this code run?" | tracing, or a one-line LOG | a breakpoint answers this at 100× the cost |
| "How often, how long?" | tracing | the debugger destroys the timing you are measuring |
| "How did I get here?" | debugger backtrace | nothing else gives you the stack |
| "What is this value?" | debugger, or LOG | |
| A sequence across many iterations | logging | stepping 400 times is not a plan |
| Timing-dependent / race | tracing only | breakpoints change the schedule and hide the bug |
| Intermittent, 1-in-50 | tracing with a long buffer | you cannot sit at a breakpoint waiting |
| Cross-process ordering | tracing with flow events | the 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.
3.7 Deep dive: making a Blink breakpoint actually hit
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_EVENTorLOG, 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 thread —
ccimpl 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.
| Tool | Answers |
|---|---|
memory-infra tracing | per-process, per-allocator breakdown over time |
| DevTools heap snapshot | what is retaining this JS object (retainer chains) |
chrome://memory-internals | process-level totals |
| Oilpan statistics | Blink 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
- Thread census. Attach to a renderer,
thread backtrace all, and write down every thread name. Map each tobi-02's diagram. Note any you did not expect. - 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.
- Conditional breakpoint. Break in
setAttributeonly when the attribute isclass. - Trace-to-source. Record a trace, pick three unfamiliar event names, and find each in the source. Write down what each one measures.
- Source-to-trace. Add your own
TRACE_EVENTto a Blink function you studied inbi-03orbi-07. Rebuild that target only. Confirm it appears. - 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
- Build with
symbol_level = 0and try to debug. Experience it once; it explains the flag. - Set a Blink breakpoint on the browser process and observe it never hit. Diagnose from first principles.
- Break in a function that gets inlined. Find the workaround.
- Attach to a renderer without
--disable-hang-monitorand wait 30 seconds. Explain the kill.
8. References
docs/mac/debugging.md,docs/lldbinit.mdin your checkout.docs/tracing documentation; Perfetto UI docs.base/trace_event/— theTRACE_EVENTmacros themselves.bi-00-roadmap/docs/chromium-build-debug-trace.md— your operational reference.
9. Principal Engineer Review
-
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.
-
DCHECKis 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. -
A teammate debugs with
--single-processbecause it is convenient. Explain the risk with a concrete example of a conclusion it would make them draw wrongly. -
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?
-
Trace events are string identifiers in source. Argue this coupling is good design; then name its failure mode at scale.
-
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.
-
When is adding a log statement better engineering than setting a breakpoint? Give two cases.
-
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.mdin your checkout.docs/tracing documentation; Perfetto UI docs.base/trace_event/— theTRACE_EVENTmacros 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
| Situation | Instrument | Why |
|---|---|---|
| No hypothesis | tracing | a breakpoint requires knowing where to put it |
| Does this run? | tracing / one LOG | a breakpoint costs 100× |
| How often, how long? | tracing | the debugger destroys the timing |
| How did I get here? | debugger backtrace | nothing else gives the stack |
| A sequence over many iterations | logging | stepping 400 times is not a plan |
| Timing-dependent or racy | tracing only | breakpoints change the schedule and hide the bug |
| Cross-process ordering | tracing with flow events | the only tool showing both sides |
Why a breakpoint fails to hit
Three causes, in frequency order:
- Wrong process — Blink runs in a renderer, not the browser process you launched.
- Inlined — set it on the caller, break on a line, or fall back to a trace event.
- Feature-gated — the path is behind a
RuntimeEnabledFeaturethat 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
- Thread census. Attach to a renderer,
thread backtrace all, and write down every thread name. Map each tobi-02's diagram. Note any you did not expect. - 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.
- Conditional breakpoint. Break in
setAttributeonly when the attribute isclass. - Trace-to-source. Record a trace, pick three unfamiliar event names, and find each in the source. Write down what each one measures.
- Source-to-trace. Add your own
TRACE_EVENTto a Blink function you studied inbi-03orbi-07. Rebuild that target only. Confirm it appears. - 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
- Build with
symbol_level = 0and try to debug. Experience it once; it explains the flag. - Set a Blink breakpoint on the browser process and observe it never hit. Diagnose from first principles.
- Break in a function that gets inlined. Find the workaround.
- Attach to a renderer without
--disable-hang-monitorand 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)
| What | Predicted | Actual | Gap 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_EVENTadded, target rebuilt, event observed -
A
DCHECKdeliberately 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
| Layer | Shape | Runs in | Good for |
|---|---|---|---|
| Unit test | foo_test.cc (Blink) / foo_unittest.cc (rest) | one process, no browser | algorithms, data structures, invariants |
| Browser test | *_browsertest.cc | real multi-process browser | cross-process behaviour, navigation, security |
| Web test | HTML + expected output, under web_tests/ | content_shell | rendering and DOM behaviour |
| WPT | HTML/JS under web_tests/external/wpt/ | any browser | spec 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.jstests — 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.
TestExpectationsis 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:
- A skipped test yields no data. A test marked
Failurethat unexpectedly passes is reported — so the system tells you when someone accidentally fixed it. - Flakiness is measurable only if the test runs.
Skipdestroys the signal you would need to decide whether it is flaky or broken. - 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
| Layer | Runs in | Cost | Catches | Misses |
|---|---|---|---|---|
Unit (_test.cc / _unittest.cc) | one process | ms | algorithm and invariant bugs | integration, real DOM behaviour |
Browser test (_browsertest.cc) | real multi-process browser | seconds+ | cross-process, navigation, security | fine-grained algorithm cases |
Web test (web_tests/) | content_shell | fast | DOM and rendering behaviour | non-web-exposed internals |
WPT (web_tests/external/wpt/) | any browser | fast | spec conformance, interop | Chromium-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
testharness.js— JS assertions, portable across browsers, states intent.- 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.
- 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-08andfw-09make 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
- 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. - Run each kind once. A Blink unit test, a web test directory, and a WPT.
- Write a
testharness.jstest for the parsing lab's Case B. Verify it passes on Chromium and reason about whether it should pass on other engines. - 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 yourbi-14candidate pool. - 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
- Write a test that passes without your change. Explain how you would have caught this.
- Rebaseline a pixel test to accept a wrong rendering. Notice how easy it was — this is the argument for preferring reftests.
- 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.mdand the web-test expectations documentation in your checkout.third_party/blink/web_tests/TestExpectations.- web-platform-tests documentation;
testharness.jsAPI. docs/testing/generally — browser tests, unit tests, flakiness policy.
9. Principal Engineer Review
-
Given a rendering bug, how do you decide the test layer? Give your decision procedure and a case where it is genuinely ambiguous.
-
TestExpectationsrecords known failures rather than deleting tests. Argue this is superior; then describe how it decays and what you would do about it. -
A WPT fails in Chromium and passes in two other engines. Enumerate what this could mean, ranked, and how you distinguish them.
-
Pixel tests catch everything and break constantly. Argue for banning them; then defend the cases where nothing else works.
-
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.
-
A contributor submits a correct fix with no test. Write the review comment.
-
WPT contributions improve all browsers. Argue this is the highest-leverage work an application engineer can do; then give the strongest counterargument.
-
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.mdand the web-test expectations documentation in your checkout.third_party/blink/web_tests/TestExpectations.- web-platform-tests documentation;
testharness.jsAPI. 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
- A test must fail for the reason the bug exists. A fix at the wrong layer with a passing test proves nothing.
- Run the test against unpatched source first. A test that passes before your fix is not a test.
- A failing test keeps running. Chromium forbids a bare
[ Skip ]so that flakiness data keeps accruing and an accidental fix is detected. - Expectations carry a bug link. An entry without one is an untracked known-broken behaviour.
- 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:
- A skipped test yields no data; an expected-failure that unexpectedly passes is reported.
- Flakiness is measurable only if the test runs.
- 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
| Layer | Catches | Misses |
|---|---|---|
| Unit | algorithms, invariants | integration, real DOM |
| Browser test | cross-process, navigation, security | fine-grained algorithm cases |
| Web test | DOM and rendering behaviour | non-web-exposed internals |
| WPT | spec conformance, interop | Chromium-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
| Mistake | Consequence |
|---|---|
| Rebaseline to make it pass | encodes a bug as expected |
| Pixel test for an algorithmic bug | brittle; rebaselined away in six months |
| Unit test for a rendering bug | does not demonstrate the user-visible fix |
| Assume a failing WPT is a Chromium bug | may 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.
TestExpectationsis 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
- 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. - Run each kind once. A Blink unit test, a web test directory, and a WPT.
- Write a
testharness.jstest for the parsing lab's Case B. Verify it passes on Chromium and reason about whether it should pass on other engines. - 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 yourbi-14candidate pool. - 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
- Write a test that passes without your change. Explain how you would have caught this.
- Rebaseline a pixel test to accept a wrong rendering. Notice how easy it was — this is the argument for preferring reftests.
- 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)
| What | Predicted | Actual | Gap 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.jstest written for the parsing lab's Case B -
Candidate pool built from
TestExpectationswith 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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 owndocs/contributing.mdstill contains abugs.chromium.orgreference — 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 ownerssuggests reviewers. Committers need oneCode-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:
| Rung | Change | Teaches |
|---|---|---|
| 0 | Build Chromium | toolchain, targets, iteration |
| 1 | Docs or test-only | the process end to end, at low risk |
| 2 | Small isolated correctness fix | locating a cause; minimal reproduction |
| 3 | Blink behaviour bug + regression test | spec → test → implementation, the core loop |
| 4 | Small rendering/style/layout improvement | subsystem depth, performance argument |
| 5 | Cross-component change | Mojo 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:
- Is the behaviour correct per the specification?
- Is there a test that fails without the fix?
- Is it at the right layer, and does it fit the subsystem's design?
- Ownership/lifetime correctness (
bi-06). - Style, naming, and
git cl format. - 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 formatbefore 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
DCHECKstating any new invariant. - Correct handle types (
Membervsraw_ptrvsunique_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 logfor 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:
| Field | Why |
|---|---|
| bug | the problem, as the project sees it |
| reproduction | the thing you will lose first if you do not write it down |
| spec | what should happen, independent of any implementation |
| suspected subsystem | your prediction — score it later (bi-01 discipline) |
| source path + call path | the navigation, so it transfers |
| test | what would have caught this |
| proposed fix | including alternatives rejected |
| reviewer feedback | the highest-value field |
| architectural lesson | what 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
- Rung 0 — a build that runs. (Blocked here; see the roadmap's build guide §2.0.)
- Rung 1 — land a docs or test-only change. Goal: complete the process once. Record every step that surprised you.
- Rung 2 — from your
bi-13candidate list, pick the smallest defensible correctness fix. Write the failing test first. - Rung 3 — a Blink behaviour bug with a regression test, in a subsystem you studied.
- 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.mdin your checkout — the authority.docs/cl_tips.md,docs/code_reviews.md, and theOWNERSdocumentation.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
-
Your fix is correct per spec but would break a measurable fraction of sites. What happens next, and what is your role in it?
-
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?
-
Argue that a well-written bug report is more valuable than a mediocre fix. Give the case where it is not.
-
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.
-
You have one week of a team's time for open-source contribution. Chromium, WPT, or a framework? Justify from leverage, not from prestige.
-
Your CL sits unreviewed for three weeks. Enumerate your options in order of escalation, and say which you would actually use.
-
What makes a change "cross-component," and why is that qualitatively harder than a large change within one component?
-
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.mdin your checkout — the authority.docs/cl_tips.md,docs/code_reviews.md, and theOWNERSdocumentation.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
- One change per CL. A fix plus a refactor plus a rename is three CLs.
- A test that fails without the fix, at the layer the bug lives.
- The right layer. Fixing a symptom at a call site because the cause is in unfamiliar code is the most common rejection.
- Correct handle types and lifetimes (
bi-06). - 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
- Rung 0 — a build that runs. (Blocked here; see the roadmap's build guide §2.0.)
- Rung 1 — land a docs or test-only change. Goal: complete the process once. Record every step that surprised you.
- Rung 2 — from your
bi-13candidate list, pick the smallest defensible correctness fix. Write the failing test first. - Rung 3 — a Blink behaviour bug with a regression test, in a subsystem you studied.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
- Which layers are involved, and which are skipped?
- Which thread and process is each step on?
- What is deferred vs immediate?
- What would make this operation dramatically more expensive?
- 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:
DOMTokenListmutation; 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
wheellistener. Re-trace. What changed and where? scrollevent 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
BeginFrameand 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.
| Operation | Framework | DOM | Style | Layout | Paint | Composite | GPU |
|---|---|---|---|---|---|---|---|
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) | — | — | forced | forced | — | — | — |
setState → text change | ✓ | ✓ | ✓ | maybe | ✓ | ✓ | ✓ |
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 grepandgit log -Son the local checkout — rung 2, and faster than Code Search,- the declarative files —
css_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
-
Pick any two traces. Name the layer where they diverge most, and what that says about the cost model a developer should carry.
-
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.
-
Which of the eight operations has the largest gap between "what developers think happens" and what happens? Justify.
-
You are teaching this to a team with two hours. Which single trace do you use, and why that one?
-
For each trace, name the one measurement you would put on a dashboard to detect regression.
-
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
- Every layer is resolved with evidence, or explicitly recorded unresolved.
- Process and thread are named at every resolved step.
- Deferred vs immediate is stated at every step — most of the pipeline is deferred, and saying so is most of the insight.
- A variant is constructed that changes which layers are involved. This is what proves mechanism rather than memorised path.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
| Guarantee | Unlocks |
|---|---|
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:
- Which framework work is scheduled, at what priority, and is it batched? (
fw-04) - Which components re-render, and why those? (
fw-02/fw-05— and the answer differs by architecture, which is the point) - Which DOM operations result? Count them. (
fw-02's instrumented DOM) - What does that mutation invalidate? Which elements, which stages — cite
css_properties.json5(bi-07) - Does layout run? For which boxes, and does anything force it early? (
bi-08) - Does paint re-record, or is this a property-tree change? (
bi-09) - Which thread and process does each step run on, and where are the hops? (
bi-02,bi-10) - 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
-
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. -
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?
-
Name three pieces of production complexity you now believe are not essential. Defend each with evidence, and say what you would do instead.
-
You are given a rendering performance problem in a codebase you have never seen, and two hours. Write the procedure.
-
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):
- Framework scheduling, priority, and batching — named.
- Which components re-render, and why those.
- The DOM operations — counted, not described.
- What the mutation invalidates, cited against
css_properties.json5. - Whether layout runs, for which boxes, and what could force it early.
- Whether paint re-records or only a property-tree node changes.
- Thread and process at each step, and where the hops are.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
- Author-supplied guarantees unlock impossible optimisations.
- Record cheaply, resolve precisely, once per frame.
- Immutability is the enabling condition for caching.
- Incomplete cache keys fail silently.
- Author code must never observe a half-built state.
- Speculation is safe only when unobservable.
- Restrictions make circular systems analysable.
- 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
| Direction | Next step |
|---|---|
| Depth in Chromium | pick one subsystem and go to contribution rung 4–5 |
| Breadth in the platform | WPT contributions in areas you studied |
| Framework authorship | take fw-04's runtime to SSR and hydration |
| Organisational impact | write your team's cold-read procedure and cost model |
| Cross-track | fe-* 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
createStore(reducer)—getState,dispatch,subscribe.combineReducers.- Middleware +
applyMiddleware. - Enhancers — and articulate why these are a different extension point from middleware.
- Selectors with memoisation.
- Action/state recording.
- Time travel.
- 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.
- Dispatch inside a reducer. What breaks, and why is a guard better than "don't do that"?
- 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.
- 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.
- Middleware that dispatches synchronously in its own path. Find the re-entrancy.
- 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:
- 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. - The fix is a snapshot, and the cost is one array copy per dispatch only when the list
changed (the
ensureCanMutateNextListenerspattern: 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:
| Model | Cost to answer "did this change?" |
|---|---|
| Immutable + reference compare | O(1) |
| Deep equality | O(size) |
| Dirty flags | O(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-reduxand 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
-
Redux notifies every subscriber on every dispatch. Defend this as good design, then say what it forces every consuming framework to build.
-
Explain why immutability is load-bearing here in terms of change detection cost, not purity.
-
Middleware and enhancers are two extension points. Design a single mechanism that replaces both. What do you lose?
-
Time travel is nearly free in Redux and expensive in a fine-grained reactive system. Explain why from the data model.
-
A team wants to adopt Redux for a form-heavy app with high-frequency local state. Argue against, mechanically.
-
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?
-
Redux is ~200 lines of core and enormous ecosystem. What does that ratio tell you about where the real design decisions were made?
-
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
- A reducer sees a stable world. Dispatching inside a reducer means computing state from a moving target; the result is undefined, not merely wrong.
- The listener list is fixed when notification begins. Subscribing or unsubscribing mid-dispatch has no effect on the dispatch in progress.
- State is replaced, never mutated. This is what makes change detection O(1).
- The reducer is pure, so replay is deterministic — which is what makes time travel free.
Why each guard exists
| Guard | Bug without it | Detectability |
|---|---|---|
isDispatching | state computed from a moving target | silent, nondeterministic |
| Listener snapshot | a subscriber is skipped when another unsubscribes mid-loop | silent, intermittent |
| Immutability (by convention) | memoised selectors see no change; UI goes stale | silent |
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
| Model | Cost of "did this change?" |
|---|---|
| Immutable + reference compare | O(1) |
| Deep equality | O(size) |
| Dirty flags | O(1), but maintained everywhere |
| Proxy interception | O(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
createStore(reducer)—getState,dispatch,subscribe.combineReducers.- Middleware +
applyMiddleware. - Enhancers — and articulate why these are a different extension point from middleware.
- Selectors with memoisation.
- Action/state recording.
- Time travel.
- 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.
- Dispatch inside a reducer. What breaks, and why is a guard better than "don't do that"?
- 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.
- 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.
- Middleware that dispatches synchronously in its own path. Find the re-entrancy.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
fw-01 — Broader Ideas
Where the guards reappear
| Guard | Same bug elsewhere |
|---|---|
| Listener snapshot | event emitters, animation-frame callbacks, observer lists, live NodeList iteration |
| Re-entrancy guard | any callback that can trigger the thing that invoked it |
| Immutability for change detection | React 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, afterbi-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
- Element model.
createElement(type, props, ...children)returning plain objects. No classes, no magic. Writeh('div', {id:'x'}, 'hi')by hand and look at the object. - DOM renderer. Render an element tree to real DOM. No diffing yet — mount only.
- Reconciliation. Given old and new trees, compute and apply the minimal DOM changes. Same-type nodes update in place; different types replace.
- 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.
- Function components. Components return element trees; render recursively.
- State.
useStatewith 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. - Effects.
useEffectwith dependency comparison, cleanup on unmount and on dep change. Get the cleanup ordering wrong at least once.
4. Failure Lab
- Unkeyed reorder. A list of inputs with text typed in them, reordered. Watch state follow position instead of identity. This is the canonical demonstration.
- Wrong key. Use array index as key on a reorderable list. Show the same bug returns.
- 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.
- Missing dependency. A stale closure reading old state. Then over-specify dependencies and produce an infinite effect loop. Both are real; describe the tension.
- 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:
| Design | Cost |
|---|---|
| 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 fields | explicit and safe; but no composition without mixins/HOCs — the problem hooks were created to solve |
| Compiler-assigned slots | best 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,useEffectwith 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
-
Explain the virtual DOM's value proposition in terms of cost ratios, and name the workload where it is a net loss.
-
Why can't the runtime infer list identity without keys? Give the theoretical answer and the practical one.
-
Hooks depend on call order. Design an alternative with the same composability. What does it cost in API surface or ergonomics?
-
useEffectdependency arrays are a manual correctness burden. Argue they are essential; then argue the compiler should do it (and note who has tried). -
A colleague says "React is slow because of the virtual DOM." Give the accurate version.
-
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?
-
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.
-
What would break if
setStatewere synchronous? Answer beforefw-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
- Elements are plain data. Descriptions, not DOM nodes; the runtime decides what to do with them.
- Same type and key ⇒ reuse the DOM node. Node identity is what carries state.
- Identity must be author-supplied for lists. The runtime cannot derive it.
- Hook order is stable per component instance. Positional storage requires it.
- 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
| Break | Consequence |
|---|---|
| Unkeyed reorder | state follows position, not identity — inputs keep the wrong values |
| Index as key | same bug, disguised |
| Conditional hook call | slot list shifts; every subsequent hook reads the wrong state |
| Missing effect dependency | stale closure reads a value from a previous render |
| Missing cleanup | subscriptions 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
- Element model.
createElement(type, props, ...children)returning plain objects. No classes, no magic. Writeh('div', {id:'x'}, 'hi')by hand and look at the object. - DOM renderer. Render an element tree to real DOM. No diffing yet — mount only.
- Reconciliation. Given old and new trees, compute and apply the minimal DOM changes. Same-type nodes update in place; different types replace.
- 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.
- Function components. Components return element trees; render recursively.
- State.
useStatewith 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. - Effects.
useEffectwith dependency comparison, cleanup on unmount and on dep change. Get the cleanup ordering wrong at least once.
4. Failure Lab
- Unkeyed reorder. A list of inputs with text typed in them, reordered. Watch state follow position instead of identity. This is the canonical demonstration.
- Wrong key. Use array index as key on a reorderable list. Show the same bug returns.
- 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.
- Missing dependency. A stale closure reading old state. Then over-specify dependencies and produce an infinite effect loop. Both are real; describe the tension.
- 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)
| What | Predicted | Actual | Gap 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,useEffectwith 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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-style | Vue reactivity | Signals | Redux | |
|---|---|---|---|---|
| How does it know what changed? | re-run and diff | tracked at property granularity | tracked at signal granularity | you tell it (actions) |
| Unit of update | component subtree | effect | effect | subscriber (all) |
| Author burden | keys, deps | almost none | almost none | action discipline |
| Cost scales with | tree size | number of reads | number of reads | number of subscribers |
| Debuggability | render trees, explicit | implicit graph | implicit graph | best: explicit log |
Fill this in yourself as you build; do not copy it. §47 wants your version.
3. Build order — mini-vue reactivity
reactive(obj)viaProxy— get/set traps.effect(fn)— a global "currently running effect" and a dependency map.- Dependency tracking:
track(target, key)on get,trigger(target, key)on set. ref(value)— the primitive-value case, and why it needs.value.computed(fn)— lazy, cached, and itself both a dependent and a dependency.watch(source, cb).- Cleanup: effects must drop stale dependencies on re-run.
- Nested effects — an effect stack, not a single global.
- 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
- 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.
- 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.
- Stale computed. Break the cache invalidation so a computed returns an old value. Explain why lazy caching is harder than it looks.
- Lost reactivity. Destructure a reactive object and lose tracking. Explain to a hypothetical junior in three sentences.
- Nested effect corruption. Use a single global "current effect" instead of a stack; watch the inner effect steal the outer's dependencies.
- 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.
| Model | On write | On read | Problem |
|---|---|---|---|
| Pure push | eagerly recompute all dependents | free | recomputes values nobody reads; glitches |
| Pure pull | mark dirty only | recompute if dirty | must walk the graph on every read |
| Push-pull (what real systems do) | mark dirty, propagate invalidation | recompute if dirty, cache result | the 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
computedis 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:
- Destructuring —
const { a } = sreadsaonce, then you hold a plain value. Tracking is lost. (This is why Vue hastoRefs.) - Collections —
Map,Set, arrays need their methods instrumented, not just property access. - Async boundaries — reads after an
awaithappen 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:
- N writes, N runs. A loop of 1,000 mutations runs the effect 1,000 times.
- Inconsistent intermediate states. An effect reading two values sees the first updated and the second not.
- 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
| Redux | React | Vue reactivity | Signals | |
|---|---|---|---|---|
| Who knows what changed | you (actions) | nobody — re-run and diff | the runtime (tracked reads) | the runtime |
| Granularity of update | whole subscriber set | component subtree | effect | effect |
| Work proportional to | subscribers | rendered tree size | number of tracked reads | number of tracked reads |
| "State at time T" exists? | yes, as one value | as props/state per component | no — scattered | no — scattered |
| Debug question | "which action?" | "which component re-rendered?" | "which dependency fired?" | same |
| Tooling required | log (trivial) | render profiler | dependency graph inspector | same |
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
-
"Signals are faster than the virtual DOM." Give the accurate statement, including the workload where it reverses.
-
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.
-
computedis lazy and cached. Enumerate the invariants that make caching safe, and the bug from breaking each. -
Why do refs need
.value? Answer from the mechanism, then say what a language feature would have to provide to remove it. -
Nested effects require a stack. Construct the concrete corruption a single global causes.
-
Compare Vue reactivity with signals: what is genuinely different, and what is naming?
-
A team proposes replacing Redux with signals in a large app. Argue both sides in terms of debuggability and incident response, not performance.
-
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
- 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."
- Dependencies are cleared before each re-run. Otherwise a branch no longer read keeps firing — a dependency leak.
- The active-effect context is a stack, not a global. A nested effect must not steal the outer effect's dependencies.
computedis lazy and cached, and is simultaneously a dependent and a dependency.- 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
| Case | Why | Mitigation |
|---|---|---|
| Destructuring | reads once, then you hold a plain value | toRefs-style wrappers |
| Collections | methods must be instrumented, not just property access | wrap Map/Set/array methods |
Reads after await | outside the tracking context | re-establish, or forbid |
| Conditional reads | stale deps accumulate | cleanup 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
reactive(obj)viaProxy— get/set traps.effect(fn)— a global "currently running effect" and a dependency map.- Dependency tracking:
track(target, key)on get,trigger(target, key)on set. ref(value)— the primitive-value case, and why it needs.value.computed(fn)— lazy, cached, and itself both a dependent and a dependency.watch(source, cb).- Cleanup: effects must drop stale dependencies on re-run.
- Nested effects — an effect stack, not a single global.
- 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
- 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.
- 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.
- Stale computed. Break the cache invalidation so a computed returns an old value. Explain why lazy caching is harder than it looks.
- Lost reactivity. Destructure a reactive object and lose tracking. Explain to a hypothetical junior in three sentences.
- Nested effect corruption. Use a single global "current effect" instead of a stack; watch the inner effect steal the outer's dependencies.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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.
- Your
fw-02renderer is recursive. Render a tree of 10,000 nodes. Measure the task length. Compare to the frame budget frombi-10. Observe the dropped frames directly. - 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.
- Therefore: make the stack an explicit data structure. A node with
child,sibling,returnpointers, walked with a loop instead of recursion. Now "where am I" is a value you can hold. - Now yielding is possible. Loop while work remains and time remains; otherwise schedule
a continuation (
bi-11: which primitive, and why not microtasks?). - 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.
- Now you need double buffering. Building the new tree while the current one is displayed means two trees and a pointer swap — an alternate.
- 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.
- Batching falls out. Multiple
setStatecalls 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
- Commit interruption. Deliberately yield in the middle of the commit phase. Produce a visibly half-updated UI. This is why commit is atomic.
- 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."
- Yield too often. Measure total time as you shrink the yield interval. Find the point where scheduling overhead dominates.
- Yield too rarely. Measure input delay. Find the point where the user notices.
- Starvation. Continuously schedule high-priority updates; watch low-priority work never
complete. Then design the escalation rule. (Compare Chromium's anti-starvation,
bi-11.) - Tearing. Read a mutable external value at two points in one interruptible render, mutating
it in between. Observe inconsistent output. This is the problem
useSyncExternalStoreexists 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
| Phase | Interruptible? | May touch DOM? | Invariant |
|---|---|---|---|
| Render | yes | no | work is discardable, so it must have no observable effect |
| Commit | no | yes | the 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:
- 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.)
- Snapshot at render start — read a consistent snapshot; requires the store to support it.
- Subscribe with a consistency check — the
useSyncExternalStoreapproach: 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
getBoundingClientRectloops. The single diagnostic question is: "is the long task a render, or something else?" A LoAFscripts[]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
-
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.
-
Why can't effects be interrupted? What would break?
-
React yields with
MessageChannelrather than microtasks orsetTimeout. Reconstruct the decision frombi-11, and say whatscheduler.yield()changes. -
"Concurrent React fixes our INP." Under exactly which conditions is that true, and which false? What one question identifies which situation you are in?
-
Tearing is a consequence of interruptibility plus external mutable state. Explain the mechanism, then evaluate the fix.
-
Design a UI framework that is interruptible without a Fiber-like structure. What must you give up?
-
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.
-
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
- Render work is discardable, therefore side-effect free.
- Commit is atomic — exactly one continuation may touch the DOM.
- A new update makes in-flight work stale. Continuing it commits a tree where some components saw old state and some saw new: tearing.
- Two trees exist during render — current and work-in-progress.
- 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 when | Does not help when |
|---|---|
| work is long (tens of ms) | the long task is an effect or layout read |
| work is in the render phase | total work is small; overhead dominates |
| there is higher-priority work to yield to | the 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.
- Your
fw-02renderer is recursive. Render a tree of 10,000 nodes. Measure the task length. Compare to the frame budget frombi-10. Observe the dropped frames directly. - 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.
- Therefore: make the stack an explicit data structure. A node with
child,sibling,returnpointers, walked with a loop instead of recursion. Now "where am I" is a value you can hold. - Now yielding is possible. Loop while work remains and time remains; otherwise schedule
a continuation (
bi-11: which primitive, and why not microtasks?). - 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.
- Now you need double buffering. Building the new tree while the current one is displayed means two trees and a pointer swap — an alternate.
- 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.
- Batching falls out. Multiple
setStatecalls 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
- Commit interruption. Deliberately yield in the middle of the commit phase. Produce a visibly half-updated UI. This is why commit is atomic.
- 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."
- Yield too often. Measure total time as you shrink the yield interval. Find the point where scheduling overhead dominates.
- Yield too rarely. Measure input delay. Find the point where the user notices.
- Starvation. Continuously schedule high-priority updates; watch low-priority work never
complete. Then design the escalation rule. (Compare Chromium's anti-starvation,
bi-11.) - Tearing. Read a mutable external value at two points in one interruptible render, mutating
it in between. Observe inconsistent output. This is the problem
useSyncExternalStoreexists 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)
| What | Predicted | Actual | Gap 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-11completed 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
- VNode representation;
h(). render(vnode, container)— mount.patch(oldVNode, newVNode)— props, attributes, events, children.- Keyed children diff. Implement a naive version, then the two-ended / longest-increasing- subsequence approach. Measure both on a shuffle.
- Components with lifecycle.
- The join: wrap each component's render in a
fw-03effect so reactive reads trigger re-render of that component only. - Scheduler: dedupe and flush component updates on a microtask.
3. Failure Lab
- Naive keyed diff on a reversal — measure the DOM operation count against the smarter algorithm. Explain the algorithmic difference, not just the number.
- Component effect that reads a value conditionally — show the dependency set changing between
renders, and what breaks without cleanup (
fw-03). - No scheduler: cause the same component to render three times in one tick.
- 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.
| Approach | Reorder cost | Notes |
|---|---|---|
| Naive: for each new child, find it in old, move it | O(n²) lookups, O(n) DOM moves | correct, and quadratic |
| Map-based: index old children by key | O(n) lookups, still O(n) moves | most hand-written diffs stop here |
| Two-ended: walk from both ends inward | O(n), few moves for common edits | handles prepend/append/reverse cheaply |
| Longest increasing subsequence | O(n log n), minimum moves | Vue 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:
| Default | Escape hatch | |
|---|---|---|
| React | re-render subtree | memo, useMemo, useCallback |
| Vue / signals | re-render nothing but the tracked effect | rarely 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
-
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.
-
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?
-
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?
-
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.
-
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
- A component's render is an effect, so reactive reads subscribe that component's render.
- Keyed children preserve node identity across reorder.
- The scheduler deduplicates: one component renders once per flush regardless of how many of its dependencies changed.
- Patch flags are trusted. A wrong flag means a skipped update, silently.
Where the defaults sit
| Default | Escape hatch | Failure mode of the escape hatch | |
|---|---|---|---|
| React | re-render subtree | memo, useMemo | memoise wrongly ⇒ stale UI |
| Vue / signals | re-render only the tracked effect | untrack | lose 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
| Approach | Reorder cost |
|---|---|
| Find-in-old per child | O(n²) lookups |
| Map by key | O(n) lookups, O(n) moves |
| Two-ended | O(n), few moves for common edits |
| Longest increasing subsequence | O(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
- VNode representation;
h(). render(vnode, container)— mount.patch(oldVNode, newVNode)— props, attributes, events, children.- Keyed children diff. Implement a naive version, then the two-ended / longest-increasing- subsequence approach. Measure both on a shuffle.
- Components with lifecycle.
- The join: wrap each component's render in a
fw-03effect so reactive reads trigger re-render of that component only. - Scheduler: dedupe and flush component updates on a microtask.
3. Failure Lab
- Naive keyed diff on a reversal — measure the DOM operation count against the smarter algorithm. Explain the algorithmic difference, not just the number.
- Component effect that reads a value conditionally — show the dependency set changing between
renders, and what breaks without cleanup (
fw-03). - No scheduler: cause the same component to render three times in one tick.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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 intoRuleFeatureSetindices for exactly the same reason.
3. Build order
- Lexer for a small template language: text, interpolation
{{ }}, elements, attributes, directives. - Parser → AST, with source locations on every node.
- Transforms: interpolation, attribute binding, event binding, conditionals, loops.
- Codegen → a render function returning your
fw-05VNodes. - Static hoisting: detect fully-static subtrees; emit them once.
- Patch flags: annotate dynamic bindings; make your renderer honour them.
- Error reporting with source locations — a caret-and-line message, not a stack trace.
- Then: a JSX-to-
createElementtransform over real JS, using an existing JS parser.
4. Failure Lab
- Drop source locations. Produce a compile error and try to act on it. This is why step 2 says every node.
- Hoist a subtree that is not actually static (it reads a variable). Show the stale render.
- 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.
- 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
| Optimisation | Wins | Silent failure if wrong |
|---|---|---|
| Static hoisting | subtree created once, not per render | hoisted node mutates and is shared across instances |
| Patch flags | skip prop enumeration and children diff | dynamic node marked static → update never appears |
| Static prop dedup | shared prop objects | a mutated shared object leaks between instances |
| Inline component slots | fewer closures | scope captured wrongly |
| Tree flattening | skip static intermediate nodes | dynamic 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-06matters 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
namesfield 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
-
What can a template compiler know that a JSX compiler cannot, and what follows from that?
-
A wrong patch flag is a silent correctness bug. Design the testing strategy that makes compiler optimisations safe to ship.
-
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?
-
Source maps are always imperfect. What would you require before enabling an aggressive optimisation in a large org?
-
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
- Every AST node carries a source range, from lexing onward. Positions cannot be recovered later.
- A static hoist must be genuinely static — no reads of anything dynamic.
- A patch flag must be a superset of what can change. Under-flagging is a correctness bug.
- Generated code must be debuggable, which means composable source maps at every stage.
Why compiler optimisation is riskier than it looks
| Optimisation | Silent failure if wrong |
|---|---|
| Static hoisting | a hoisted node mutates and is shared across instances |
| Patch flags | dynamic node marked static ⇒ update never appears |
| Static prop dedup | a mutated shared object leaks between instances |
| Tree flattening | a 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
- Lexer for a small template language: text, interpolation
{{ }}, elements, attributes, directives. - Parser → AST, with source locations on every node.
- Transforms: interpolation, attribute binding, event binding, conditionals, loops.
- Codegen → a render function returning your
fw-05VNodes. - Static hoisting: detect fully-static subtrees; emit them once.
- Patch flags: annotate dynamic bindings; make your renderer honour them.
- Error reporting with source locations — a caret-and-line message, not a stack trace.
- Then: a JSX-to-
createElementtransform over real JS, using an existing JS parser.
4. Failure Lab
- Drop source locations. Produce a compile error and try to act on it. This is why step 2 says every node.
- Hoist a subtree that is not actually static (it reads a variable). Show the stale render.
- 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.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
- Parse a module's imports (use an existing JS parser).
- Build the dependency graph; detect cycles.
- Transform each module (reuse
fw-06). - Emit a bundle with a tiny runtime module registry.
- Code splitting on dynamic
import()— emit separate chunks, load on demand. - 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.
- Source maps.
- Caching + incremental rebuild: content-hash each module; rebuild only affected paths.
- HMR concepts: what must be true for a module to be replaceable without a reload?
3. Failure Lab
- Stale cache. Key the cache on filename only, not content. Produce a wrong bundle.
- Unsafe tree shaking. Drop a module with an import side effect. Break the app.
- Cycle. Create a circular import with top-level usage; observe the partially-initialised module.
- Broken source map. Off-by-one the mapping and try to debug through it.
- 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 key | Fails when |
|---|---|
| filename | contents change |
| mtime | files touched without change; checkouts; containers |
| content hash | a dependency's content changed |
| content hash + resolved dependency hashes | correct — and requires a full graph |
| + config, plugin versions, env vars | correct 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:
- Identifying what changed and its dependents.
- Deciding a boundary — how far up the dependency graph to propagate before giving up and reloading.
- Preserving state across the swap — which the module must cooperate with.
- 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
-
Tree shaking is unsound without author annotations. Explain why, and evaluate
sideEffectsas a solution. -
Your build takes 8 minutes. Enumerate causes in order of likelihood and the measurement for each.
-
Code splitting reduces initial bytes and adds requests and complexity. What decides the split granularity, and what would you measure?
-
Compare a bundler's invalidation problem with Blink's style invalidation (
bi-07). What is genuinely the same, and what is different? -
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
- The dependency graph is complete before any transform decision. Splitting and shaking are graph properties, not per-file ones.
- A cache key captures every input that affects the output — contents, resolved dependency hashes, config, plugin versions, env.
- Removal must preserve observable behaviour, which static analysis cannot prove in general.
- 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
| Key | Fails when |
|---|---|
| filename | contents change |
| mtime | touched files; fresh checkouts; containers |
| content hash | a dependency's content changed |
| content hash + dependency hashes | correct — needs the full graph |
| + config, plugins, env | correct 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
- Parse a module's imports (use an existing JS parser).
- Build the dependency graph; detect cycles.
- Transform each module (reuse
fw-06). - Emit a bundle with a tiny runtime module registry.
- Code splitting on dynamic
import()— emit separate chunks, load on demand. - 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.
- Source maps.
- Caching + incremental rebuild: content-hash each module; rebuild only affected paths.
- HMR concepts: what must be true for a module to be replaceable without a reload?
3. Failure Lab
- Stale cache. Key the cache on filename only, not content. Produce a wrong bundle.
- Unsafe tree shaking. Drop a module with an import side effect. Break the app.
- Cycle. Create a circular import with top-level usage; observe the partially-initialised module.
- Broken source map. Off-by-one the mapping and try to debug through it.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
- URL parsing and route matching (static, params, wildcards); ranking by specificity.
- History API:
pushState,replaceState,popstate. Note whatpopstatedoes not tell you. - Nested routes and a matched-route chain.
- Redirects, including loops — detect and fail loudly.
- Loaders: async data per route, resolved before the transition commits.
- Cancellation: a navigation superseded by another must abort its loaders.
- Error routes and error boundaries per level.
- Back/forward, including during an in-flight navigation.
- Scroll restoration.
- Blocking navigation on unsaved changes.
3. Failure Lab
- 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.
- Back during load. Press back while a loader is pending. Where does the state end up?
- Redirect loop. A→B→A. What does the user see, and what should they see?
- Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
- 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 load —
popstatearrives 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
| Fix | Mechanism | Stops the work? | Correct with server side effects? |
|---|---|---|---|
| Render-time guard | compare response's URL to current | no | no — the effect happened |
| Sequence number | ignore responses with a stale id | no | no — same |
AbortController | signal cancellation to the request | yes, best-effort | closest — 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
| Reality | Consequence |
|---|---|
popstate does not say direction | you cannot tell back from forward without tracking indices yourself |
pushState does not fire popstate | your own navigations need explicit handling |
| History entries have a state object with a size limit | do not store your app state there |
| You cannot read the history stack | no "can I go back?" without your own bookkeeping |
| You cannot cancel a back navigation | which 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
- Name the invariant violated by a stale loader overwrite, and defend your preferred fix including the side-effecting case.
popstatedoes not distinguish back from forward. What does that force routers to build?- Loaders eliminate request waterfalls but couple data to URL structure. When is that wrong?
- Design navigation blocking for unsaved changes that works with the back button. What can you not do, and why is that a platform decision?
- 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
- Only the most recent navigation commits.
- A superseded navigation's loaders are cancelled and their results discarded.
- A redirect during loading prevents the original navigation from committing.
- Scroll restoration happens after the content determining page height exists.
- Navigation blocking cannot silently swallow a browser back navigation.
The three fixes, and where each stops working
| Fix | Stops the work? | Correct with a server side effect? |
|---|---|---|
| Render-time guard | no | no — the effect already happened |
| Sequence number | no | no |
AbortController | best-effort | closest — 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
| Reality | Consequence |
|---|---|
popstate does not report direction | track indices yourself |
pushState does not fire popstate | handle your own navigations explicitly |
| The history stack is unreadable | no "can I go back?" without bookkeeping |
| Back cannot be cancelled | unsaved-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
- URL parsing and route matching (static, params, wildcards); ranking by specificity.
- History API:
pushState,replaceState,popstate. Note whatpopstatedoes not tell you. - Nested routes and a matched-route chain.
- Redirects, including loops — detect and fail loudly.
- Loaders: async data per route, resolved before the transition commits.
- Cancellation: a navigation superseded by another must abort its loaders.
- Error routes and error boundaries per level.
- Back/forward, including during an in-flight navigation.
- Scroll restoration.
- Blocking navigation on unsaved changes.
3. Failure Lab
- 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.
- Back during load. Press back while a loader is pending. Where does the state end up?
- Redirect loop. A→B→A. What does the user see, and what should they see?
- Scroll restoration vs async content. Restore scroll before the content that made the page tall has loaded.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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
- Key normalisation (structural equality, stable ordering).
- Cache with status:
fresh | stale | fetching | error. - Deduplication: N simultaneous subscribers, one request.
- Stale-while-revalidate.
- Retries with backoff; distinguish retriable from terminal errors.
- Cancellation on unsubscribe.
- Invalidation, exact and by key prefix.
- Background refetch (focus, reconnect, interval).
- Optimistic updates with rollback.
- Garbage collection of unobserved entries.
- Pagination and dependent queries.
4. Failure Lab — build every race
- 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.
- 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?
- Dedup + cancellation. Three subscribers share one request; one unsubscribes. Does the request abort? Should it?
- Refetch storm. Window focus triggers refetch of 50 queries simultaneously.
- GC race. An entry is collected while a component is mid-mount.
- 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:
- Snapshot-and-restore — restore the pre-mutation value. Simple; loses concurrent updates.
- Invalidate and refetch — discard local state, ask the server. Correct, costs a round trip, and flickers.
- 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 cache | Normalised cache | |
|---|---|---|
| Stores | whole responses per key | entities by id, queries as id lists |
| Update one entity | must invalidate every query containing it | update once, all queries see it |
| Requires | nothing | a schema, or id extraction |
| Complexity | low | high |
| Chosen by | React Query, SWR | Apollo, 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
- Why is a timestamp insufficient for last-write-wins? Give the failing scenario.
- Optimistic update rollback when the base changed: specify the correct behaviour, and defend it.
- Argue that server state should never live in a client state manager. Then give the exception.
- Design invalidation for a mutation affecting an unknown set of queries. What do you give up?
- 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
- Order by a monotonic counter you control, incremented at dispatch. Not wall clock, not arrival order, not server time.
- One in-flight request per key, shared by all subscribers.
- A cancelled or superseded response never writes to the cache.
- Cache keys compare structurally, with stable ordering.
- 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
| Strategy | Correctness | Cost |
|---|---|---|
| Snapshot and restore | loses concurrent updates | trivial |
| Invalidate and refetch | correct | a round trip, and a flicker |
| Optimistic layer over server state | correct and composable | substantial 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
- Key normalisation (structural equality, stable ordering).
- Cache with status:
fresh | stale | fetching | error. - Deduplication: N simultaneous subscribers, one request.
- Stale-while-revalidate.
- Retries with backoff; distinguish retriable from terminal errors.
- Cancellation on unsubscribe.
- Invalidation, exact and by key prefix.
- Background refetch (focus, reconnect, interval).
- Optimistic updates with rollback.
- Garbage collection of unobserved entries.
- Pagination and dependent queries.
4. Failure Lab — build every race
- 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.
- 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?
- Dedup + cancellation. Three subscribers share one request; one unsubscribes. Does the request abort? Should it?
- Refetch storm. Window focus triggers refetch of 50 queries simultaneously.
- GC race. An entry is collected while a component is mid-mount.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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.
- Fixed heights: viewport → visible index range → render window + overscan.
- Absolute positioning or transform offset for the window. Choose deliberately and justify it
from
bi-09. - Dynamic heights: measure rendered rows, cache measurements, correct the estimate.
- Scroll anchoring: keep the visually-anchored row stable when an above-viewport measurement changes.
- Fast scroll: what to show when you outrun measurement.
- Accessibility: what a screen reader sees when only 20 of 100,000 rows exist.
3. Failure Lab
- Measurement thrash. Measure every row every frame. Watch forced synchronous layout
(
bi-08). - Anchor drift. Skip scroll anchoring; watch content jump as heights resolve.
- Overscan zero. Blank rows on fast scroll — the app-level analogue of checkerboarding
(
bi-10). - DOM growth leak. Recycle rows incorrectly so the DOM grows; find it in a heap snapshot.
- Scroll handler on the main thread. Update the window in a
scrollhandler; measure the one-frame lag. Compare anIntersectionObserver/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:
- Assume an estimated height for unmeasured rows.
- Measure rows as they render; cache by item id (not by index — indices shift).
- Maintain a prefix-sum structure so "what is the offset of row N" stays fast.
- 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:
ResizeObserver— delivered inside the rendering steps, after layout, so it does not force a synchronous flush (bi-11).- Batch reads, then writes — measure everything, then apply all offsets.
IntersectionObserverfor visibility rather than repeatedgetBoundingClientRect.content-visibility: autowithcontain-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.
| Breaks | Why | Mitigation |
|---|---|---|
| Find-in-page (Ctrl+F) | offscreen rows are not in the DOM | none reliable — a genuine loss |
| Screen reader navigation | AT sees 20 of 100,000 rows | aria-setsize / aria-posinset, and a real grid role |
Anchor links / scrollIntoView | target may not exist | route through your own index → scroll |
| Tab order | focusable elements appear and disappear | manage focus on recycle; never leave focus on a removed node |
| Text selection across rows | selection breaks at the window edge | rarely fixable |
| only the window prints | render an unvirtualized print view | |
| Browser scroll restoration | height changes as rows measure | manual 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
- Explain why virtualization helps, in terms of which pipeline stages it removes work from.
- Dynamic measurement forces layout. Design the scheme that minimises it; state what you give up.
- Argue that
content-visibility: automakes list virtualization obsolete. Then defeat it. - What does virtualization do to accessibility and find-in-page, and what is your mitigation?
- 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
- Measurements are cached by item identity, not by index. Indices shift; identities do not.
- A measurement correction above the viewport adjusts scroll offset, or content jumps under the user's finger.
- Reads are batched separately from writes, or measurement becomes forced synchronous layout.
- Total height is derivable in better than O(n) — a prefix-sum or Fenwick structure.
- 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
| Breaks | Mitigation |
|---|---|
| Find-in-page | none reliable — a genuine loss |
| Screen-reader navigation | aria-setsize / aria-posinset, a real grid role |
Anchor links, scrollIntoView | route through your own index |
| Tab order and focus | manage focus on recycle |
| Text selection across rows | rarely fixable |
| render an unvirtualized print view | |
| Browser scroll restoration | manual 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.
- Fixed heights: viewport → visible index range → render window + overscan.
- Absolute positioning or transform offset for the window. Choose deliberately and justify it
from
bi-09. - Dynamic heights: measure rendered rows, cache measurements, correct the estimate.
- Scroll anchoring: keep the visually-anchored row stable when an above-viewport measurement changes.
- Fast scroll: what to show when you outrun measurement.
- Accessibility: what a screen reader sees when only 20 of 100,000 rows exist.
3. Failure Lab
- Measurement thrash. Measure every row every frame. Watch forced synchronous layout
(
bi-08). - Anchor drift. Skip scroll anchoring; watch content jump as heights resolve.
- Overscan zero. Blank rows on fast scroll — the app-level analogue of checkerboarding
(
bi-10). - DOM growth leak. Recycle rows incorrectly so the DOM grows; find it in a heap snapshot.
- Scroll handler on the main thread. Update the window in a
scrollhandler; measure the one-frame lag. Compare anIntersectionObserver/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)
| What | Predicted | Actual | Gap 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
-
transformvstopchoice 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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.
| Area | Your counterpart | Question to answer |
|---|---|---|
| Element creation | fw-02 stage 1 | what does the real element carry that yours does not, and why? |
| Reconciliation / child diff | fw-02 stages 3–4 | which cases does yours get wrong? |
| Fiber structure | fw-04 step 3 | which fields did you not derive, and what is each for? |
| Work loop | fw-04 steps 4–7 | how are priorities represented, and how is work abandoned? |
| Scheduler package | bi-11 | why MessageChannel; what does the deadline model assume? |
| Hooks | fw-02 stage 6 | how is the slot list actually stored, and what does the dispatcher indirection buy? |
| DOM renderer | fw-02 stage 2 | property vs attribute handling — the bi-04 distinction, in production |
| Events | — | why synthetic events? what does delegation cost, and what changed in React 17? |
| Commit | fw-04 step 5 | what are the commit sub-phases and why is the order forced? |
| Hydration | — | how are mismatches detected and recovered? |
| Server rendering | — | what does streaming require of the component model? |
3. Vue reading list
| Area | Your counterpart | Question |
|---|---|---|
reactivity | fw-03 | how are dep sets stored; what does cleanup actually do? |
runtime-core | fw-05 | scheduler queue, job dedup, flush timing |
runtime-dom | fw-05 | prop patching, event handler caching |
compiler-core | fw-06 | transform pipeline, patch-flag generation |
compiler-dom | fw-06 | DOM-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:
- Start from your own implementation's file, not theirs. "Where is their version of my
reconcileChildren?" - Find the entry point by searching for the public API name, then follow one call inward.
- 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.
- 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.
- Keep a deferred list (
bi-01). Names you chose not to follow are your map. - 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
bailoutcheck, and how does it skip a subtree? - Why do effects have separate
passiveandlayoutphases? 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
-
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.
-
Synthetic events: reconstruct the original justification, then evaluate whether it still holds.
-
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?
-
Vue's scheduler and React's scheduler solve overlapping problems differently. Name the deepest difference, and what each optimises.
-
After reading both: which architecture would you choose for a new product, and what is the strongest argument against your choice?
-
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
| Class | Definition | Tell |
|---|---|---|
| Essential architecture | removing it makes a capability impossible | your derivation independently produced it |
| Production hardening | works in the small, fails at scale or on hostile input | it is a fast path that bails out, or a guard around correct code |
| Accretion | historical; a greenfield design would omit it | names 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
- Start from your file, not theirs: "where is their version of my
reconcileChildren?" - Read type definitions first — a Fiber's field list teaches more in two minutes than an hour of call-following.
- 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.
- Keep a deferred list.
- 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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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:
aria-labelledby(follow the ids, recursively)aria-label- native host-language labelling —
<label for>, wrapping<label>,<caption>,alt,<legend>,titleas a last resort - subtree text content, if the role permits name-from-content
titleattribute
Facts that fall out and that matter in real code:
aria-labelledbybeats 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
buttontakes its name from its contents; adivdoes 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 click | CDP Input.dispatchMouseEvent | |
|---|---|---|---|
isTrusted | false | true | true |
| Hit testing | skipped — you named the target | performed (bi-09) | performed |
| Compositor involvement | none | yes (bi-10) | yes |
| Default actions | many gated on trust | full | full |
| Focus / activation | not granted | grants user activation | grants |
| Pointer capture, drag | not simulated | full | mostly |
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
-
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.
-
Synthetic events differ from real input. Enumerate the differences that matter, and give a bug that only real input reveals.
-
Your observability layer changes the thing observed. Give three concrete instances from frontend tooling, and how each is mitigated.
-
DevTools is a protocol client. What does that architecture buy, and what does it cost?
-
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
- A query by role must reflect what assistive technology computes, not what the DOM contains.
- Synthetic events are not user input and must never be presented as equivalent.
- An observability layer must not perturb what it measures.
Accessible name computation is an algorithm
Precedence, roughly: aria-labelledby → aria-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-labelledbybeats 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 | |
|---|---|---|
isTrusted | false | true |
| Hit testing | skipped | performed |
| Compositor | not involved | involved |
| User activation | not granted | granted |
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)
| What | Predicted | Actual | Gap 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
- Source readings -> fe-00-roadmap/docs/learning-log.md §3
- Status -> PROGRESS.md
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.
| M | Milestone | Built in | Paired reading |
|---|---|---|---|
| 1 | hard-coded boxes | bi-03 | — |
| 2 | HTML tokenizer + tree builder | bi-03 | core/html/parser/ |
| 3 | DOM | bi-04 | core/dom/ |
| 4 | CSS parser | bi-07 | core/css/ |
| 5 | selector matching | bi-07 | SelectorChecker |
| 6 | cascade, inheritance, computed style | bi-07 | StyleForLayoutObject |
| 7 | layout tree | bi-08 | core/layout/ |
| 8 | block layout | bi-08 | block layout algorithm |
| 9 | inline text | bi-08 | core/layout/inline/ |
| 10 | display list | bi-09 | core/paint/ |
| 11 | raster | bi-09 | paint → raster boundary |
| 12 | events | bi-11 | event dispatch |
| 13 | incremental invalidation | bi-11 | style + paint invalidation |
| 14 | minimal JS integration | bi-05 | bindings |
| 15 | profiling / optimisation | bi-12 | tracing |
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
| # | Title | Status | Date |
|---|---|---|---|
| — | 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:
- What problem existed?
- What does the platform provide?
- What abstraction was introduced?
- What trade-off did it make?
- What complexity did it hide?
- When does the abstraction leak?
- 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
picturesrcset- 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:
| Dimension | Cases |
|---|---|
| Authentication | logged in / logged out / expired |
| Network | normal / slow / offline |
| API | success / 4xx / 5xx / malformed |
| Viewport | mobile / tablet / desktop |
| Input | mouse / keyboard / touch |
| Accessibility | screen reader / keyboard |
| Data | empty / normal / huge |
| Locale | English / 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:
- What do you investigate first?
- What evidence do you need?
- What hypotheses exist?
- What experiments distinguish them?
- What mitigation is appropriate?
- What permanent fix is appropriate?
- 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:
- Explain the architecture.
- Identify assumptions.
- Identify failure states.
- Generate tests.
- Run static analysis.
- Run browser tests.
- Inspect accessibility.
- Measure performance where relevant.
- Review security implications.
- 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:
- full roadmap
- dependency graph
- recommended order
- Beginner → Distinguished progression
- modules
- labs
- failure labs
- debugging labs
- architecture exercises
- code-reading exercises
- production incident exercises
- testing exercises
- AI-assisted engineering exercises
- capstones
- reference library
- interview questions
- Principal-engineer review questions
- technology radar
- anti-pattern encyclopedia
- 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:
- configure local Chromium environment,
- obtain source,
- understand
depot_tools, - understand
gclient, - understand dependencies,
- understand GN,
- understand the current platform build executor,
- configure build args,
- build Chromium,
- launch local browser,
- build smaller targets,
- modify source,
- rebuild incrementally,
- attach debugger,
- 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:
- verify in browser,
- identify spec rules,
- trace Blink implementation,
- 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:
- parser
- selector representation
- selector matching
- specificity
- cascade
- inheritance
- computed style
- 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:
- block flow
- margins/padding
- nested boxes
- inline text
- basic flex
- intrinsic sizing
- 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:
- web abstraction
- enforcement boundary
- 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:
- pick a behavior,
- read spec,
- locate WPT,
- run test,
- locate Blink implementation,
- deliberately break behavior,
- observe failure,
- 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.createElementclassList.addgetBoundingClientRectrequestAnimationFrame- 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:
- hard-coded boxes
- HTML parser
- DOM
- CSS parser
- selector matching
- cascade
- layout tree
- block layout
- text
- display list
- raster
- events
- incremental invalidation
- minimal JS integration
- 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:
- element representation
- DOM renderer
- reconciliation
- keyed children
- function components
- state
- effects
- batching
- scheduling
- interruptible work
- priorities
- context
- error boundaries
- SSR
- hydration
- 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:
- what problem is solved?
- what invariant is maintained?
- what complexity did our implementation avoid?
- what failure occurs without this?
- 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.