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.