The Runtime Theory
Web Platform

How CSS Selectors Actually Match: Right-to-Left and Specificity

CSS selectors match right-to-left from the subject element — why div a is costlier than a, how specificity is computed, and what actually makes matching slow.

The Runtime Theory Team3 min read#css#selectors#specificity#rendering#performance
On this page

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 a div, 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.

css
#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.