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:
- A resolution algorithm. Given many competing declarations, which one wins? This is the cascade — origin/importance, then layer, then specificity, then source order.
- 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: strict | content-visibility: auto | |
|---|---|---|
| What it does | Bounds propagation of a change | Skips the work entirely while offscreen |
| Measured on initial render | 398.4 ms (vs 401.9 baseline — nothing) | 7.9 ms (−98%) |
| Measured on the update path | 2.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
| Capability | Evidence you have it |
|---|---|
| Diagnose a slow render | Distinguish "too many nodes" from "too much invalidation" before proposing a fix |
| Choose the right containment | Pick contain vs content-visibility from the question, not the vocabulary |
| Resolve a cascade dispute | Predict the winner from origin → layer → specificity → order, and be right |
| Architect a layer stack | Design @layer order as an ownership statement between teams |
Avoid the !important inversion | Know 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 change | Entry point | Cost class |
|---|---|---|
transform, opacity (on a composited layer) | Composite | Cheapest; off the main thread |
color, background-color, box-shadow, visibility | Paint | Moderate; scales with painted area |
width, height, margin, padding, font-size, top, display | Layout | Expensive; propagates to siblings/ancestors |
| Adding/removing a class, changing a custom property | Style (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:
| 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 |
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:
| Value | The promise you make | What the engine may then skip |
|---|---|---|
contain: layout | Nothing inside changes layout outside this box | Re-laying-out anything beyond the box |
contain: paint | Descendants never paint outside this box | Painting/clipping outside; also creates a containing block and stacking context |
contain: size | This box's size does not depend on its contents | Measuring contents to size the box |
contain: style | Counters and quotes cannot escape | Tree-wide counter bookkeeping |
contain: strict | All four | The 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:
| 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 (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:
| 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 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
:rootinvalidates 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
| Term | Definition |
|---|---|
| Cascade | The algorithm selecting a winning declaration: origin/importance → layer → specificity → source order |
| Cascade layer | @layer group; later layers win regardless of specificity |
| Unlayered styles | Form 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 |
@property | Registers a custom property with a syntax and type, making it animatable |
| Containment | Promise that limits how far a change can propagate |
contain: size | The promise that actually pays — the box's size is independent of its contents |
content-visibility: auto | Skip rendering for offscreen subtrees; implies contain: layout style paint |
contain-intrinsic-size | Placeholder size for skipped subtrees; mandatory or the scrollbar lies |
| Style recalculation | Matching selectors and computing values |
| Layout / reflow | Computing geometry; propagates to siblings and ancestors unless contained |
| Paint | Recording draw commands; scales with painted area |
| Composite | Assembling layers on the GPU; transform/opacity only, off the main thread |
| Stacking context | Isolated z-ordering scope, created by transform, opacity < 1, will-change, contain: paint |
| Containing block | The box a percentage or absolute offset resolves against; contain: paint creates one |
| Logical property | Flow-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.
| Question | Tool | Measured effect |
|---|---|---|
| "Most of this is offscreen — can the browser skip it?" | content-visibility: auto | 98% 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: paint | enables clipping optimisations, creates a containing block |
| "This box's size is independent of its content" | contain: size | the piece that made layout go from 18% → 80% |
Checklist for a content-visibility rollout:
- Apply to repeated, mostly-offscreen subtrees — list items, cards, table sections. Not to the page shell.
- Always add
contain-intrinsic-sizewith a realistic estimate.auto <n>pxlets the browser remember the measured value. - Verify in-page search (Ctrl-F) and sequential focus still reach the content.
- Check nothing inside animates while offscreen.
- 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.
!importantinverts. Measured. A design system using!importantfor 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
| Approach | Runtime cost | Scoping | Dynamic theming | Where it hurts |
|---|---|---|---|---|
| Global CSS + BEM | none | convention only | custom properties | discipline decays across teams |
| CSS Modules | none | build-time hashing | custom properties | composition across packages is awkward |
| Runtime CSS-in-JS | style injection + serialisation per render | automatic | trivial | SSR complexity, hydration cost, RSC friction |
| Zero-runtime CSS-in-JS | none (extracted) | automatic | limited to what is statically known | build complexity; dynamic values fall back to inline styles or custom properties |
| Atomic / utility-first | none | n/a (no cascade to manage) | custom properties | markup verbosity; ejecting is a rewrite |
Two observations that matter more than the table:
- 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.
- 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
!importantin a shared or design-system stylesheet. Inverts layer order; makes the rule unoverridable by consumers. Almost always the wrong tool now that layers exist.contain: sizeon a box whose content can grow. A correctness bug dressed as an optimisation. Ask what happens when the content is longer than expected.content-visibility: autowith nocontain-intrinsic-size. Ships as a scrolling bug.- 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. - Animating
top/left/width/height. Entering the pipeline at layout every frame.transformenters at composite. - A new selector that raises specificity to win. The ratchet. Ask whether a layer is the right answer instead.
- 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.getMetricsover CDP givesRecalcStyleDurationandLayoutDuration. Aperformance.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 forcedoffsetHeight, 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-changeoveruse. 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: paintall create one. A z-index that "stops working" is nearly always an ancestor that quietly became a stacking context — andcontain: 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
!importantcount over time. - Custom property invalidation on the root. Theme switching by rewriting
:rootvariables invalidates the whole document — fine per user action, not per frame. - Physical properties block internationalisation.
margin-leftis a rewrite when RTL arrives;margin-inline-startis 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
-
"Large DOM is inherently slow." Measured false. 261,011 nodes rendered in 7.9 ms of layout with
content-visibility: autoand 401.9 ms without. The node count was identical. -
"
containmakes things faster." It bounds propagation. Measured:contain: strictgave no meaningful initial-render improvement, and 80% on the update path. Wrong tool → no result → wrong conclusion. -
"Specificity determines the winner." Only within a layer. Measured:
0,1,0in a later layer beat1,1,0in an earlier one. And unlayered styles beat everything layered. -
"
!importantis always last resort but at least it's predictable." Measured: author!importantreverses layer order, so the earliest layer wins. A design system's!importantdefeats the application's. -
"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.
-
"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.
-
"
contain: layoutis the performance win." Measured at 18%.contain: sizeis 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. -
"
content-visibility: autohides content from search and screen readers." Unlikedisplay: 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. -
"
will-changeis a free hint." It allocates a compositor layer and GPU memory. Applied broadly it is a regression, and it silently creates a stacking context. -
"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." - "
containandcontent-visibilitysolve different problems and teams conflate them.containbounds 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,
sizeis the promise that pays.contain: layoutalone measured 18%, because the box could still resize and move its siblings. Addingsizetook 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
!importantwar structurally rather than by convention — and specificity inflation is a ratchet, so structural is the only kind that holds." - "The catch is
!importantinverts layer order — so a design system using!importantfor 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 needscontain-intrinsic-sizeor 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,::partandappearanceare negotiated with the tools in this module.fe-06(layout) — intrinsic sizing and container queries decide when containment is possible;contain: sizeandcontain-intrinsic-sizeare the bridge, andcontainer-typeimplies size containment.fe-11(performance) — style and layout are thepresentationterm of INP.fe-26(i18n) — logical properties are the difference between an RTL-capable stylesheet and a rewrite.fe-30(large-scale UI) —content-visibilityis 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.