CSS selectors are the only part of the platform where a string you write gets matched
against every element on the page, on every style recalc. Understanding how the engine
does that matching — and the ordering rule that makes it fast — is the difference between
throwing div a at a problem and knowing why a 200-rule stylesheet is faster than a
20-rule one with the wrong selectors.
Matching is right-to-left
When the browser applies styles, it works element-first, not rule-first. For each
element on the page it asks: which rules match me? And it evaluates each candidate rule
from the rightmost selector ("the subject") leftward. For div a, the engine finds
<a> elements and walks up the ancestor chain looking for a div. The rule only
matches if the walk succeeds.
That direction is the entire performance story. Consider div a vs a:
a— the engine checks the element's tag. One comparison.div a— for every<a>, the engine must walk ancestors until it finds adiv, or exhausts the chain. The cost is proportional to the depth of the DOM, and it must re-check whenever the DOM changes.
div a is not a slow selector in the 2026 engine — selector matching is indexed, and
modern engines bucket rules by their key (rightmost) selector so only plausible rules are
tested. But it is structurally more work: the ancestor walk can never be avoided, and on
deep DOMs (large apps routinely have 20+ levels), div a multiplies matching cost by
that depth. The same logic makes div ul li a worse still: three ancestor hops per
subject, chained.
What the engine actually does
Chromium and WebKit use rule hashing: each rule is indexed by its key selector (the
rightmost compound selector), so checking a.foo only tests the rules whose key contains
a and .foo. Gecko builds a selector map of class and ID hashes with the same idea.
So matching cost is dominated not by stylesheet size but by (a) the number of elements,
and (b) the specificity of the key — an ID key (#nav a) narrows candidates to one
element's subtree; a type key (a) is tested against every <a>.
The practical translation:
- Slow: universal keys —
*,* *, bare attribute selectors like[data-x]— because the key matches every element. - Moderate: type keys (
div,a) — tested per element of that type. - Fast: class and ID keys (
#app .item) — the hash lookup narrows candidates before any ancestor walk.
Descendant selectors are the expensive shape; child (>), sibling (~, +) and
:not() add structural checks but remain cheap when the key is specific. If you want a
single rule that lands on every element in a subtree, that's #app * — and it is exactly
as costly as it sounds, because * is the key.
Specificity: the tiebreaker
When two rules match the same element, the cascade needs an ordering. Specificity is computed per rule as three counts — (a, b, c):
- a — inline
style=""attributes (count 1 if present, 0 otherwise) - b — ID selectors (
#app) - c — classes, attributes, pseudo-classes (
.item,[type="x"],:hover)
Element and pseudo-element selectors (div, ::before) count in a fourth bucket, below
c. Compare left to right: #app .item beats div .item because a=1 beats a=0, regardless
of anything to the right. :not() takes the specificity of its argument — :not(#x) is
an ID-level selector. !important overrides all of this (and then re-ties by
specificity), which is why !important is a cascade time-bomb: it stops being a tiebreaker
and starts being a property you can't override without more !important.
#app .item /* specificity: 1,1,0 */
.item.item /* specificity: 0,2,0 — loses to the ID rule */
div.item /* specificity: 0,1,1 — loses to both */Where matching cost actually shows up
Style recalc runs on every DOM mutation that can affect matching: class changes, attribute
changes, node insertion. A page with 10,000 elements and rules keyed on * will spend
meaningful time re-testing every element on each change. The fix is never "fewer rules" —
it's more specific keys, so each mutation only re-tests the elements whose keys could
have changed.
The bottom line
div a is costlier than a because of the ancestor walk, not the characters. Specificity
decides which rule wins, but the rightmost selector decides whether the engine even
tries. Match your key selectors narrowly, keep descendant chains shallow, and leave
!important for the one rule you genuinely cannot reach otherwise.