Web Platform · Rendering Performance

Layout Invalidation and Dirty Bits: Why Some Changes Reflow Everything

How a style change propagates into layout, where a reflow can be stopped at a layout root, and which changes defeat containment entirely.

A page with ten thousand elements does not relayout ten thousand boxes when one button grows by four pixels. It relayouts a subtree, starting from a node near the change and stopping at the first ancestor whose geometry cannot be affected. The mechanism that makes this possible is a pair of flags per layout object, and the reason it sometimes fails is that a small number of properties make every box’s geometry depend on every other box’s.

Two bits: dirty and child-dirty

Every layout object carries state describing whether its own layout is stale and whether any descendant’s layout is stale. A style change that can affect geometry marks the object dirty, then walks up the tree marking each ancestor as having a dirty descendant, and stops when it reaches an ancestor already marked. That upward walk is why invalidation is cheap even for a large subtree: marking is memoised at the first dirty ancestor.

The downward pass does the work. Starting from the nearest layout root — an object whose own geometry is independent of what happens inside it — the engine lays out dirty subtrees, clearing flags as it goes. An object whose own flag was never set, but whose child-dirty flag is set, does not recompute its own size; it lays out only the affected children and keeps its existing fragment.

The subtlety is that “dirty” is not the same as “must be measured”. A dirty box that receives the same constraints as before can produce the same size, and its parent can then skip re-laying out siblings. Engines exploit this: a subtree can be laid out and found unchanged, terminating propagation early. Correctness does not require this optimisation, only performance does, which is why engines differ in how far a change appears to spread.

Where the upward walk stops

The layout root for a change is the nearest ancestor for which the change cannot alter the box’s own size or position. The usual candidates:

  • an element with a definite size in both axes that does not depend on its content;
  • an element establishing an independent formatting context, such as a block formatting context created by overflow: hidden, a float, or a flex or grid container — though only if its size does not derive from the content inside it;
  • an out-of-flow descendant’s containing block, since absolutely positioned children do not affect their parent’s size;
  • an element declared self-contained by contain: layout, which asserts exactly the property the engine would otherwise have to prove.

Conversely, the walk continues upward when a box’s size is content-derived: a shrink-to-fit float, an auto-height block, a table cell, or a flex item in the main axis. In those cases a change deep inside propagates all the way to the nearest definite-size ancestor. A page built from nested auto-sized containers has effectively one layout root: the viewport.

The changes that are never contained

Four categories defeat the boundary logic above, and they are worth knowing because they look like ordinary edits.

Root-relative metrics. Changing the root font size changes em and rem lengths everywhere, and changes the computed font size of every element that inherits. Percentages of the viewport height change with the viewport. These are not subtree changes; they are global ones.

Viewport and page-level state. A window resize, an orientation change, the appearance or disappearance of a classic scrollbar (which changes the viewport width by roughly 15 pixels on desktop platforms that reserve space for it), or a change in writing-mode at the root all invalidate layout document-wide. This is one reason scrollbar-gutter behaviour and overflow changes on the root produce disproportionate cost.

Font availability. When a web font finishes loading, the metrics of every run of text that uses that family change. Text measured with the fallback face must be re-measured, which means line breaking and therefore layout for every block containing that text. Synthesised bold or italic faces, and fallback chains where the chosen face differs per character, make the change harder to localise. The font loading model itself is specified separately, and preloading or self-hosting does not avoid the relayout; only ensuring the fallback metrics are close, or that the font is available before first paint of the text, does.

Synchronous reads. A geometry read after a write forces the pending layout to run immediately. That is not an invalidation issue but it converts a batched, subtree-limited pass into a blocking one; Forced Synchronous Layout covers the mechanics.

Content-derived sizing is the real cost driver

The number of dirty boxes is a poor predictor of layout cost. Two pages can dirty the same thousand boxes and differ by an order of magnitude, because the per-box work depends on the layout algorithm:

  • Block layout with fixed widths is close to linear in the number of boxes and lines.
  • Inline layout does line breaking, which is linear in the number of characters but with a much larger constant.
  • Flex layout may need to resolve flexible lengths iteratively, and a flex item’s intrinsic size can require measuring its content.
  • Grid layout can require multiple passes to determine track sizes, especially with auto and fr tracks combined.
  • Table layout computes column widths from the intrinsic widths of all cells, so a change to one cell can require examining the content of the whole table.

This is the trade the engine made: precise, spec-compliant sizing in exchange for content-dependent cost. The mitigation is to give containers definite sizes where the design allows, so that a change in one child does not trigger measurement of its siblings.

/* Self-contained panel: internal changes cannot resize it or escape it. */
.panel {
  contain: layout;
  width: 320px;
  height: 240px;
  overflow: auto;
}

/* Stricter: adds paint and style containment, so descendants are clipped. */
.card {
  contain: content;
  width: 100%;
  max-height: 40vh;
}

contain: layout states that the element’s internal layout cannot affect anything outside it. The browser may then treat the element as a layout root and skip the upward walk entirely. It is an assertion, not a hint, but it does not change how the element itself is sized — that is contain: size, which tells the engine to size the box as if it had no contents, and which collapses a content-sized box to zero when no definite size is supplied.

Formatting contexts, and why a float is a boundary

An element that establishes a block formatting context contains its floats and prevents margin collapsing across its boundary. Those semantics make its internal layout independent of the surrounding flow in one direction, which is why engines can treat it as a containment boundary. The same is true of a flex container with respect to its items: the container’s size may depend on the items, but item positions inside it do not depend on the container’s siblings.

This is why display: flow-root is often a cheaper way to gain a boundary than adding overflow: hidden and its clipping side effects. It creates the formatting context without changing scroll behaviour or clipping.

Reading layout counts without fooling yourself

Developer tooling reports layout events per frame with a duration, and the underlying trace records how many objects were dirty and how many the pass touched. Two mistakes are common when reading them. The first is counting objects instead of measuring time: a layout that touches a hundred boxes in an inline formatting context can cost more than one that touches a thousand fixed-size boxes. The second is attributing a layout event to the nearest script call: the invalidation may have happened several tasks earlier and merely been flushed by a read or by the frame’s start.

The reliable procedure is to correlate three observations for the same frame: the style invalidation count, the layout event’s duration and object counts, and the frame’s total main-thread time. When the dirty count approaches the total object count, the pass started at the document root: the change escaped containment and the fix is structural — a definite size, a formatting context, or a contain declaration. When few objects are dirty but the duration is long, the cost is per-node algorithm work or font measurement, and the fix is on the content side rather than the containment side.

Offscreen content complicates the count: an element outside the viewport still participates in layout unless it opts out of rendering work, which is the subject of CSS Containment and Content Visibility. A long document with thousands of offscreen blocks pays layout for all of them on every global invalidation, and no amount of local containment removes that cost.

References

  1. CSS Containment Module Level 2 — W3C
  2. CSS Font Loading Module Level 3 — W3C
  3. CSS Box Model Module Level 3 — W3C