Inside Chrome's Layout Engine: How Caching Ended CSS Grid's Exponential Slowdown

Khanh Nguyen
Khanh Nguyen
(Updated: )
Listen to this article0 / 0
Hand holding a glowing 3D block with blue energy and Chrome logo. Photo: AI/BytePith.

Block layout only ever visits each element once. Flexbox and CSS Grid do not: both measure their children first, then stretch them to match, and until Chrome rewrote its layout engine that second pass could turn a deeply nested page into an exponential-time problem instead of a linear one.

Block Layout's One Pass, and Why Flex and Grid Need Two

The Chromium engineering team's account of the LayoutNG rewrite, published as part of the RenderingNG series on the Chrome for Developers site, lays out the distinction plainly. Block layout, in almost all cases, requires the engine to lay out each child exactly once. That single-pass guarantee is what makes ordinary document flow cheap to compute, but it cannot express something as common as making every item in a row match the height of the tallest one.

Flexbox and Grid solve that by running a measure pass to find each child's intrinsic size, then a separate layout pass that stretches every child to the size the measure pass determined. This two-pass behavior is the default for both layout modes, and the staged rollout Chrome documented as LayoutNG replaced the old engine module by module treated flex and grid as later, harder phases than ordinary block and inline layout for exactly this reason.

The Recurrence That Turned Nesting Into an Exponential Bug

A two-pass layout is not a problem in isolation. The trouble starts when two-pass containers are nested inside each other, because each level of nesting can double the number of layout visits required below it. Chrome's own writeup gives the recurrence directly: for a tree of depth n, layout(div) = 2 × layout(child), and layout(child) = 2 × layout(grandchild), which resolves to roughly 2ⁿ visits rather than the n visits a single-pass block tree would need.

That distinction stayed mostly theoretical while flex and grid layouts were shallow, but as nested flex and grid structures became common, the cost stopped being theoretical. The Blink team describes fighting a chain of roughly ten related bugs over more than a year in flexbox alone, each fix trading a correctness problem for a performance one or the reverse. A public demo cited in the same writeup shows an equivalent exponential blowup in Grid layout, a bug the team says was fixed in Chrome 93 by moving Grid onto the new architecture, the same architecture Igalia's engineering account of building Grid support into LayoutNG describes as a multi-year effort sponsored by Bloomberg and coordinated with the Blink team's own LayoutNG engineers.

What LayoutNG's Cache Key Actually Fixed

The fix was not a faster measure pass. It was making the measure pass skippable. LayoutNG's core change is that the parent constraints handed down to a child, the available width, the alignment mode, and similar inputs, are captured as an explicit, immutable object and stored alongside that child's resulting fragment. When the engine revisits a child, it diffs the new constraints against the stored ones. If nothing relevant changed, the cached fragment is reused and neither the measure pass nor the layout pass runs again.

That single change is what brings nested two-pass layout back down from roughly 2ⁿ to O(n): each child still may need two passes on its first layout, but a repeat visit with unchanged constraints costs a cache lookup instead of a second full recursion. The team notes that migrating flexbox itself onto this architecture produced large improvements in very slow layout times in the wild, which the engineers took as a sign that their earlier, more targeted caches for flexbox had missed cases the new system caught automatically.

A rough guide the team offers to developers debugging real pages: an incremental change (a single element changing one CSS property) that produces a 50 to 100 millisecond layout is more likely a sign of an exponential-time bug reappearing somewhere in the nesting than a sign that flex or grid layout is inherently slow.

This is what that caching pipeline looks like end to end, from the moment a node's constraints arrive to the reuse-or-rerun decision:

How LayoutNG turns a two-pass layout into a cache lookupFlow diagram showing that a child's parent constraints become a cache key, so a repeat measure pass with identical constraints reuses the stored fragment instead of re-running layout.The measure-pass cache in LayoutNGApplies to Flexbox and Grid, both two-pass layout modesDOM tree and computed style enter layoutParent constraints become the cache keyMeasure pass computes intrinsic sizesNew constraints diffed against the cached keyMatch: reuse the cached fragmentMismatch: re-run the layout passSource: Chrome for Developers, RenderingNG deep-dive: LayoutNG

Table Layout's Older, Narrower Version of the Same Idea

CSS table layout ran into a version of this tradeoff long before flex or grid existed, though its fix is much simpler because a table only has to solve it in one dimension: column width. The table-layout property's auto value, which is what most browsers use by default, requires the engine to look at the content of every cell in a column before it can settle on that column's width, because the layout depends on what the widest piece of content in the column turns out to be.

The fixed value skips that dependency entirely. MDN's documentation of the property describes fixed layout as depending only on the table's declared width, its columns' declared widths, and border or cell spacing, never on cell content, so the browser can commit to column widths after reading just the first row. CSS-Tricks' table-layout reference calls this out explicitly, noting that the CSS2.1 specification itself describes the fixed algorithm as the "fast" one for exactly this reason: it does not need to see the whole table before it can lay it out.

The underlying logic is the same shape as LayoutNG's cache key, even though the two mechanisms come from different specifications built more than a decade apart. Both trade a dependency on downstream content, a table cell's text, a flex child's intrinsic size, for a commitment made from information already in hand. Table layout makes that trade once, permanently, at the level of a CSS keyword. LayoutNG makes it dynamically, on every layout pass, by treating whether the inputs actually changed as the question worth caching an answer to.

Why the 2013 Benchmark Predates Grid Entirely

The most-cited head-to-head numbers on flexbox performance are older than LayoutNG itself. In 2013, Chrome developer relations engineer Paul Irish benchmarked three layout approaches across 500 elements in the same Blink and WebKit codebase: the original, pre-standard flexbox syntax (display: box), the rewritten standard syntax (display: flex), and a display: table-cell layout as a reference point. Averaged across three runs on desktop, old flexbox cost about 43.5 milliseconds, table-cell cost about 30 milliseconds, and new flexbox cost about 18.2 milliseconds, a 2.3x improvement from the flexbox rewrite alone.

Ojan Vafai, who wrote much of that flexbox implementation, is quoted in the same post making a claim worth keeping in view alongside LayoutNG's later history: regular block layout would usually match or beat new flexbox because it is always single-pass, but new flexbox should still beat both tables and hand-written JavaScript layout code in the common case.

Layout cost of old flexbox, table-cell, and new flexbox in 2013A 2013 Blink and WebKit benchmark of 500 elements found the legacy display:box flexbox took about 43.5 milliseconds to lay out, display:table-cell took about 30 milliseconds, and the rewritten display:flex syntax took about 18.2 milliseconds.Layout cost, old flexbox vs. table-cell vs. new flexbox2013 Blink/WebKit desktop benchmark, 500 elements, pre-LayoutNGOld flexbox (display:box)43.5 msTable-cell (display:table-cell)30.0 msNew flexbox (display:flex)18.2 ms01020304050 msSource: Chrome for Developers blog, "Flexbox layout isn't slow," 2013

That benchmark says nothing about Grid, which was not yet part of the comparison, and nothing about the post-LayoutNG architecture, which shipped years later. Chrome's engineering blog documents the exponential-bug fix qualitatively, through the recurrence relation and the Chrome 93 ship date, rather than with a fresh millisecond figure comparable to the 2013 numbers. A developer deciding between Flexbox and Grid on a current browser is choosing between two layout modes that now share the same caching architecture, not between the old and new flexbox syntaxes the 2013 benchmark actually measured.

Comments (0)

Sort by:

No comments yet.

Be the first to share your perspective on this topic.