CSS Optimizations
Overview
Section titled “Overview”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?”- 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.
- Isolate: Temporarily remove complex selectors. If performance improves significantly, you’ve found your culprit.
If that is indeed the cause, here are our recommendations
Improving matching performance
Section titled “Improving matching performance”What is Style Matching?
Section titled “What is Style Matching?”Style matching is the process where Gameface determines which CSS rules apply to a specific element. It is essentially a search and filter operation.
- Filtering: Gameface iterates through all available CSS selectors to find those that match the element’s type, classes, IDs, and attributes.
- 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.” - 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.
Selectors and Their Impact
Section titled “Selectors and Their Impact”| Selector Type | Example | Invalidation Scope | Why it’s a Pitfall |
|---|---|---|---|
| Direct | .my-class | Local | Only affects the element itself. Predictable and fast. |
| Sibling | .a + .b, .a ~ .b | Linear (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 .b | Recursive (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 > .b | Direct (Shallow) | Affects only the immediate children. Safer than descendant selectors but still requires a scan of the child list. |
How Style Matching Works
Section titled “How Style Matching Works”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).
The “Chance of Change” Problem
Section titled “The “Chance of Change” Problem”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.
Example: The Unnecessary Search
Section titled “Example: The Unnecessary Search”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.
Performance Pitfall Examples
Section titled “Performance Pitfall Examples”1. The “Ghost” Universal Selector
Section titled “1. The “Ghost” Universal Selector”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
.wrapperchanges.
2. Broad Invalidation
Section titled “2. Broad Invalidation”- Bad:
.theme-dark div - Impact: Changing the theme on the
<body>forces Gameface to style-match every singledivin your entire application.
Optimization Guidelines
Section titled “Optimization Guidelines”To keep your application’s styling performant, follow these rules.
| Optimization Goal | Recommendation |
|---|---|
| Specific Targets | Avoid 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 Reasons | Avoid 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-elementBetter: .class-child-element:hover |
| Limit Invalidation Area | Avoid 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 Reasons | Avoid 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 Traversal | Prefer 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) |
| Encapsulation | Use 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 element | Avoid 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. |
Other CSS optimizations
Section titled “Other CSS optimizations”CSS sharing
Section titled “CSS sharing”- Check Scope: Look for “Style Matching” markers in your performance profiler.
- Isolate: Temporarily remove complex selectors. If performance improves significantly, you’ve found your culprit.
- 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.
Skipping matching for every frame changes
Section titled “Skipping matching for every frame changes”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.
© 2026 Coherent Labs. All rights reserved.