CSS :has() Performance Inside Safari's Selector Engine
This article explores how Safari’s WebKit engine processes and
optimizes the CSS :has() relational pseudo-class. Once
considered too performance-heavy for browsers to implement, the
long-awaited "parent selector" became a reality in Safari through
advanced style invalidation techniques, internal element flagging, and
caching mechanisms that prevent costly rendering slowdowns.
The Core Performance Challenge
To understand how Safari optimizes :has(), you must
first understand how browser selector engines typically work. CSS
selectors are evaluated from right to left. In a standard selector like
.card .title, Safari starts at the .title
element and walks up the DOM tree to see if it sits inside a
.card. This is extremely fast because any given HTML
element only has one direct parent.
The :has() pseudo-class breaks this model. A selector
like .card:has(.title) forces the engine to start at
.card and search downward through all of its
descendants. Because an element can have thousands of descendants,
checking them all is computationally expensive. Furthermore, any dynamic
change to a child element—like adding a class via JavaScript—now
requires the browser to walk up the tree and recalculate the styles of
the parent.
Selective Style Invalidation
Safari avoids performance bottlenecks primarily by optimizing style invalidation—the process of figuring out which elements need their styles recalculated when the DOM changes.
Instead of re-evaluating :has() rules on every DOM
mutation, Safari’s WebKit engine parses the arguments inside the
:has() parentheses and creates strict invalidation sets.
For example, if your CSS contains div:has(.active), Safari
maps the .active class as an invalidation trigger. If
JavaScript adds a .hidden class to a child inside that
div, Safari’s engine completely ignores the
:has() rule because .hidden is not in the
invalidation set.
Internal Element Flagging
To prevent the engine from unnecessarily scanning the entire
document, WebKit uses internal memory flags on DOM nodes. When the page
initially loads and styles are calculated, Safari marks specific
elements that are relevant to :has() evaluation:
- Affected by
:has(): The engine flags the subject element (the parent) so the browser knows its styles depend on its children. - Ancestors of
:has()targets: Elements sitting between the parent and the target child are flagged.
When an element deep in the DOM is modified, added, or removed,
Safari walks up the DOM tree from that point. If it does not encounter
any elements carrying these internal flags, it immediately stops
checking for :has() changes, saving massive amounts of CPU
time.
Subtree Caching
During a single style resolution pass, a naive engine might end up
checking the same group of child elements multiple times, especially if
multiple parents use similar :has() rules.
Safari solves this with a temporary :has() match cache.
As the selector engine traverses the DOM to resolve styles, it records
the results of :has() evaluations. If it determines that a
specific subtree does not contain a .featured class, it
caches that negative result. If another parent element in the same chain
asks the same question milliseconds later, Safari reads the answer from
the cache rather than walking down the DOM tree a second time.
Fast Rejections with Bloom Filters
Safari also leverages an existing optimization known as a Bloom
filter—a fast, memory-efficient data structure used to quickly tell if
an element is not present in a set. Before Safari performs a
heavy downward tree traversal to check if a parent :has() a
specific child, it checks the Bloom filter. If the filter indicates that
the target class, ID, or tag name doesn't exist in that branch of the
DOM, the engine instantly rejects the match and moves on in
nanoseconds.
Real-World Performance Limits
Because of WebKit's optimizations, :has() performs
nearly identically to standard CSS selectors in 99% of use cases.
However, developers can still bypass Safari's guardrails and trigger
slowdowns by writing overly broad selectors:
- Unscoped subjects: Writing
:has(.active)without attaching it to a specific class or tag (which defaults to*:has(.active)) forces Safari to flag and check almost every node in the DOM tree. - Universal arguments: Writing
.container:has(*)defeats the selective invalidation engine, as any DOM mutation of any kind will trigger a recalculation. - Deep sibling combinators: Combining
:has()with complex sibling selectors (like~) across massive, flat DOM trees can overload the caching mechanism and cause noticeable frame drops during page updates.