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.


0. Orientation

The 60-second version

CSS is two different systems wearing one syntax, and almost every confused conversation about CSS is a failure to say which one is being discussed:

  1. A resolution algorithm. Given many competing declarations, which one wins? This is the cascade — origin/importance, then layer, then specificity, then source order.
  2. An invalidation system. Given a change, how much style, layout, paint and compositing must the browser redo? This is decided by what you have promised it about your boxes.

Most CSS architecture debate is about the first. Most CSS performance is the second.

The headline result: 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, from one declaration, with no nodes removed and no JavaScript written.

Rendering cost is a property of your CSS, not your DOM size. The DOM was never the problem; the instruction to the browser was.

The trap this module is built around

contain and content-visibility sound like the same feature and are not:

contain: strictcontent-visibility: auto
What it doesBounds propagation of a changeSkips the work entirely while offscreen
Measured on initial render398.4 ms (vs 401.9 baseline — nothing)7.9 ms (−98%)
Measured on the update path2.5 ms (−80%)n/a — different question

A team that reaches for contain expecting content-visibility's effect measures no improvement and concludes "containment doesn't work." Both tools are excellent. They answer different questions, and picking the wrong one produces a confident wrong belief.

What you should be able to do after this module

CapabilityEvidence you have it
Diagnose a slow renderDistinguish "too many nodes" from "too much invalidation" before proposing a fix
Choose the right containmentPick contain vs content-visibility from the question, not the vocabulary
Resolve a cascade disputePredict the winner from origin → layer → specificity → order, and be right
Architect a layer stackDesign @layer order as an ownership statement between teams
Avoid the !important inversionKnow why a design system's !important defaults become unoverridable

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.

The pipeline this module scopes

fe-01 established the rendering pipeline as a sequence. This module is about how much of it runs after a change:

   a change happens
        │
   ┌────▼──────────────────────────────────────────────────────┐
   │ STYLE     which rules match? what are the computed values? │  ← selectors, inheritance, custom props
   ├───────────────────────────────────────────────────────────┤
   │ LAYOUT    where is every box, how big?                     │  ← the expensive one; propagates
   ├───────────────────────────────────────────────────────────┤
   │ PAINT     what draw commands, in what order?               │  ← per-pixel, per-layer
   ├───────────────────────────────────────────────────────────┤
   │ COMPOSITE assemble layers on the GPU                       │  ← off main thread; transform/opacity
   └───────────────────────────────────────────────────────────┘

The rule that organises everything below: each stage forces every stage after it, and never any stage before it. So the entire performance game is entering the pipeline as late as possible, and affecting as few boxes as possible once you do.

You changeEntry pointCost class
transform, opacity (on a composited layer)CompositeCheapest; off the main thread
color, background-color, box-shadow, visibilityPaintModerate; scales with painted area
width, height, margin, padding, font-size, top, displayLayoutExpensive; propagates to siblings/ancestors
Adding/removing a class, changing a custom propertyStyle (then everything after)Scales with how many elements match

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.

The organisational cost of getting the cascade wrong

Specificity inflation is a ratchet. Without a layering strategy, every override must out-specify what came before, so the floor rises permanently and never falls. The measurable symptom is !important count over time — a number that, in a codebase without layers, only ever goes up.

The end state is familiar: nobody can predict which rule wins, every change is made by trial, and the only reliable tool left is !important, which is exactly the tool that makes the next change harder. Layers stop the ratchet structurally rather than by convention — and conventions are what decay across teams.


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

3.1 The cascade, resolved step by step

The cascade compares candidate declarations on each criterion in order and stops at the first difference. Measured, and every row here surprises someone:

casewinnerwhy
specificity only, no layersRED (1,1,0)highest specificity wins, as expected
@layer reset, base, app — app rule 0,1,0, base rule 1,1,0BLUE (0,1,0)later layer wins; specificity never consulted
unlayered .btn vs layered #widget .btn.primaryBLUE (unlayered)unlayered styles form an implicit final layer
!important in an earlier layerRED (earlier layer)author !important reverses layer order

Row 2 is the feature. A single class selector in a later layer beats an ID-plus-class selector in an earlier one. Specificity is not consulted at all, because the comparison already terminated at the layer step. This is what makes layers an ownership model rather than a naming convention.

Row 3 is the migration path. Unlayered styles form an implicit final layer, so anything you do not control — a third-party widget, an unmigrated legacy file — outranks your entire layer stack. During a migration that is a feature: legacy CSS keeps working while new CSS is layered underneath and cannot break it.

Row 4 is the trap. Author !important inverts layer order, so the earliest layer wins. A design system that ships defaults with !important becomes unoverridable by applications — precisely the opposite of its intent. That deserves a lint rule, not a paragraph in a contribution guide.

3.2 Containment: the promise you make to the engine

Each containment value is a promise, and the browser trades you optimisation for it:

ValueThe promise you makeWhat the engine may then skip
contain: layoutNothing inside changes layout outside this boxRe-laying-out anything beyond the box
contain: paintDescendants never paint outside this boxPainting/clipping outside; also creates a containing block and stacking context
contain: sizeThis box's size does not depend on its contentsMeasuring contents to size the box
contain: styleCounters and quotes cannot escapeTree-wide counter bookkeeping
contain: strictAll fourThe most, and constrains you the most

Break the promise and you break the layout, not just the optimisation. contain: size on a box whose content must grow produces a zero-height or clipped box — a correctness bug caused by a performance declaration. This is why containment belongs in code review rather than a stylesheet-wide sweep.

Measured, 500 cards, 60 mutations inside one of them:

variantstyle recalclayoutlayout count
no containment0.5 ms12.7 ms120
contain: layout0.5 ms10.5 ms (−18%)120
contain: strict + fixed height1.5 ms2.5 ms (−80%)120

Same mutation, same DOM, same number of forced layouts (120 in every row — the harness forces layout synchronously so the comparison is cost, not frequency).

Two details in that table carry the lesson:

Why layout alone gives only 18%. The box can still change size, so following siblings may still need to move. Adding contain: size (via strict) plus a fixed height removes that — the box cannot resize, so nothing outside it can be affected, and the layout collapses to the card's own subtree. size is the containment that actually pays; layout alone is half a promise.

Containment is not free. Style recalc rose from 0.5 ms to 1.5 ms under strict — the bookkeeping has a cost. It paid here by a factor of five. On a small subtree it would not pay at all, which is the argument against applying it everywhere by default.

3.3 content-visibility: skipping instead of bounding

Measured, 3,000 cards × 12 rows = 261,011 DOM nodes:

variantDOM nodesinitial styleinitial layout
no containment261,01199.5 ms401.9 ms
content-visibility: auto261,01110.9 ms7.9 ms
contain: strict261,01189.6 ms398.4 ms

content-visibility: auto tells the browser: this subtree is not visible right now — do not style it, do not lay it out, do not paint it, until it approaches the viewport. It implies contain: layout style paint, and adds the skipping on top.

contain: strict did essentially nothing here (398.4 vs 401.9 ms), and that is the important half of the result. Containment bounds propagation; everything was still being rendered.

Always pair it 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 240 px until it knows better, and to remember the real value once measured. A content-visibility deployment without it looks like a scrolling bug and gets reverted.

Where it does not apply: anything animating offscreen; anything whose size genuinely cannot be estimated. Note that skipped content is still findable by in-page search and reachable by sequential focus in modern Chromium — which is precisely why it beats display: none for this job — but that is a behaviour to test rather than assume.

3.4 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 */

Custom properties are inherited and resolved at computed-value time. Everything that makes them useful and everything they cost follows from those two facts:

  • Changing one on :root invalidates every element that inherits it — powerful for theming, and a real cost on large trees. Acceptable once per user action; not acceptable per frame, where it becomes a hidden animation cost.
  • They cannot be interpolated by transitions or animations unless registered with @property, which gives them a syntax and a type.
  • They are readable and writable from JS, which 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. A Sass variable is gone after compilation; a custom property is live, inherited, and overridable per subtree. The cost is that a token rename becomes a runtime break rather than a compile error — an argument for generating the token layer from a single source and type-checking consumers.


4. Core terminology

TermDefinition
CascadeThe algorithm selecting a winning declaration: origin/importance → layer → specificity → source order
Cascade layer@layer group; later layers win regardless of specificity
Unlayered stylesForm 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
:is()Same matching as :where(), but takes the specificity of its most specific argument
Custom property--x; inherited, resolved at computed-value time, dynamic at runtime
@propertyRegisters a custom property with a syntax and type, making it animatable
ContainmentPromise that limits how far a change can propagate
contain: sizeThe promise that actually pays — the box's size is independent of its contents
content-visibility: autoSkip rendering for offscreen subtrees; implies contain: layout style paint
contain-intrinsic-sizePlaceholder size for skipped subtrees; mandatory or the scrollbar lies
Style recalculationMatching selectors and computing values
Layout / reflowComputing geometry; propagates to siblings and ancestors unless contained
PaintRecording draw commands; scales with painted area
CompositeAssembling layers on the GPU; transform/opacity only, off the main thread
Stacking contextIsolated z-ordering scope, created by transform, opacity < 1, will-change, contain: paint
Containing blockThe box a percentage or absolute offset resolves against; contain: paint creates one
Logical propertyFlow-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.

Enter the pipeline as late as possible. Style forces layout forces paint forces composite, never the reverse. Animating transform instead of top is not a trick — it is entering at the last stage instead of the first. This one idea covers most of what is called "CSS performance."

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.

The right question selects the tool, not the vocabulary. "Most of this is offscreen" → content-visibility. "A change here must not disturb the page" → contain. Teams that memorise the feature list rather than the questions reach for whichever they read about most recently, and then disbelieve the whole area when it does not help.


6. Applied: the decisions this module actually makes

6.1 Choosing between contain and content-visibility

They are not alternatives; they answer different questions.

QuestionToolMeasured effect
"Most of this is offscreen — can the browser skip it?"content-visibility: auto98% 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: paintenables clipping optimisations, creates a containing block
"This box's size is independent of its content"contain: sizethe piece that made layout go from 18% → 80%

Checklist for a content-visibility rollout:

  1. Apply to repeated, mostly-offscreen subtrees — list items, cards, table sections. Not to the page shell.
  2. Always add contain-intrinsic-size with a realistic estimate. auto <n>px lets the browser remember the measured value.
  3. Verify in-page search (Ctrl-F) and sequential focus still reach the content.
  4. Check nothing inside animates while offscreen.
  5. Measure with renderer accounting, not a performance.now() wrapper (§6.5).

6.2 Designing a layer stack

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. Anything you do not control outranks your entire stack. During migration this is a feature.
  • !important inverts. Measured. A design system using !important for defaults becomes unoverridable. Lint rule, not 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 — and they compose well: a design system can ship :where(.btn) inside its components layer for belt-and-braces low priority.

6.3 The CSS strategy comparison, honestly

ApproachRuntime costScopingDynamic themingWhere it hurts
Global CSS + BEMnoneconvention onlycustom propertiesdiscipline decays across teams
CSS Modulesnonebuild-time hashingcustom propertiescomposition across packages is awkward
Runtime CSS-in-JSstyle injection + serialisation per renderautomatictrivialSSR complexity, hydration cost, RSC friction
Zero-runtime CSS-in-JSnone (extracted)automaticlimited to what is statically knownbuild complexity; dynamic values fall back to inline styles or custom properties
Atomic / utility-firstnonen/a (no cascade to manage)custom propertiesmarkup verbosity; ejecting is a rewrite

Two observations that matter more than the table:

  1. 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.
  2. 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).

6.4 Reviewing a diff — the seven smells

  1. !important in a shared or design-system stylesheet. Inverts layer order; makes the rule unoverridable by consumers. Almost always the wrong tool now that layers exist.
  2. contain: size on a box whose content can grow. A correctness bug dressed as an optimisation. Ask what happens when the content is longer than expected.
  3. content-visibility: auto with no contain-intrinsic-size. Ships as a scrolling bug.
  4. Broad will-change. .card { will-change: transform } across thousands of cards consumes GPU memory and degrades what it was meant to improve. Apply immediately before an animation, remove after.
  5. Animating top/left/width/height. Entering the pipeline at layout every frame. transform enters at composite.
  6. A new selector that raises specificity to win. The ratchet. Ask whether a layer is the right answer instead.
  7. Physical properties in new code (margin-left, padding-right, text-align: left). The cheapest possible moment to adopt logical properties; the most expensive to retrofit (fe-26).

6.5 Measuring style and layout honestly

  • Use the renderer's own accounting. Performance.getMetrics over CDP gives RecalcStyleDuration and LayoutDuration. A performance.now() wrapper around your JS cannot see style and layout, because they happen after your code returns — so it will report a fast function and a slow page.
  • The metrics are cumulative, so take deltas around the operation.
  • Force a settle first: two requestAnimationFrames plus a forced offsetHeight, or you measure whatever happened to have completed.
  • Fresh page per variant — the standard established in fe-02 and fe-03.
  • Equalise the layout count across variants. The invalidation experiment forces layout synchronously on every mutation so all variants perform 120 layouts. Without that, the browser coalesces differently per variant and the comparison measures frequency, not cost.

6.6 What breaks at scale

  • will-change overuse. Promotes elements to their own compositor layer, consuming GPU memory. Applied broadly it degrades the thing it was meant to improve.
  • Accidental stacking contexts. transform, opacity < 1, filter, will-change, contain: paint all create one. A z-index that "stops working" is nearly always an ancestor that quietly became a stacking context — and contain: paint, added for performance, is a common culprit. The two most common performance declarations in this module both have this side effect.
  • Specificity inflation is a ratchet. Without layers, every override raises the floor permanently. The measurable symptom is !important count over time.
  • Custom property invalidation on the root. Theme switching by rewriting :root variables invalidates the whole document — fine per user action, not per frame.
  • Physical properties block internationalisation. margin-left is a rewrite when RTL arrives; margin-inline-start is not.
  • Containment promises get broken by content. The optimisation and the correctness constraint are the same declaration, so containment belongs in code review, not in a stylesheet-wide sweep.

7. Common misconceptions

  1. "Large DOM is inherently slow." Measured false. 261,011 nodes rendered in 7.9 ms of layout with content-visibility: auto and 401.9 ms without. The node count was identical.

  2. "contain makes things faster." It bounds propagation. Measured: contain: strict gave no meaningful initial-render improvement, and 80% on the update path. Wrong tool → no result → wrong conclusion.

  3. "Specificity determines the winner." Only within a layer. Measured: 0,1,0 in a later layer beat 1,1,0 in an earlier one. And unlayered styles beat everything layered.

  4. "!important is always last resort but at least it's predictable." Measured: author !important reverses layer order, so the earliest layer wins. A design system's !important defeats the application's.

  5. "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.

  6. "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.

  7. "contain: layout is the performance win." Measured at 18%. contain: size is what took it to 80%, because a box that can still resize can still move its siblings. Half a promise buys about a fifth of the benefit.

  8. "content-visibility: auto hides content from search and screen readers." Unlike display: none, skipped content remains findable and focusable in modern Chromium — which is the whole reason it is usable for this. Test it rather than assuming either way.

  9. "will-change is a free hint." It allocates a compositor layer and GPU memory. Applied broadly it is a regression, and it silently creates a stacking context.

  10. "I measured it with performance.now() and it was fast." Style and layout run after your JS returns. A JS-side timer cannot see them, which is how a slow page produces fast numbers.


8. 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."
  • "contain and content-visibility solve different problems and teams conflate them. contain bounds propagation — 80% off our update path. It did essentially nothing for initial render, because everything was still being rendered. Reaching for the wrong one is how people conclude containment doesn't work."
  • "Within containment, size is the promise that pays. contain: layout alone measured 18%, because the box could still resize and move its siblings. Adding size took it to 80%."
  • "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 !important war structurally rather than by convention — and specificity inflation is a ratchet, so structural is the only kind that holds."
  • "The catch is !important inverts layer order — so a design system using !important for its defaults becomes unoverridable. That's worth a lint rule rather than a line in a 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. But it needs contain-intrinsic-size or it ships as a scrollbar bug."
  • "I don't accept style or layout numbers from a performance.now() wrapper. Those stages run after your JS returns, so the wrapper reports a fast function on a slow page. It has to be the renderer's own accounting."

9. 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-04 — the styling boundary is why teams abandon native elements; ::backdrop, ::part and appearance are negotiated with the tools in this module.
  • fe-06 (layout) — intrinsic sizing and container queries decide when containment is possible; contain: size and contain-intrinsic-size are the bridge, and container-type implies size containment.
  • fe-11 (performance) — style and layout are the presentation term of INP.
  • fe-26 (i18n) — logical properties are the difference between an RTL-capable stylesheet and a rewrite.
  • fe-30 (large-scale UI) — content-visibility is 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.
  • bi-07-css-engine — the invalidation machinery from the Blink side: how the style engine decides what to recompute.
  • browser-framework-internals.md §10 (CSS Engine), §12 (Layout Engine) — cross when you need invalidation implementation rather than behaviour.