One :has() selector was 70% of our TBT: 369 → 108 ms.
The home page’s blocking time grew sixfold in a single round; the culprit was not the new scenes or the animation library but ten CSS rules that never matched on that page.
After one development round the home page’s blocking time had gone from 61 milliseconds to 369. The first suspect was the usual one, JavaScript: that round had added two new scenes and the animation library was producing long tasks. The measurement said something else — what was expensive was not the script but the document the script touched.
Hypothesis
Splitting the browser trace into script, style and layout made the weight visible: total style recalculation had gone from 423 milliseconds to 1,392. The document had indeed grown, from 671 elements to 1,733, but growth was not the point: every frame was recalculating the WHOLE document. That was the hypothesis — a selector is anchored at the root and invalidates the entire tree on every small change.
Method
Two trees were run side by side: the live build from four days earlier in a temporary working copy, the current tree next to it, both under the same mobile profile (412 × 823, pixel ratio 1.75, CPU slowed fourfold). Then the stylesheet was rewritten in flight by a request interceptor — families of rules were removed one at a time without a byte changing on disk, and the median blocking time was read each time. The browser’s own invalidation trace was switched on as well; it writes, line by line, which element was affected by which selector.
Date and environment
- date → 2026-09-21 · local A/B · two trees side by side
- profile → 412 × 823 · DPR 1.75 · CPU 4× slowdown
- metric → median TBT · total style recalc · element count
- trace → devtools.timeline.invalidationTracking
Result: the bisect
Removing rules by family left the culprit standing alone. With all :has() selectors removed, blocking time fell from 369 milliseconds to 104; with only those whose subject was html or body removed, to 115. Tried one at a time, nearly all of the weight collapsed into a single family.
- as shipped → TBT 369 ms
- all :has() removed → 104 ms
- only html/body subjects → 115 ms
- :has(.menu … → 431 ms
- :has(main.oda) … → 430 ms
- :has(.sayfa__ … → 112 ms
The culprit: ten rules that never matched
The remaining candidate was ten rules in the base stylesheet, all of the same shape: a :has() whose subject is html, followed by a broad right-hand side. We also measured that those rules did not match on the home page — not one of them painted anything there. And still, every tween and every engine setup invalidated the entire document. Because the browser can only answer “might this change have altered the result of that :has() at the root?” by recalculating the subtree. The invalidation trace said so by name: 350 and 347 records of “affected by :has()” on html and body, annotated “invalidation set invalidates subtree”.
The fix: move the question to build time
The question stayed the same; who asks it changed. Which host a page carries is a structure that does not change after load — so it is already known at build time. The generator looks at the final HTML and stamps a marker on the root element, and the stylesheet now asks the marker. Specificity stayed identical, so no rule order shifted; the computed-style difference came out as zero across twelve routes, two widths and more than fifteen thousand elements.
- local TBT → 369 → 97–108 ms (5 runs)
- style recalc → 1,415 → 783 ms
- computed diff → 0 (12 routes × 2 widths)
- live median TBT → 293 → 83 / 86 ms
The guard
The finding was wired to a gate, because a decision written down as a comment gets quietly broken in the next round. The gate asks three things: do marker and host match both ways on disk, is that pattern still absent from the built stylesheet, and how many times is the root’s subtree invalidated while two routes load. That the gate can fail was verified by putting the defect back: with the old rules restored the counter goes from two to forty-nine. A guard that cannot fail is not green, only silent.
Limits
These numbers belong to this machine and this document; on another page the ratio will differ. Nor is the lesson “:has() is slow” — used in place it is cheap. The lesson is this: a :has() whose subject is html or body invalidates the whole document on every change, even on a page where it never matches. A structure that does not change after load should be marked at build time instead of being asked with a selector at runtime.
A selector costs you where it searches, not where it matches.