Skip to content
SiteEmail

A recurring reason for performance issues in styling is the use of complex selectors in CSS. In Gameface, certain CSS selector patterns are more expensive to evaluate than others. When overused, complex selectors can trigger “invalidation storms” that negatively affect frame rates. This guide explains how style matching works at a high level and how to avoid common performance pitfalls.

How to confirm if complex selectors are the cause of styling performance issues?

Section titled “How to confirm if complex selectors are the cause of styling performance issues?”
  1. Check Scope: Look for “Style Matching” markers in your performance profiler. If there are many of them in the slow frame, it is likely due to complex selectors.
  2. Isolate: Temporarily remove complex selectors. If performance improves significantly, you’ve found your culprit.

If that is indeed the cause, here are our recommendations

Style matching is the process where Gameface determines which CSS rules apply to a specific element. It is essentially a search and filter operation.

  1. Filtering: Gameface iterates through all available CSS selectors to find those that match the element’s type, classes, IDs, and attributes.
  2. Resolution: If multiple rules apply to the same property (e.g., two different selectors both set color), Gameface uses Specificity and Precedence to decide which one “wins.”
  3. Invalidation: When an element changes (e.g., you add a class via script), Gameface must decide which other elements need to have their styles matched again.

The performance cost isn’t just in the matching itself, but in how many elements must be re-evaluated when a single change occurs.

Selector TypeExampleInvalidation ScopeWhy it’s a Pitfall
Direct.my-classLocalOnly affects the element itself. Predictable and fast.
Sibling.a + .b, .a ~ .bLinear (Wide)Affects siblings under the same parent. Performance drops significantly in wide layouts (e.g., long lists/grids). If a sibling changes (added, removed, or state change), Gameface must re-check the entire sibling set. In a wide hierarchy, a single change can trigger a “restyle all siblings” operation.
Descendant.a .bRecursive (Deep)If a parent changes, Gameface must inspect the descendants. In a deep hierarchy, a change high in the tree can force Gameface to traverse and restyle a massive subtree.
Child.a > .bDirect (Shallow)Affects only the immediate children. Safer than descendant selectors but still requires a scan of the child list.

To understand performance “cliffs”, we must look at how Gameface decides which elements need updating. This process is called Invalidation.

When an element changes, Gameface identifies which other elements might be affected. To do this efficiently, selectors are split into two parts.

  • The Target: The rightmost part of the selector.
  • The Reason: All other parts of the selector (the conditions that must be met).

Gameface associates Reasons with Targets. For example, in the selector .sidebar-active .nav-link, the class .sidebar-active is the Reason and .nav-link is the Target.

If any element receives the .sidebar-active class, Gameface searches its entire subtree for .nav-link targets to rematch them. Gameface does not know if the CSS will actually change; it only knows there is a chance it will change.

Consider this selector: .aParent .anIntermediateClass > .aChild

<div class="aParent">
<div>
<div class="aChild"></div>
</div>
</div>

When aParent is added to the root, Gameface must inspect the .aChild element. Even though the selector won’t match (because .anIntermediateClass is missing), Gameface still spends time performing a style rematch because the Reason (.aParent) was triggered.

One common performance killer is using pseudo-selectors or combinators without a specific base.

  • Bad: .wrapper :first-child (This is internally treated as .wrapper *:first-child)
  • Impact: Every single child inside the wrapper’s entire subtree must be re-evaluated whenever .wrapper changes.
  • Bad: .theme-dark div
  • Impact: Changing the theme on the <body> forces Gameface to style-match every single div in your entire application.

To keep your application’s styling performant, follow these rules.

Optimization GoalRecommendation
Specific TargetsAvoid general selectors as the last part of a complex selector. Using a class that is not on many elements (a “Rare Class”) as the target prevents Gameface from checking every element in a subtree.
Bad: .C *
Better: .C .RareClass
Low-Frequency ReasonsAvoid having a class, ID, or attribute that changes very often as part of a complex selector. If a class toggles frequently (for example - a hover state), it should not be the Reason that triggers a descendant search.
Bad: .class:hover .child-element
Better: .class-child-element:hover
Limit Invalidation AreaAvoid changing attributes that are part of a complex selector on elements with large subtrees. This also applies to large next-sibling subforests for sibling selectors. Changing a class on a root element that has 10,000 descendants is more expensive than changing one on a leaf node.
Consolidate ReasonsAvoid using the same attribute as a Reason in overlapping complex selectors. If you have both .A .B and .A *, changing .A forces an invalidation of the entire subtree, rendering the specific scoping of the first selector useless.
Shallow TraversalPrefer the direct child combinator (>) over the descendant combinator ( ). This limits the depth of invalidation to immediate children rather than the entire recursive subtree.
Bad: .list .item (scans all depths)
Better: .list > .item (scans one level)
EncapsulationUse Shadow DOM to scope the effect of complex selectors. The effect of a selector like .A * will stop at shadow boundaries. Elements inside will not be rematched when class .A changes, though they may still update via inheritance.
Lower the cost of matching per elementAvoid selectors that combine descendant and sibling combinators. Having many such selectors (e.g. .A ~ .B .C) will slow down matching itself, instead of just causing more rematching, since every element with class .C will need to check all its parents for .B and for each such parent all its siblings for .A before it can be rejected.
  1. Check Scope: Look for “Style Matching” markers in your performance profiler.
  2. Isolate: Temporarily remove complex selectors. If performance improves significantly, you’ve found your culprit.
  3. Simplify: Replace the offending complex selectors by following the guidelines above.

Gameface creates a stylesheet for each <link/> and <style/> element in a page. Each stylesheet contains the parsed CSS rules used for style matching. Creating a stylesheet involves fetching the resource, parsing the text, and creating and storing the internal objects.

When using the same stylesheet in different views or shadow trees, Gameface tries to reuse the CSS, which saves memory and skips requesting the stylesheet or parsing it again.

However, there are the following limitations:

  • When sharing a common stylesheet between views or components, it needs to appear in the same position everywhere, for memory sharing to take place. Otherwise fetching and parsing will be skipped, but the internal objects will be duplicated. Example:

    <!-- The following 2 WILL share common.css -->
    <shadowRoot>
    <link rel="stylesheet" href="common.css">
    <link rel="stylesheet" href="specific1.css">
    </shadowRoot>
    <shadowRoot>
    <link rel="stylesheet" href="common.css">
    <link rel="stylesheet" href="specific1.css">
    </shadowRoot>
    <!-- The following 2 will FAIL to share common.css, parsing and fetching is still done once -->
    <shadowRoot>
    <link rel="stylesheet" href="common.css">
    <link rel="stylesheet" href="specific1.css">
    </shadowRoot>
    <shadowRoot>
    <link rel="stylesheet" href="specific1.css">
    <link rel="stylesheet" href="common.css">
    </shadowRoot>
  • Stylesheets modified by JS can not be shared. They are copied and the copy can not be reused anywhere.

  • To skip fetch and parse, the stylesheet needs to be currently active, so if a shadow tree or view with unique styles reloads it will have to fetch and parse the same stylesheet again. To avoid this you can use preloading and hold the stylesheets that will get added and removed often.

When there are elements that need to change only a single property we can avoid rematching by using the CSSOM API e.g. element.style.backgroundColor instead of setting the inline style directly or toggling classes.

// This avoids rematching element
element.style.transform = "translateX(30px)";
// This causes a rematch
element.style = "transform: translateX(30px)";

Note that properties requiring string values that change very often, such as the number in the transform example above, may create a lot of garbage due to string concatenation in JS. Consider using the CSSTOM API instead.