Measuring the Paint Cost of Custom Property Updates

Part of Theme Transition Motion & Performance. This guide turns “the theme switch feels slow” into a number a pull request can be gated on, without producing a flaky test that everyone learns to re-run.

Signals available for measuring a theme swap, ordered by stability Three measurement signals compared: wall-clock duration is fast but noisy, counter deltas such as layout count are stable and binary, and trace event durations are precise but sensitive to runner load. Wall-clock duration performance.now() around the attribute write varies 5x on shared CI measures the write, not the work it schedules Counter deltas LayoutCount, RecalcStyleCount from the Performance domain deterministic, not timing answers "did layout run", which is the real question Trace event durations UpdateLayoutTree, Paint from a recorded trace precise but load-sensitive use for investigation, gate on it only with wide margins
Gate on the counters and investigate with the trace. Timing thresholds on a shared runner produce flaky failures that erode trust in the whole suite.

Prerequisites Link to this section

  • A theme toggle reachable by an accessible name, so the test can click it the way a visitor would.
  • Playwright with a Chromium project — the Chrome DevTools Protocol session used below is Chromium-specific.
  • A build served over HTTP in the test, not from the filesystem; custom property resolution is identical either way, but asset loading is not.
  • Optional but recommended: CPU throttling configured, so the numbers reflect something closer to real hardware.

Step-by-Step Implementation Link to this section

Step 1 — Sample counters, not clocks Link to this section

Intent: ask a binary question with a deterministic answer — did the swap cause layout — rather than a timing question whose answer depends on what else the runner is doing.

// tests/helpers/metrics.mjs
export async function readCounters(client) {
  const { metrics } = await client.send('Performance.getMetrics');
  const get = (name) => metrics.find((m) => m.name === name)?.value ?? 0;
  return {
    layout: get('LayoutCount'),
    recalcStyle: get('RecalcStyleCount'),
    layoutDuration: get('LayoutDuration'),
    recalcDuration: get('RecalcStyleDuration'),
  };
}

Why this works: LayoutCount and RecalcStyleCount are monotonic counters maintained by the renderer. The delta across an interaction is the number of times each stage ran, which is a property of the CSS rather than of the machine. Two runs on wildly different hardware produce the same delta; that is exactly what a gate needs.

Step 2 — Quiet the page before measuring Link to this section

Intent: remove the sources of background work that would otherwise be attributed to the theme swap.

export async function settle(page) {
  await page.evaluate(() => document.fonts.ready);       // font swaps cause layout
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r())));
  await page.waitForLoadState('networkidle');
  await page.evaluate(() => window.scrollTo(0, 0));      // scroll can trigger paint
  await page.waitForTimeout(200);                        // let any entry animation finish
}

Why this works: the four sources of noise in a freshly loaded page are font loading, lazy images, entry animations and scroll-triggered work. Each produces layout or paint events that arrive during the measurement window and are indistinguishable from the ones the theme swap caused. Waiting for document.fonts.ready alone removes the largest of them.

Step 3 — Measure the interaction, not the click Link to this section

Intent: the attribute write returns immediately; the work it schedules happens on the next frame. The measurement window has to span both.

import { test, expect } from '@playwright/test';
import { readCounters, settle } from './helpers/metrics.mjs';

test('theme swap stays out of layout', async ({ page }) => {
  await page.goto('/');
  await settle(page);

  const client = await page.context().newCDPSession(page);
  await client.send('Performance.enable');

  const before = await readCounters(client);

  await page.getByRole('button', { name: /dark theme/i }).click();
  await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');

  // Wait for two frames so the scheduled style and paint work has completed.
  await page.evaluate(() => new Promise((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(resolve));
  }));

  const after = await readCounters(client);

  expect(after.layout - before.layout).toBeLessThanOrEqual(1);
  expect(after.recalcStyle - before.recalcStyle).toBeGreaterThan(0);  // it did do work
});

Why this works: the double requestAnimationFrame is the standard way to wait until after the frame in which the change was applied. Asserting that the style counter did increase is as important as asserting the layout counter did not — without it, a test that accidentally clicks nothing at all would pass.

Measurement window across the frame boundary A timeline showing the click, the synchronous attribute write, the frame boundary, and the style and paint work that follows, with the measurement window spanning from before the click to after the second frame. click attribute write frame boundary style recalc paint commit measurement window — counters sampled here and here Sampling immediately after the click measures nothing — the work has not happened yet
The attribute write is synchronous and instant; everything expensive happens after the next frame boundary. Sampling too early is the most common way this measurement returns a misleading zero.

Step 4 — Throttle the CPU so the numbers mean something Link to this section

Intent: CI runners are typically faster than the devices that matter. Throttling makes a regression visible before it reaches a visitor.

const client = await page.context().newCDPSession(page);
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });

Why this works: a 4x slowdown approximates a mid-range mobile device relative to a typical CI runner. It does not change the counter deltas at all — those are structural — but it does change the duration metrics, which is what makes a duration threshold meaningful rather than aspirational. Set it before the settle step so the page loads under the same conditions it is measured under.

Step 5 — Add a duration budget with a wide margin Link to this section

Intent: counters catch the structural failure; a generous duration budget catches the gradual ones, such as a stylesheet that has doubled in size.

const styleMs = after.recalcDuration - before.recalcDuration;

// Budget is deliberately loose: this catches a 3x regression, not a 10% one.
expect(styleMs).toBeLessThan(0.05);   // seconds, i.e. 50ms under 4x throttling

Why this works: a tight threshold on a shared runner fails randomly, and a randomly failing test is removed within a month. A loose one still catches the failures worth catching — a selector change that makes style recalculation quadratic, a theme block that grew from 40 to 400 declarations — while surviving a noisy neighbour on the build machine. Record the observed value in the test output so the trend is visible even when the assertion passes.

Step 6 — Record the numbers over time Link to this section

Intent: the single most useful output of this measurement is the trend, not the pass or fail.

// After the assertions, emit a machine-readable line for the CI log collector.
console.log(JSON.stringify({
  metric: 'theme-swap',
  layoutDelta: after.layout - before.layout,
  recalcDelta: after.recalcStyle - before.recalcStyle,
  recalcMs: Math.round(styleMs * 1000),
  commit: process.env.GITHUB_SHA ?? 'local',
}));

Why this works: a pass/fail gate tells you when something broke; a recorded series tells you when something started drifting. In practice the second is what prompts the investigation that prevents the first, and it costs one line.

Verification Link to this section

The measurement itself needs verifying, because a test that always passes is worse than no test. Prove it can fail by deliberately breaking the thing it checks.

Add a spacing token to the dark theme block in a scratch branch and run the test. The layout delta should jump from zero or one into double digits on any page with a reasonable number of elements, and the assertion should fail. If it does not, the measurement window is wrong — usually the counters are being sampled before the frame boundary, as in the figure above.

A second sanity check: run the test against a page with almost no content. The recalc delta should still be greater than zero, confirming that the toggle is genuinely being exercised, while the durations drop close to the noise floor. A test that passes on an empty page and fails on a real one is measuring the page, not the theme.

Interpreting the two counter deltas together A two-by-two matrix of style recalculation delta against layout delta, labelling the healthy case, the misconfigured test case, the layout-thrash case and the no-op case. Reading the pair of deltas style > 0, layout ≤ 1 healthy — the swap repainted only this is the state the gate protects style > 0, layout > 1 layout thrash — a geometry token is in the theme block style = 0, layout = 0 nothing happened — the click missed, or the sample was taken too early style = 0, layout > 0 unrelated work — fonts or images landed inside the window; settle first
Both counters have to be read together. Either one alone has a failure mode that looks like success.

What the Numbers Do Not Capture Link to this section

A green assertion means the swap did not cause layout and the style recalculation stayed inside budget. It does not mean the theme change felt good, and it is worth being clear about the gap.

Perceived smoothness depends on when the change lands relative to the visitor’s input, not only on how long the work takes. A swap that completes in eight milliseconds but starts eighty milliseconds after the click — because the handler was queued behind other work on the main thread — reads as sluggish despite a perfect measurement. Input delay is a separate metric and worth tracking separately if the toggle sits in a busy application.

The measurement is also blind to compositing cost. Style and layout counters say nothing about how many layers the compositor had to reraster, which is where a page with many large images or backdrop filters spends its time during a theme change. If the counters look healthy and the interaction still feels heavy, the trace’s compositing track is the next place to look.

Finally, these counters describe one interaction on one page. A theme switch on a dense data table behaves differently from one on a marketing page, and measuring only the latter produces a comfortable number and no protection. Pick the two or three heaviest routes in the product and gate those; the light pages will never be the problem.

Troubleshooting Link to this section

Symptom Likely cause Fix
Both deltas are zero Counters sampled before the frame boundary Wait two animation frames between the click and the second sample
Layout delta is high but the theme is correctly partitioned Fonts or lazy images loaded inside the window Add the settle step; wait for document.fonts.ready and network idle first
Durations vary by an order of magnitude between runs No CPU throttling and a shared runner Set a fixed throttling rate; gate on counters and treat durations as advisory
The test passes on CI and fails locally A browser extension or devtools open in the local browser Run the test headless, or in a clean profile
Performance.getMetrics returns an empty array The Performance domain was never enabled on the session Send Performance.enable before the first sample

Migration Note Link to this section

If a project already has a Lighthouse budget in CI, this measurement complements rather than replaces it. Lighthouse measures page load; this measures one interaction, and a theme switch is invisible to a load-time audit because it never happens during the audit.

The cheapest way to introduce this is as a non-blocking job first. Run it on every pull request, record the JSON line, and leave the assertions in place but tolerant. After two or three weeks the recorded series shows the real variance on your runners, and the thresholds can be set from data rather than from a guess. Setting them from a guess is how these tests acquire their reputation for flakiness.