My site has an aurora background. Four soft coloured bands, drifting, behind everything, on every content route. It is fixed inset-0, full viewport, and it sets willChange: 'transform'. I have always half-known it wasn't free.
This week I went looking for the number, and the number turned out to be the easy part. The hard part was learning that most of my measurements were lying to me, and building the one thing that could tell me which ones.
You cannot see this cost in DevTools
Start with why the number was unknown in the first place.
The Performance panel is where you go when something feels slow, and it is structurally blind to this class of cost. Compositor-driven animations, transforms and opacity on a promoted layer, never touch the JS thread. You can record a ten-second profile of a page burning a third of your GPU process and get back a flat, idle, healthy-looking timeline.
The cost is visible in exactly one place: Chrome's task manager, the GPU process row. That's a native UI. You can look at it, but you cannot diff it, log it, or put it in a table.
So the first thing to build wasn't a fix. It was an instrument:
- Sampler, over CDP,
SystemInfo.getProcessInfo, two samples N seconds apart, diffcpuTimeon the GPU process. That's the task manager row, programmatically. - Toggle, inject a CSS override into the live page that disables one feature class, re-sample, restore.
Then run it: baseline, toggle, baseline, toggle. Simple.
The first version produced beautiful, impossible numbers
I sampled one baseline at the start of the run and compared every toggle against it. The results came back gorgeous. Six different toggles each "saved" about twenty-five percent.
Six independent optimizations, each worth a quarter of the GPU process, on a page that only has one. That doesn't add up, literally. The overlap made it arithmetically impossible, which is the only reason I caught it.
What was actually happening: the browser window was losing visibility partway through the run. An occluded tab reads near-zero GPU no matter what its CSS says. Every toggle after the moment of occlusion looked like a spectacular fix, and the shape of the data, "everything helps a lot", is exactly what a naive optimizer wants to see.
I threw the whole file out. Two changes made the next version trustworthy:
1. Paired sampling. Re-measure the baseline immediately before every single toggle. The baseline drifts, occlusion, thermal throttling, a background tab waking up, so only within-pair deltas carry information. A baseline from ninety seconds ago is a different machine.
2. A control toggle at both ends of the run. The control injects a CSS rule that changes nothing. --nothing: 1. It must measure approximately zero. When it doesn't, the run is noise, and no number from that run is reportable, not the bad ones, not the good ones, not the one that confirms what you expected.
I also had to assert that the page was compositing at all before each sample: count rAF callbacks over a short window, with a timeout escape. A backgrounded tab reads 0% GPU regardless of your CSS, and 0% looks identical to a triumph.
What the controls caught
Three of eight measurement attempts that session self-invalidated on their control rows.
Not three that produced weird outliers I would have noticed. Three that produced confident, plausible, internally consistent, wrong numbers, noise floors of 6.5% and 10.3% on a toggle that changes nothing, baselines climbing to 42.7% as the laptop heated up. Every one of them would have gone into a table, and the table would have looked exactly as convincing as the real one.
That's the whole essay, really. The controls are the load-bearing part of the harness. The toggles are the part that looks like the point.
A toggle tells you what a change is worth. A control tells you whether the toggle is telling you anything at all. Only one of those two can fail silently, and it's the one people skip because it measures nothing by construction.
The results, and the fix I didn't ship
With paired sampling and controls in place, three valid runs against my own site:
| Toggle | Deltas across 3 runs | Read |
|---|---|---|
Hide fixed inset-0 layers | +32.8, +23.1, +31.6 | Dominant. The full-viewport stack is ~23–33% of the GPU process on its own. |
backdrop-filter: none | +6.3, +8.3, +5.1 | Real, modest, consistently over threshold. |
| Disable animations | +1.3, +8.6, +5.0 | Did not replicate. |
| Disable the rAF canvas | −4.2, +5.1 | Rejected. |
Baseline was 31–34% of the GPU process, sustained, on a page with no user input.
Now look at row three. The received wisdom, the reason I started this investigation, is that persistent infinite animations are the cost. That hypothesis was on the table, it was plausible, it had a mechanism, and it did not reproduce. Neither did the canvas theory, which I'd have bet on second.
What replicated is that the existence of the full-viewport fixed layers costs 23–33%, roughly independent of which sub-property you toggle off.
If I had applied the popular fix, strip the pulse animations, ship it, write a confident changelog entry, I would have removed the thing that measured near zero and kept about thirty percent of the actual cost. A plausible, well-argued, near-worthless change. I'd have believed it worked, too, because there'd have been nothing to contradict me.
Then the same discipline ate my own fix
Armed with the real culprit, I did the obvious thing: collapsed the static members of the layer stack. Two base layers merged into one element with stacked background-images, another pair folded into their parent. Composited full-viewport layers went from 11 to 7. The four animated bands I left alone, their keyframes animate transform and opacity, so willChange: 'transform' there is correct usage, not the misuse it looks like from the outside.
Before claiming it was safe, I diffed screenshots. The first comparison showed 12.4% of subpixels differing, which briefly looked like I'd broken the design. I hadn't. I'd taken the comparison against a live page, so I was measuring animation phase, not my code change. Froze animations and transitions, hid the randomly-seeded starfield, re-shot: max per-channel delta 0/255, zero differing subpixels, empty diff bounding box. Provably identical.
And the performance result? One valid post-change run: 27.5%, against a pre-change mean of 32.8%, below the entire pre-change range. Encouraging.
Two follow-up runs died on their control rows. Thermal load, not CSS.
So: n = 1. One run is not a finding, and by my own threshold I don't get to call this a win. Hiding the fixed layers still measures +21 to +30% after the change, essentially unchanged, which says plainly that collapsing the static members didn't touch the term that matters.
I kept the merge anyway, and I want to be precise about why: because it is free and provably pixel-identical, not because the number is proven. Those are different reasons, and only one of them would justify keeping it if it had cost me anything.
The largest remaining identified term is the motion on the four bands: +14.3% and +9.8% across the two valid runs that carried it. That one I'm not taking unilaterally, because stopping or slowing those bands changes how the site looks, and a visual decision with a number attached is a decision, not an optimization.
What I'd carry to any perf work
- Build the instrument before you build the fix. A structural finding, "this is
fixed inset-0with four infinite animations", is a hypothesis, not a measurement. It reads like evidence because it's specific. - Pair every sample with a fresh baseline. Anything else measures your laptop's afternoon.
- Put a no-op control at both ends of every run, and throw out the entire run when it fails. Including the runs you liked.
- Assert the thing is actually running before you measure it. Zero is not always good news.
- Freeze the moving parts before any visual diff, or you'll measure the animation instead of the change.
- Log the per-layer before/after delta, not just the fix. A change with no recorded delta is indistinguishable from a placebo, and the log is the only artifact that survives to the next audit.
The uncomfortable part is point 3, because it means deleting good-looking data on the strength of a row that, by design, says nothing. That row is the only thing standing between a measurement and a story.