Concepts — The CSS Engine: Matching, Cascade, and Invalidation
Phase 2 · Spec areas §10 (CSS engine internals), §11 (mini CSS engine).
Prerequisites: bi-01, bi-03, bi-04. Build not required.
Cross-track hook: the sibling track's CSS module covers cascade semantics; this one
covers the machinery — and specifically why a classList.add does not recompute the page.
1. Why a Principal Engineer needs this
1. "Why is style recalculation 80 ms?" is a question about invalidation, not about selectors. The folk answer — "your selectors are too complex" — is usually wrong. The real answer is almost always scope: how many elements got marked dirty, and why. Without the invalidation model you cannot tell a selector problem from a scope problem, and the fixes are completely different.
2. It is the clearest example in the browser of an index built to avoid work. Blink does not re-match every rule against every element on every change. It precomputes, from the stylesheets, a set of features that could possibly invalidate an element — then uses a DOM change to look up a small candidate set. Understanding this one mechanism transfers directly to every reactive framework you will evaluate, because they are all solving the same problem with the same shape of solution.
3. CSS-in-JS, utility CSS, and design-system architecture all have measurable consequences here. Whether a change adds a stylesheet, mutates a class, or writes an inline style determines which invalidation path runs. Teams argue about these on aesthetics; you should be able to argue from mechanism.
2. Mental Model
2.1 The pipeline
CSS text
-> tokenizer -> parser -> CSSStyleSheet (rules, selectors, declarations)
-> indexed into RuleSet / RuleFeatureSet <- the "compile" step
-> for each element: collect matching rules <- SelectorChecker
-> cascade + inheritance -> ComputedStyle
-> ComputedStyle feeds layout
Two things to notice immediately, because both contradict the naive model:
- Stylesheets are compiled and indexed, not scanned. Rules are bucketed by their rightmost simple selector (id, class, tag, attribute), so matching an element consults only the buckets that could apply, not every rule.
- Selectors are matched right-to-left.
.a .b .cstarts at.cand walks up. This is why the rightmost (key) selector dominates cost, and why "deep descendant selectors are slow" is a half-truth: the depth only costs when the key selector already matched.
2.2 Computed style, and why it is shared
ComputedStyle is the fully-resolved property set for an element. It is large. Blink
therefore shares immutable ComputedStyle objects between elements that resolve identically,
and this sharing is why a page with 10,000 similar rows does not carry 10,000 distinct style
objects. Style sharing is an optimisation with observable consequences: it is defeated by
things that make elements distinguishable (inline styles, sibling-position-dependent
selectors, unique ids), which is one mechanism by which "harmless" markup changes regress
memory and recalc time.
2.3 Invalidation is the whole subject
The question the engine must answer on every DOM change is:
Which elements' computed styles could possibly have changed?
Answering "all of them" is correct and unusably slow. Blink's answer, per its in-tree
style-invalidation.md, has three parts:
RuleFeatureSet— built when stylesheets are processed. For each feature that appears in a selector (a class, an id, an attribute, a pseudo-class), it records what would need invalidating if that feature changed on an element. These are invalidation sets.- DOM change → pending invalidations. A mutation (say, adding class
foo) looks upfooin the feature set and produces invalidation sets, stored in aPendingInvalidationsMap— deliberately not applied yet. - Pushing pending invalidations. Later, the pending sets are pushed down the tree, marking the specific elements that need recalculation.
The essential design idea:
Turn "re-match everything" into "look up what this change could affect." The stylesheet is preprocessed into an index keyed by the things that can change.
Invalidation sets come in flavours — descendant (this change affects descendants matching X) and sibling (this change affects following siblings) — and there is always a fallback to whole-subtree invalidation when the analysis cannot be precise. Finding what triggers that fallback is the single most practically valuable thing in this module: those are the selectors that quietly make your page expensive.
2.4 Why classList.add is cheap and why sometimes it isn't
el.classList.add('active');
- Look up
activein theRuleFeatureSet. - If no rule mentions
.active, nothing is invalidated. This is the common case and it is essentially free. - If rules mention it, schedule the corresponding invalidation sets.
- Nothing is recomputed now. Recalculation happens at the next rendering opportunity —
unless something forces a synchronous flush first (
bi-04, and vertical trace #2).
The pathological cases are the ones where the analysis degrades to subtree invalidation.
Selectors involving sibling combinators, :has(), and certain attribute patterns are the
usual suspects. Predict which, then verify — that prediction is the lab.
2.5 Custom properties and container queries
Two features that complicate the model in instructive ways:
- Custom properties inherit, so a change at the root can affect an arbitrary subtree. The
engine needs machinery to avoid making every
--foochange a full-page recalc. - Container queries make an element's style depend on an ancestor's layout. That is a dependency from style on layout, in a pipeline that otherwise runs style→layout. Chasing how that cycle is resolved is the single best "why is production complex?" exercise in this module — the naive design (just run style, then layout) provably cannot work.
3. Under the Hood
Derive, do not memorise. Blink's own docs are unusually good here and are the right entry
point: core/css/README.md, style-calculation.md, style-invalidation.md.
| Concern | What to look for |
|---|---|
| Parsing | CSS parser + tokenizer in core/css/ |
| Property definitions | css_properties.json5 — declared, not written (bi-01 Technique 4) |
| Keywords | css_value_keywords.json5 |
| Rule indexing | RuleSet, RuleFeatureSet |
| Selector matching | SelectorChecker::MatchSelector |
| Computed style production | Element::StyleForLayoutObject |
| Pending invalidation state | PendingInvalidationsMap |
| Style storage fields | computed_style_extra_fields.json5 and friends |
Note how much of this is generated from .json5. If you grep for a property name and find
only tests, you have met bi-01 Technique 4 in its natural habitat.
3.5 Deep dive: the property table already knows what invalidates
Before you write a single line of invalidation analysis, know this: Blink records, declaratively
and per property, which pipeline stage a change dirties. It is in core/css/css_properties.json5,
in the invalidate: field.
{ name: "contain", ..., invalidate: ["layout"], is_animation_affecting: true }
Measured across the file (2026-08-10): 822 property entries, 311 of which declare invalidate:.
The distribution of the most common declarations:
invalidate: value | Properties | Reading |
|---|---|---|
["layout", "paint"] | 95 | geometry and appearance — the expensive class |
["paint"] | 50 | appearance only — no geometry work |
["layout"] | 34 | geometry only |
["layout", "scroll-anchor"] | 13 | also disturbs scroll anchoring |
["color"] | 10 | a dedicated colour-only path |
["text-decoration"] | 8 | narrower than "paint" |
["border-radius", "paint"] | 8 | |
["reshape"] | 7 | text shaping must be redone |
["transform-data", "transform-other"] | 5 | property-tree data, not paint |
["compositing"] | 4 | compositor-only |
Why this matters more than it looks
The folk model of web performance has three buckets — layout, paint, composite — and advises
you to "prefer composite-only properties." The real vocabulary is far more granular:
scroll-anchor, reshape, transform-data, has-transform, border-visual, ax-style,
box-paint-property, border-outline-visited-color.
Three things follow.
1. "Does this property cause layout?" is a grep, not a debate. You can settle a design argument in thirty seconds with the tree.
2. The buckets are not equally coarse. ["text-decoration"] and ["color"] exist because
repainting everything for a colour change was worth avoiding. Someone measured that. Fine-grained
invalidation categories are the accumulated record of optimisations that paid off.
3. ["ax-style", ...] means accessibility has its own invalidation. The accessibility tree is a
real consumer of style, maintained incrementally like everything else — not a debug view generated
on demand. That single field is the best argument in the tree against treating accessibility as
an afterthought: it is in the pipeline.
The exercise
Take the ten CSS properties you use most. Predict each one's invalidate: value. Then grep. Your
error rate on this is a direct measurement of how good your performance intuition actually is —
and most engineers who consider themselves strong on CSS performance get 3–4 wrong.
Pay attention to any property where you predicted ["paint"] and the answer includes "layout".
Those are the ones costing you frames today.
3.6 Deep dive: the invalidation machinery, named
Blink's core/css/style-invalidation.md describes the mechanism in three stages. The class names
are the vocabulary you need to read the code.
Stage 1 — building the index (RuleFeatureSet)
When stylesheets are processed, Blink extracts, for every feature appearing in a selector — a class, an id, an attribute, a pseudo-class — a description of what would need invalidating if that feature changed on an element. These descriptions are invalidation sets.
Think of it as inverting the stylesheet: instead of "selector → elements it matches," you build "feature → what to invalidate when it changes."
Stage 2 — DOM change → pending invalidations (PendingInvalidationsMap)
A mutation looks up its feature in the RuleFeatureSet and produces invalidation sets, stored as
pending. Deliberately not applied yet — batching is what makes a thousand DOM writes cost one
recalculation.
Stage 3 — pushing pending invalidations
Later, sets are pushed down the tree, marking the specific elements needing recalculation.
The flavours, and the cliff
Invalidation sets are not one kind:
- Descendant — "if this feature changes, descendants matching X need recalc."
- Sibling — "following siblings need recalc" (the
+and~combinators). - Nth —
:nth-child()and friends, where a structural change shifts everyone's index. - Part / slotted — crossing shadow boundaries.
And there is always a fallback to whole-subtree invalidation when the analysis cannot be precise. Finding what triggers the fallback is the most practically valuable thing in this module: those selectors are the ones quietly making your page expensive.
The precision of the analysis is bounded by the expressiveness of the selector language. Every selector feature added to CSS is a new case the invalidator must either analyse precisely or give up on.
:has()is the sharpest example: it inverts the matching direction, so a change to a descendant can affect an ancestor's match — the exact direction the tree walk was designed around.
Matching: SelectorChecker::MatchSelector
Selectors are matched right to left, starting from the key (rightmost) simple selector. Rules
are bucketed by that key selector in the RuleSet, so matching an element consults only the
buckets that could apply — by id, by class, by tag, by attribute — never the whole stylesheet.
This is why "avoid deep descendant selectors" is a half-truth: .a .b .c only walks ancestors
after .c has already matched. If .c is rare, the selector is cheap regardless of depth. If
the key selector is div, you have a problem no amount of shortening fixes.
Producing the value: Element::StyleForLayoutObject
Collect matched rules → cascade → resolve → ComputedStyle. The output is shared between elements
that resolve identically (see below).
3.7 Deep dive: style sharing, and how you break it
ComputedStyle objects are large and immutable, and Blink shares them between elements whose style
resolves identically. On a 10,000-row table this is the difference between 10,000 style objects and
a handful.
Things that make elements distinguishable and therefore defeat sharing:
- inline
styleattributes (each is unique), - unique ids referenced by a rule,
- sibling-position-dependent selectors (
:nth-child,+,~), :hover/:focusstate differing per element,- different attribute values that some selector reads.
The design consequence. "Just use inline styles, it skips selector matching" is true and usually a net loss on a large list: you skipped matching and destroyed sharing. This is a real architectural argument in the CSS-in-JS discussion — one that is almost always conducted on ergonomics instead.
The failure mode is memory and time, and it appears as a cliff rather than a slope, which makes it hard to catch in small tests. Measure at realistic list sizes.
3.8 Deep dive: values, and the three that get confused
The cascade produces several distinct notions of "value," and mixing them up causes real bugs.
| Value | What it is | Example |
|---|---|---|
| Specified | what the cascade selected, after inherit/initial | width: 50% |
| Computed | resolved as far as possible without layout | 50% stays 50%; em → px |
| Used | after layout, when geometry is known | 50% → 320px |
| Resolved | what getComputedStyle() returns — used value for some properties, computed for others | varies per property |
That last row is the trap. getComputedStyle() returns the resolved value, and whether that is
computed or used depends on the property. For layout-dependent properties it is the used value —
which is precisely why reading it can force layout (bi-08).
Custom properties complicate this further. They inherit, are substituted at computed-value time,
and are (mostly) untyped unless registered via @property. A change to --x at the root can
therefore affect an arbitrary subtree, and registration with @property is what lets the engine
know a custom property's type — which is what makes it animatable and more precisely invalidatable.
3.9 Deep dive: the cycle problem — container queries
Container queries let an element's style depend on an ancestor's layout. In a pipeline that runs style → layout, that is a dependency pointing the wrong way, and it is genuinely circular: the container's size can depend on its contents, whose style depends on the container's size.
The platform breaks the cycle with containment requirements: a query container must establish
containment on the queried axis (container-type: inline-size implies inline-size containment).
Containment means the container's size in that axis does not depend on its contents — which
severs the loop by construction rather than by iteration limit.
This is a beautiful piece of spec design and the best §46 entry in this module: the feature is only implementable because a matching restriction was added at the same time. The restriction is not a wart; it is what makes the feature possible.
Compare content-visibility: auto and contain-intrinsic-size, which apply the same idea to skip
work for offscreen content: the author promises something about the subtree, and the engine uses
the promise to avoid work. Same shape as {passive: true} (bi-10) and key (fw-02) —
an author-supplied guarantee unlocking an optimisation the runtime could not derive.
4. Anti-Patterns
"Selector complexity is the problem." Usually it is scope. Measure how many elements were recalculated before optimising how a rule is written.
Reading getComputedStyle in a loop that also mutates. Forces a style/layout flush per
iteration. Same shape as the geometry-read bug from bi-04.
Assuming inline styles are fast because they skip matching. They skip matching and defeat style sharing. On a large list this can be a net loss.
Believing every !important or specificity trick is free. They are cheap at match time;
their real cost is that they make the cascade unpredictable to humans, which produces the
selector sprawl that does cost.
Treating :has() as a normal selector. It inverts the matching direction and can force
much broader invalidation. Use it deliberately, and measure.
5. Trade-offs
Precision of invalidation vs cost of computing it. More precise analysis means less recalculation but more work per mutation and more complexity in the feature set. Blink's fallback-to-subtree behaviour is the deliberate cutoff — worth finding and defending.
Style sharing vs simplicity. Sharing saves substantial memory and time, at the cost of a non-obvious performance cliff whenever something defeats it.
Declarative property definitions (.json5) vs hand-written code. Generation guarantees
that every property gets consistent parsing, inheritance and animation handling — but makes
the subsystem opaque to newcomers, and means adding a property is a build-system change.
6. Lab — mini-browser M4–M6
Build a CSS engine supporting div {}, .foo {}, #header {}, .parent .child {}.
- Tokenizer + parser producing a rule list.
- Selector representation and right-to-left matching.
- Specificity, cascade, inheritance, computed style.
- Deliberately naive first: on any DOM change, recompute every element's style. Measure on a 10,000-node tree.
- Then build an index. Bucket rules by key selector. Measure again.
- Then build invalidation sets. On
classList.add(x), consult a precomputed map from class → affected-descendant descriptors. Measure again. - Add a selector your analysis cannot handle precisely (a sibling combinator). Implement the subtree fallback. Measure the cliff.
Deliverable: a table of recalculated-element counts and wall time for stages 4, 5, 6 and 7, plus a written explanation of where each speedup came from. Stage 7 is the point of the lab — you must be able to state exactly which selector shapes cost you precision.
7. Failure Lab
- Invalidate too little. Deliberately omit sibling invalidation. Construct markup that renders incorrectly. This is the bug class the complexity exists to prevent.
- Invalidate too much. Fall back to whole-document invalidation always. Show it is correct and unusable.
- Style-sharing defeat. Add a unique inline style to every row of a 10,000-row list. Measure memory and recalc time against the shared version.
- The container-query cycle. Construct a case where an element's style depends on a container's size, which depends on that element's size. Predict the outcome, then check what real browsers do and find the rule that breaks the cycle.
8. Debugging Exercise
- DevTools → Performance: capture a
classList.addthat triggers recalc. Find the number of elements affected. Find where DevTools reports it. - Construct two changes with identical visual effect but 100× different recalc scope. Explain the difference by selector shape alone.
- Trace with the
blinkcategory and locate style-recalc trace events. Correlate one back to source by grepping its literal event name (bi-01, rung 3 → rung 1). - Find, in the local checkout, the code path that decides to fall back to subtree invalidation. Write down the exact condition.
9. References
- CSS Cascading and Inheritance; CSS Selectors; CSS Containment; CSS Container Queries specs.
third_party/blink/renderer/core/css/README.mdthird_party/blink/renderer/core/css/style-calculation.mdthird_party/blink/renderer/core/css/style-invalidation.md— read this in full; it is short and it is the authoritative description of the mechanism above.css_properties.json5— read a dozen entries to learn what a property declaration contains.
10. Principal Engineer Review
-
A team reports style recalc regressed 5×. Give three mechanisms, and the single cheapest observation that discriminates them.
-
Explain right-to-left selector matching to a senior engineer, then explain precisely why "avoid deep selectors" is a half-truth.
-
Invalidation sets are an index built from the stylesheets. What is the equivalent structure in a reactive UI framework, and where does the analogy break?
-
Argue that whole-subtree invalidation fallback is a bug. Then argue it is the correct engineering decision. What evidence would settle it?
-
Your design system is choosing between utility classes, CSS-in-JS with generated class names, and inline styles. Argue each from invalidation and style-sharing mechanics, not from developer experience.
-
:has()was added despite known performance risk. Reconstruct the argument for shipping it. What would you have required before enabling it by default? -
Container queries create a style→layout→style dependency. Design a rule that makes this terminate. What does your rule forbid, and would authors notice?
-
Custom properties inherit, so a root change can affect everything. Design the optimisation that avoids full-page recalc, and name the case where it must give up.
-
An engineer proposes banning descendant selectors org-wide via lint. Strongest case for, strongest case against, and what you actually do.
-
You must explain to a product manager why adding one CSS rule made the page 30 % slower to update. Five sentences, no jargon.