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.