Our app was mysteriously using 60% CPU and 25% GPU on my M2 MacBook. It turned out this was due to a tiny CSS animation! In this post, I show how to find expensive animations, why some are so expensive, and how to make many animations much cheaper. Along the way, we'll learn how the browser renders CSS animations and how to use Chrome's dev tools for performance profiling.
The problem
While building Granola, a note-taking app, I noticed it was using 60% CPU and 25% GPU on my M2 MacBook:
What is Granola spending those cycles on? It's an Electron app, so let's use the Chrome dev tools to find out!
First, in the "Performance" tab, we see that most of the time is spent not in JavaScript, but in "rendering" and "painting":
But what is it rendering and painting? To see this, open the "Layers" tab, which shows:
Our window has two layers: one for the "action bar" at the bottom, and another for the rest of the document content. Each layer has a "paint count", and the action bar's layer is painting every single frame!
Why is the action bar painting so much? For a clue, jump to the "Rendering" tab, and turn on "paint flashing" and "layout shift regions". We quickly see the browser hates our audio volume visualizer! Those three green "dancing bars" at the bottom of the Granola window constantly flash with layout shift and repainting.
But why are the dancing bars causing so much layout calculation? My first suspicion was DOM updates: the bars' styles update 10 times per second, and DOM updates are expensive, right? But reducing DOM updates made little difference.
Back to the Performance tab, and we see this pattern dominating the profile:
That stuff happens once for every frame, 60 times per second. No JavaScript here — this is pure CSS work!
Why is it doing work every frame? The answer is in the image. Look at those three purple "Animation" jobs. There's one animation job for each dancing bar. We've found our culprit, and it's this line of CSS:
transition: height 300ms ease-in-out;
Why is this height
transition so expensive?
What is the browser doing when we animate the height
property?
We first need to understand the browser's rendering pipeline.
There are three relevant steps:
Layout (deciding where elements go on the page), then
Painting (drawing those elements onto layers like in Photoshop), and finally
Compositing (merging those layers into a single image).
Here's the browser rendering one frame of Granola:
What happens when we change the height
property?
It triggers a layout recalculation, followed by re-painting, and re-compositing.
This makes height
a layout property,
and these are the most expensive CSS properties to animate.
Less expensive are paint properties.
A paint property does trigger layout,
but it does repaint a layer, and then re-composites.
For example, the fill
and stroke
colors on an SVG are paint properties:
they repaint the layer with new colors, then re-composite them.
A funny example is this tiny "bikeshed" SVG
which you can find on lots of W3C spec pages.
It costs ~30% CPU!
The cheapest properties are composite properties.
They don't trigger layout or paint;
they only trigger a compositing update.
The two classic composite properties are transform
and opacity
.
Can we replace our expensive height
animations with cheaper transform
animations?
A solution
A naive solution would be to replace height
animations with transform: scaleY()
.
And hey presto, this fixes our performance problem!
The "Layout" and "Paint" jobs are nowhere to be seen in the Performance tab.
... Unfortunately, this scaleY
transform is ugly.
Look how those rounded corners get warped as the rectangle stretches!
Instead,
we can create the illusion of a changing height by using two rectangles, applying translate
to each.
There's one rectangle for each end:
The bars are actually pairs of rounded rectangles that move in opposite directions,
creating the illusion of a single stretching bar.
Since we're only using transform
,
the browser can skip the layout and paint phases.
Here's the optimized version of the visualizer:
On my M2 MacBook,
the renderer process is now using 6% CPU (down from 15%),
and the GPU process is now using 6% CPU and less than 1% GPU (down from 25% and 20%).
This optimization demonstrates
how understanding the browser's rendering pipeline
and choosing the right CSS properties for animations
can dramatically improve performance.
In the next post,
I'll show how to find hidden performance costs using Chrome's about://tracing
tool,
which is like the performance profiler on steroids.
See you there!
You can see the optimized visualizer in action by trying Granola. If you're interested in working on performance optimizations like these, we're hiring!
Jim Fisher, Founding Engineer