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.