Benchmarking the performance of CSS @property

Published: Oct 2, 2024

When starting to use a new CSS feature it's important to understand its impact on the performance of your websites, whether positive or negative. With @property now in Baseline this post explores its performance impact, and things you can do to help prevent negative impact.

Benchmarking the performance of CSS with PerfTestRunner

To benchmark the performance of CSS we built the "CSS Selector Benchmark" test suite. It is powered by Chromium's PerfTestRunner and benchmarks the performance impact of CSS. This PerfTestRunner is what Blink–Chromium's underlying rendering engine–uses for its internal performance tests.

The runner includes a measureRunsPerSecond method which is used for the tests. The higher the number of runs per second, the better. A basic measureRunsPerSecond-benchmark with this library looks like this:

const testResults = PerfTestRunner.measureRunsPerSecond({
 
"Test Description",
 
iterationCount: 5,
 
bootstrap: function() {
   
// Code to execute before all iterations run
   
// For example, you can inject a style sheet here
 
},
 
setup: function() {
   
// Code to execute before a single iteration
 
},
 
run: function() {
   
// The actual test that gets run and measured.
   
// A typical test adjusts something on the page causing a style or layout invalidation
 
},
 
tearDown: function() {
   
// Code to execute after a single iteration has finished
   
// For example, undo DOM adjustments made within run()
 
},
 
done: function() {
   
// Code to be run after all iterations have finished.
   
// For example, remove the style sheets that were injected in the bootstrap phase
 
},
});

Each option for measureRunsPerSecond is described through comments in the code block, with the run function being the core part that gets measured.

CSS Selector benchmarks require a DOM tree

Because the performance of CSS selectors is also dependent on the size of the DOM, these benchmarks need a decently sized DOM tree. Instead of manually creating this DOM tree, this tree gets generated.

For example, the following makeTree function is part of the @property benchmarks. It constructs a tree of 1000 elements, each element with some children nested inside.

const $container = document.querySelector('#container');

function makeTree(parentEl, numSiblings) {
 
for (var i = 0; i <= numSiblings; i++) {
    $container
.appendChild(
      createElement
('div', {
        className
: `tagDiv wrap${i}`,
        innerHTML
: `<div class="tagDiv layer1" data-div="layer1">
         
<div class="tagDiv layer2">
           
<ul class="tagUl">
             
<li class="tagLi"><b class="tagB"><a href="/" class="tagA link" data-select="link">Select</a></b></li>
           
</ul>
          </
div>
       
</div>`,
     
})
   
);
 
}
}

makeTree
($container, 1000);

Because the CSS selector benchmarks don't modify the DOM tree, this tree generation gets executed only once, before any of the benchmarks run.

Running a benchmark

To run a benchmark that is part of the test suite you must first start a web server:

npm run start

Once started you can visit the benchmark at its published URL and execute window.startTest() manually.

To run these benchmarks in isolation—without any extensions or other factors intervening— Puppeteer is triggered from the CLI to load and execute the passed in benchmark.

For these @property benchmarks specifically rather than visiting the relevant page at its URL http://localhost:3000/benchmarks/at-rule/at-property.html invoke the following commands on the CLI:

npm run benchmark at-rule/at-property

This loads the page through Puppeteer, automatically calls window.startTest(), and reports back the results.

Benchmarking the performance of CSS properties

To benchmark the performance of a CSS property, you benchmark how fast it can handle a style invalidation and the subsequent recalculate style task the browser needs to do.

Style invalidation is the process of marking which elements need their style recalculated in response to a change in the DOM. The simplest possible approach is to invalidate everything in response to every change.

When doing so, there is a distinction to make between CSS properties that inherit and CSS properties that don't inherit.

  • When a CSS property that inherits changes on a targeted element, the styles of potentially all elements in the subtree underneath the targeted element also need to change.
  • When a CSS property that doesn't inherit changes on a targeted element, only the styles for that individual element get invalidated.

Because it wouldn't be fair to compare properties that do inherit against properties that don't, there are two sets of benchmarks to run:

  • A set of benchmarks with properties that inherit.
  • A set of benchmarks with properties that don't inherit.

It's important to carefully choose which properties to benchmark. While some properties (such as accent-color) only invalidate styles, there are many properties (such as writing-mode) that also invalidate other things such as layout or paint. You want the properties that only invalidate styles.

To determine this, look things up in Blink's list of CSS properties. Each property has a field invalidate that lists what gets invalidated.

Furthermore, it's also important to pick a property that is not marked as independent from that list, as benchmarking such a property would skew the results. Independent properties don't have any side-effects on other properties or flags. When only independent properties have changed, Blink uses a fast code-path that clones the style of the descendant and updates the new values in that cloned copy. This approach is faster than doing a full recalculation.

Benchmarking the performance of CSS properties that inherit

The first set of benchmarks focuses on CSS properties that inherit. There are three types of properties that inherit to test and compare against each other:

  • A regular property that inherits: accent-color.
  • An unregistered custom property: --unregistered.
  • A custom property that is registered with inherits: true: --registered.

Unregistered custom properties are added to this list because these inherit by default.

As mentioned before, the property that inherits was carefully chosen so that it's one that only invalidates styles and one that is not marked as independent.

As for registered custom properties, only those with the inherits descriptor set to true are tested in this run. The inherits descriptor determines whether the property inherits down onto children or not. It doesn't matter whether this property is registered through CSS @property or JavaScript CSS.registerProperty, as the registration itself is not part of the benchmark.

The benchmarks

As already mentioned, the page that contains the benchmarks starts off by constructing a DOM tree so that the page has a big enough set of nodes to see any impact of the changes.

Each benchmark changes the value of a property after which it triggers a style invalidation. The benchmark basically measures how long the next recalculation of the page takes to re-evaluate all those invalidated styles.

After a single benchmark is done, any injected styles get reset so that the next benchmark can begin.

For example, the benchmark measuring the performance of changing the style of --registered looks like this:

let i = 0;
PerfTestRunner.measureRunsPerSecond({
  description
,
  iterationCount
: 5,
  bootstrap
: () => {
    setCSS
(`@property --registered {
      syntax
: "<number>";
      initial
-value: 0;
      inherits
: true;
   
}`);
 
},
  setup
: function() {
   
// NO-OP
 
},
  run
: function() {
    document
.documentElement.style.setProperty('--registered', i);
    window
.getComputedStyle(document.documentElement).getPropertyValue('--registered'); // Force style recalculation
    i
= (i == 0) ? 1 : 0;
 
},
  teardown
: () => {
    document
.documentElement.style.removeProperty('--registered');
 
},
  done
: (results) => {
    resetCSS
();
    resolve
(results);
 
},
});

The benchmarks testing the other types of properties work in the same way but have an empty bootstrap because there is no property to register.

The results

Running these benchmarks with 20 iterations on a 2021 MacBook Pro (Apple M1 Pro) with 16GB of RAM gives the following averages:

  • Regular property that inherits (accent-color): 163 runs per second (= 6.13ms per run)
  • Unregistered custom property (--unregistered): 256 runs per second (= 3.90ms per run)
  • Registered custom property with inherits: true (--registered): 252 runs per second (= 3.96ms per run)

On multiple runs, the benchmarks yield similar results.

Bar chart with the results for properties that inherit. Higher numbers perform faster.
Figure: Bar chart with the results for properties that inherit. Higher numbers perform faster.

The results show that registering a custom property comes at a very tiny cost when compared to not registering the custom property. Registered custom properties that inherit run at 98% of the speed of unregistered custom properties. In absolute numbers, registering the custom property adds a 0.06ms overhead.

Benchmarking the performance of CSS properties that don't inherit

The next properties to benchmark are those that don't inherit. Here there are only two types of properties that can be benchmarked:

  • A regular property that doesn't inherit: z-index.
  • A registered custom property with inherits: false: --registered-no-inherit.

Custom properties that are not registered cannot be part of this benchmark because those properties always inherit.

The benchmarks

The benchmarks are very similar to the previous scenarios. For the test with --registered-no-inherit, the following property registration is injected in the bootstrap phase of the benchmark:

@property --registered-no-inherit {
 
syntax: "<number>";
 
initial-value: 0;
 
inherits: false;
}

The results

Running these benchmarks with 20 iterations on a 2021 MacBook Pro (Apple M1 Pro) with 16GB of RAM gives the following averages:

  • Regular property that doesn't inherit: 290,269 runs per second (= 3.44µs per run)
  • Registered Custom Property that doesn't inherit: 214,110 runs per second (= 4.67µs per run)

The test was repeated over multiple runs and these were the typical results.

Bar chart with the results for properties that don't inherit. Higher numbers perform faster.
Figure: Bar chart with the results for properties that don't inherit. Higher numbers perform faster.

The thing that stands out here is that properties that don't inherit perform much much faster than properties that do inherit. While this was to be expected for regular properties, this also holds true for custom properties.

  • For regular properties the number of runs went up from 163 runs per second to more than 290 thousand runs per second, a 1780% increase in performance!
  • For custom properties the number of runs went up from 252 runs per second to more than 214 thousand runs per second, a 848% increase in performance!

The key takeaway behind this is that using inherits: false when registering a custom property has a meaningful impact. If you can register your custom property with inherits: false, you definitely should.

Bonus benchmark: multiple custom property registrations

Another interesting thing to benchmark is the impact of having a lot of custom property registrations. To do so, rerun the test with --registered-no-inherit with it doing 25,000 other custom property registrations upfront. These custom properties are used on :root.

These registrations are done in the setup step of the benchmark:

setup: () => {
 
const propertyRegistrations = [];
 
const declarations = [];

 
for (let i = 0; i < 25000; i++) {
    propertyRegistrations
.push(`@property --custom-${i} { syntax: "<number>"; initial-value: 0; inherits: true; }`);
    declarations
.push(`--custom-${i}: ${Math.random()}`);
 
}

  setCSS
(`${propertyRegistrations.join("\n")}
 
:root {
    $
{declarations.join("\n")}
 
}`);
},

The runs per second for this benchmark is very similar to the result for "Registered Custom Property that does not inherit" (214,110 runs per second versus 213,158 runs per second), but that isn't the interesting part to look at. After all, it's to be expected that changing one custom property is not affected by registrations from other properties.

The interesting part of this test is to measure the impact of the registrations themselves. Turning to DevTools, you can see that 25,000 custom property registrations have an initial style recalculation cost of a little over 30ms. Once that's done, the presence of these registrations has no further effect on things.

DevTools screenshot with the 'Recalculate Style' cost for doing 25k custom property registrations highlighted. The tooltip indicates it took 32.42ms
Figure: DevTools screenshot with the "Recalculate Style" cost for doing 25k custom property registrations highlighted. The tooltip indicates it took 32.42ms

Conclusion and takeaways

In summary there are three takeaways from all this:

  • Registering a custom property with @property comes at a slight performance cost. This cost is often negligible because by registering custom properties you unlock their full potential which is not possible to achieve without doing that.

  • Using inherits: false when registering a custom property has a meaningful impact. With it you prevent the property from inheriting. When the property's value changes it therefore only affects the styles of the matched element instead of the entire subtree.

  • Having few versus lots of @property registrations does not impact style recalculation. There's only a very small upfront cost when doing the registrations but once that's done you're good.