20 Performance 20 Performance 20 Performance 20 Performance
Smooth motion is a deadline met on every frame. Move what the browser can move without redrawing it.
Chapter 19 ended on a warning: each medium is a different amount of work per frame, and a frame that costs too much is dropped. This chapter opens that up. A screen refreshes on a steady beat. Before each beat, the browser has to have the next picture ready: every element in its place, every pixel painted. When the picture is ready in time, you see motion. When it isn’t, the screen shows the old picture again, and the motion hitches.
How much work a picture takes depends mostly on what you animate. Move a box by its left and the browser works out the layout again and repaints, on every frame. Move it by transform and the browser can slide a picture it has already painted. The motion looks the same. The work behind it doesn’t.
Four steps to a frame
To make a frame, a browser runs up to four steps, in order. Pick a property. The box animates it, and the row shows which steps the browser has to run again, on every frame, to draw it. Try left, then transform, and compare how many steps light up.
- 1 Style runs every frame
- 2 Layout runs every frame
- 3 Paint runs every frame
- 4 Composite runs every frame
Moving an element by its position changes the geometry of the page: the browser lays out again, repaints, then composites. Every frame.
- Style. Match the CSS rules to each element and work out every property’s final value.
- Layout. Work out where each box goes and how big it is. A box that grows pushes its neighbours, so layout often reaches far past the element that changed.
- Paint. Draw the pixels: backgrounds, text, borders, shadows. The results are kept in layers, like the cels an animator stacks over a background.
- Composite. Stack the layers in order, each at its own position and opacity, to make the frame. This step usually runs on the GPU, in the part of the browser called the compositor.
The rule is that a change runs from the first step it affects through to the last. left and width change geometry, so they cost layout, paint and composite, every frame. A colour or a shadow changes pixels but not geometry: paint and composite. transform and opacity change neither. The layer is already painted; it only has to be moved or faded.
Which one is smooth?
The same dot makes the same trip twice. Which trip is smooth?
The budget
A 60Hz screen refreshes 60 times a second, and 1000ms divided by 60 is 16.7ms. That is the budget for one frame: your JavaScript, then style, layout, paint and composite, all of it, before the next refresh. The browser needs part of that budget for its own work, so your share is smaller than the whole.
| Display | A new frame every |
|---|---|
| 60Hz | 16.7ms |
| 90Hz | 11.1ms |
| 120Hz | 8.3ms |
| 144Hz | 6.9ms |
Many phones, tablets and laptops now refresh at 120Hz. A browser on one of them can draw 120 frames a second, and then each frame gets half the time. Work that fit easily at 60Hz can miss at 120Hz. So don’t assume a rate. It depends on the screen, and a browser may draw less often than its screen refreshes, or not at all in a background tab.
When a frame misses, it doesn’t arrive a little late. The screen shows the old picture again, and the new one waits for a later refresh. A time-based animation then jumps to where it should be by now. That’s A in the FEEL above: 5 of its 42 frames were never drawn.
This is the figure from chapter 19, now that you know what each step costs. Start on left/top and raise the dots until the motion stutters, then note the count and the frame time. Repeat in the other two modes. The readout is measured on your machine, as it runs.
left/top usually gives up first: every dot’s new position goes through layout and paint, every frame. transform skips layout, so it lasts longer. Canvas lasts longest. It’s one element with nothing to lay out, and your code draws every dot into a single bitmap. If nothing stutters, your machine is quick: in Chrome’s DevTools, the Performance panel can slow the CPU down 4× or 6×. Try again with that on.
In all three modes, JavaScript sets every dot’s position on every frame, so even transform costs main-thread time here. It gets cheaper still when the browser runs the animation itself.
Hand it to the compositor
The main thread does nearly everything: your scripts, event handlers, style, layout, paint. While it’s busy with one of them, the others wait, and that includes the next frame.
The compositor works on a thread of its own. It is the part of the browser that runs the Composite step: it takes layers that are already painted and puts them together. Hand it a transform or opacity animation and it can run the whole thing alone: on each frame it moves or fades the layer, without asking the main thread for anything. If a script then blocks the main thread, the animation keeps going.
There are two conditions. The property: transform and opacity are the pair every engine can hand over, and the individual translate, rotate and scale properties normally go the same way. Who drives it: a CSS transition, a CSS animation, or element.animate(). A requestAnimationFrame loop that sets transform itself runs on the main thread, every frame. It skips layout, but when the main thread stalls, it stalls too.
So, as a habit:
- Move with
translate(), notleftortop. - Resize with
scale()where you can, notwidthorheight. When the layout really does change, FLIP from chapter 10 turns the change into atransform. - Fade with
opacity. - For a deep shadow, paint it once on a pseudo-element and fade that element’s
opacity, instead of animatingbox-shadow.
Layers, and will-change
A layer isn’t free. It’s a bitmap held in memory, about four bytes for every device pixel it covers. A layer the size of a phone screen (390 by 844 CSS pixels, so 1170 by 2532 device pixels on a 3× display) comes to roughly twelve megabytes. The browser makes layers when it needs them and drops them when it’s done.
will-change: transform asks for one early. It tells the browser that the element’s transform is about to change, so it can get ready, usually by painting the element into its own layer before the move starts rather than on its first frame. Browsers already do this for a running transform or opacity animation. will-change is for being ready a moment sooner, and for elements whose transform JavaScript sets on every frame, like a card dragged under the pointer.
Two rules follow.
- Ask late, let go. Set it just before the move, on hover or pointerdown, and remove it when the move ends.
- Ask for little.
* { will-change: transform }puts everything on a layer. Too many layers cost memory and compositing time, and can make a page slower, not faster.
It also has side effects, as a transform does. The element starts a new stacking context (its z-index layering is now sealed off from its neighbours), and its position: fixed children are placed against it instead of the viewport. Treat will-change as the fix for a problem you’ve measured, not a default.
Measuring jank
Don’t guess which frames miss. Record them. Open DevTools (F12, or Ctrl+Shift+I; Cmd+Option+I on a Mac), then:
- Performance panel. Click the Performance tab. Click the gear icon (or the panel’s settings) and set CPU to 4× slowdown, so a fast laptop behaves like a slower device. Click the round Record button, do the thing that stutters for a few seconds, then click Stop. Look at the Frames track near the top: each frame is a block, and dropped or partly presented frames are flagged. Click one, then read the Main track below it. It shows what the main thread did, colour by colour: yellow is your JavaScript, purple is style and layout, green is paint. A frame that missed will have a block that runs longer than the 16.7ms slot, often with a red triangle on its corner (a long task). Click that block to see which function or step it was. Panel layouts change between Chrome versions, so if a name differs, look for the same idea.
- Rendering tab. Press Ctrl+Shift+P (Cmd+Shift+P on a Mac) to open the Command Menu, type Show Rendering and press Enter. Tick Paint flashing, then trigger your animation: each area that repaints flashes green. A box moved with
leftflashes on every frame, while one moved by atransformanimation flashes once, as its layer is painted, then stays clear. Tick Layer borders to outline every layer, and Frame Rendering Stats for a live frame-rate meter in a corner of the page. - Animations panel. Open the three-dot menu, choose More tools, then Animations. Trigger the motion and it appears as a bar. Click the bar and set the speed to 25% or 10% to replay it slowly, with its timing laid out, so you can check it is the motion you wrote.
What you are looking for, in short: frames longer than the budget in the Frames track, the block in the Main track that made them long, and whether paint flashing lights up an element that should have been compositor-only.
Then test on a real, inexpensive phone. A fast laptop hides problems that a cheap phone shows at once. For motion you ship, the monitor in The notation below does the same job from inside the page: it times every frame and reports the ones that came late.
Spend the budget
Each frame, this program works for WORK milliseconds, then draws. Each bar is the time from one frame to the next, newest on the right. The blue lines are the budgets at 60Hz and 120Hz. Leave Refresh rate on Display, so the bars are your screen’s real frames.
- Drag
WORKup, slowly. For a while the bars stay at your screen’s beat. Then they climb past the line: every frame is late, and every picture stays up for more than one refresh. - Note where that starts. It’s before
WORKreaches the line, because a frame holds more than your work: the browser needs part of the budget for itself. - Watch the red dot once the bars are past the line. It moves by
dt, so it stays on time, but it gets fewer pictures to do it in. On time, and still janky.
The notation
Three habits. Move with transform, not left. Ask for a layer just before a move, and give it back after. And measure frames instead of guessing.
/* Not this: left is geometry, so every frame
re-runs layout and paint.
.box { transition: left 600ms cubic-bezier(.65, 0, .35, 1); }
.is-on .box { left: 320px; }
*/
/* This: the same move as a transform. The box is
painted once; the compositor can slide it. */
.box {
transition: transform 600ms cubic-bezier(.65, 0, .35, 1);
}
.is-on .box {
transform: translateX(320px);
} // A moment ahead (on hover or pointerdown, say):
// ask for a layer, so it's ready for the first frame.
function prepare(el) {
el.style.willChange = 'transform';
}
// The move. Set the end state for real, then animate
// from the start, so nothing is left filling after.
function slide(el, x) {
el.style.transform = `translateX(${x}px)`;
const move = el.animate(
[{ transform: 'translateX(0)' }, { transform: `translateX(${x}px)` }],
{ duration: 600, easing: 'cubic-bezier(.65, 0, .35, 1)' },
);
// Landed, or cancelled: give the layer back.
const release = () => { el.style.willChange = 'auto'; };
move.finished.then(release, release);
return move;
}
// In an app: prepare on pointerdown, slide on click.
const box = document.querySelector('.box');
prepare(box);
slide(box, 320); // Warn about frames that came much later than
// the display's beat: a refresh was missed.
// threshold: how many beats a frame may take before it counts as late
// (1.5, so ordinary jitter doesn't trigger it).
// warmup: how many frames to time first, to learn the display's beat.
function watchFrames({ threshold = 1.5, warmup = 60 } = {}) {
const first = [];
let expected = 0; // ms per refresh, measured below
let last;
let id;
function frame(now) {
if (last !== undefined) {
const interval = now - last;
if (!expected) {
first.push(interval);
if (first.length === warmup) {
first.sort((a, b) => a - b);
expected = first[warmup >> 1]; // the median: one slow frame can't skew it
}
} else if (interval > expected * threshold) {
// A 50ms frame on a 16.7ms beat took three beats: two were missed.
const missed = Math.round(interval / expected) - 1;
console.warn(`long frame: ${interval.toFixed(1)}ms, ~${missed} missed`);
}
}
last = now;
id = requestAnimationFrame(frame);
}
// rAF pauses in background tabs: don't count the gap.
const reset = () => { last = undefined; };
document.addEventListener('visibilitychange', reset);
id = requestAnimationFrame(frame);
return function stop() {
cancelAnimationFrame(id);
document.removeEventListener('visibilitychange', reset);
};
}
const stop = watchFrames(); // later: stop() The monitor learns the display’s beat from its first frames instead of assuming 60Hz, so it’s right at 120Hz too. It watches the main thread only. A compositor animation can stay smooth while the monitor logs long frames; that’s the compositor doing its job. In the Lab, the monitor runs beside a steady move with one long task in the middle, and its warning appears at the top of the stage.
A page that hits every frame is only half of care. The other half is asking who wants the motion at all, which is chapter 21.
Blind A/B
A mixed round. Two versions of one interaction: pick the better one, then name why in one word. Some pairs differ in their curve or their timing. Some differ only in whether every frame was drawn.
Two versions. Which is better? Then say why, in one word.Taste: judging, then naming the reason.
5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.