The compositor and the frame budget: which properties are free
transform and opacity animate on the compositor thread and survive a blocked main thread; width or top re-run layout and paint per frame. Budget: ~10 ms of your code at 60 Hz. setState per frame burns it on render plus reconcile — use rAF + ref or CSS. will-change costs memory.
The release demo ran on a MacBook Pro and looked perfect. On the support team’s mid-range laptops, the new slide-out order panel crawled: the open animation stuttered at 9 fps whenever the price chart was re-rendering — which was always. The panel was animated “the React way”: a requestAnimationFrame loop calling setState with the next left offset, sixty renders a second, each paying render, reconcile, commit, and a layout-property write. The chart’s own reconcile took 40–70 ms several times a second, and every one of those swallowed four animation frames whole. The first fix attempt was folklore: will-change: transform sprinkled across the panel, the chart, and every row. Tab memory grew by 180 MB and scrolling got worse on the cheapest machines, because every promoted element became its own composited bitmap that had to be rasterized, uploaded, and held. The real fix was twenty lines: the panel moved via a CSS transition on transform, React flipped a single class, and the animation ran on the compositor thread — perfectly smooth while the main thread spent 70 ms in reconcile, because nothing about the animation needed the main thread anymore. The rendering pipeline has a fast exit. This lesson is about aiming for it.
Five stages, three exits
Every visual change walks some prefix of one pipeline, once per output frame: JS (event handlers, rAF callbacks, React render and commit) → style (recompute which rules apply and the computed values) → layout (geometry: where every box sits and how big it is — one width change cascades through siblings, ancestors, and line boxes) → paint (rasterize boxes into layer bitmaps: text, colors, shadows, borders) → composite (position the already-rasterized layers against each other and present the frame).
The expensive truth: properties differ not in how “heavy” they are but in where they enter the pipeline. Change width, top, margin, font-size — you dirty layout, and everything below re-runs: layout, paint, composite, on every frame of your animation. Change color, background, box-shadow — layout survives, but the element repaints. Change transform or opacity on an element with its own composited layer — nothing recomputes except the final placement of an existing bitmap: no layout, no paint. The compositor slides a texture. That is the entire reason “animate transform and opacity” is a rule and not a taste: they are the two commonly animated properties whose updates can skip the main thread entirely (plus filter in modern engines, with caveats).
The compositor thread is your only off-main-thread ally
Composite is not just the last stage — it lives on a different thread. The compositor thread holds the rasterized layers and can re-place them on screen without consulting the main thread. Declarative animations — CSS transitions, CSS animations, and Web Animations API calls — on transform and opacity are handed to it: after the handoff, the main thread can block for hundreds of milliseconds in reconcile or GC and the animation does not drop a frame, because its frame production happens entirely off the main thread. That is the Hook’s ending: the CSS-transition panel sailed through a 70 ms reconcile that had previously eaten four frames whole.
The flip side: any animation driven by JavaScript per frame — a rAF loop, a spring library mutating styles, React state — is main-thread by construction. It can still hit 60 fps, but it competes with everything else on that thread for every single frame and loses by default whenever React commits something big. And a CSS animation of a layout property is also main-thread work: the declarative form does not save you; the property class does.
A CSS transition on transform keeps gliding while the main thread spends 400 ms in a React reconcile, but the same transition rewritten to animate left freezes for those 400 ms. Why?
Frame budget arithmetic
Numbers matter here because a single missed deadline drops a visible frame — not a slow one, a missing one. At 60 Hz a frame is 16.7 ms. At 120 Hz — increasingly the default on phones and laptops you do not test on — it is 8.3 ms. The budget is not all yours: the browser spends part of every frame on input processing, style recalculation, layout, paint, and the commit to the compositor. The residue for your JS is roughly 10 ms at 60 Hz and 3–5 ms at 120 Hz. Numbers worth carrying: style recalc on a few thousand nodes runs 1–4 ms; layout on a complex page 4–12 ms; a medium React commit 2–8 ms. Each is affordable once per interaction. None is affordable per frame at 120 Hz. Miss the deadline and the frame is not late — it is dropped: the previous frame stays on screen for an extra 16.7 (or 8.3) ms, and in continuous motion the eye reliably reads a single dropped frame as a hitch.
The React-specific sin: a render per frame
// ❌ a render per frame: rAF → setState → render → reconcile → commit, 60×/s
useEffect(() => {
let raf;
const tick = () => {
setX((x) => x + dx); // schedules a full React render each frame
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
// ✅ React renders once; frames mutate the ref — no reconcile in the hot loop
useEffect(() => {
let raf, x = 0;
const tick = () => {
x += dx;
el.current.style.transform = `translateX(${x}px)`;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);The first loop pays React’s full machinery per frame: render the component, reconcile the subtree, commit — then style, layout, and paint if the written property demands them. A 3 ms commit is a third of the 8.3 ms budget before the browser does any rendering work. The second loop costs microseconds of JS and dirties only what the mutated property dirties — with transform on a promoted layer, nothing the main thread cares about.
The ladder, in order of preference: CSS transitions and animations first (declarative, compositor-eligible, interruption handled by the browser); WAAPI el.animate() when you need dynamic values or JS-controlled timing (still compositor-eligible); rAF + ref mutation when the value is genuinely computed per frame by JS — gestures, physics — main-thread but reconcile-free; setState per frame never. React’s job is the state at the boundaries: start the animation, commit the final value once when it ends.
will-change, honestly
will-change: transform asks the browser to promote the element to its own composited layer ahead of time, so the first animation frame does not pay promotion (which can itself trigger a paint). When you reach for it, ask yourself whether you actually need the first-frame saving or whether you are adding it defensively across the board. The cost is what the Hook’s folklore fix discovered: a layer is a bitmap, roughly width × height × 4 bytes × devicePixelRatio² — one full-screen layer at 2× DPR is on the order of 30 MB, and four hundred promoted table rows are four hundred rasterized textures the GPU must hold and composite every frame. More layers also mean slower raster and longer compositor frames, so blanket promotion makes the slow machines slower. The discipline: promote just-in-time — set will-change on press intent or right before animating, remove it when the animation ends — never in a stylesheet wildcard. And remember that browsers auto-promote elements with running transform or opacity animations anyway: will-change buys you the first frame, not the animation.
To fix scroll jank, a teammate ships will-change: transform on all 400 rows of a table. Memory jumps by hundreds of MB and low-end devices scroll worse. What actually happened?
Reading the flames — and the INP connection
In the DevTools Performance panel, record the janky interaction and read three things. The Frames track: dropped and partially-presented frames show exactly when the budget broke. The Main flame chart: a purple Layout block recurring every 16 ms during your animation is the signature of animating a layout property; wide green Paint blocks mean a paint storm (an unpromoted animation, or a repaint-heavy property like box-shadow); long yellow JS tasks flagged with red triangles are your per-frame React work. The Rendering tab’s paint flashing and layer borders answer “what repaints” and “what got promoted” live, while you reproduce.
The INP connection makes this more than cosmetics: animation work and input handling share the main thread. A pointer event that arrives while your 24 ms animation frame is running waits for it; push interaction latency past 200 ms and INP is rated poor — the janky animation is now a Core Web Vitals regression. Compositor-driven animation is exempt by construction: it cannot block input it never shares a thread with.
▸Why this works
Why can layout not move off the main thread the way compositing did? Because layout is entangled with script by specification: any JS can read offsetWidth or getBoundingClientRect synchronously and must see geometry consistent with every DOM mutation it just made — so geometry computation has to live where the DOM lives, on the single thread that owns it. Compositing escaped because its inputs are immutable: rasterized bitmaps plus a transform list, no DOM access required, so placement can run on its own thread (and scrolling lives there too — which is why passive listeners exist: to promise the browser that your touch handler will not preventDefault and force scrolling back onto the main thread). transform and opacity are fast by design, not by accident — they were deliberately defined to not affect layout, so they are the only animation inputs the DOM-free thread can consume.
- 01Walk one frame through the rendering pipeline and classify properties by where they enter. Why does a transform animation survive a 400 ms main-thread block while a left animation freezes?
- 02Do the frame budget arithmetic at 60 and 120 Hz, and explain why setState-per-frame animation breaks it — and the alternatives ladder.
The rendering pipeline — JS, style, layout, paint, composite — runs once per output frame, and a property pays every stage below its entry point. Layout properties like width, top, and margin re-run the whole column on the main thread per frame; paint properties skip layout but rasterize; transform and opacity on a composited layer skip everything except placement, and placement belongs to the compositor thread, which holds the rasterized bitmaps and needs no DOM. That thread is the only off-main-thread ally you have: CSS transitions, CSS animations, and WAAPI on transform and opacity get handed to it and keep producing frames while the main thread spends 70 ms in a React reconcile — the Hook’s panel glided through exactly the block that had eaten its setState-driven predecessor at 9 fps. The budget arithmetic is unforgiving: 16.7 ms at 60 Hz and 8.3 ms at 120 Hz, of which your code gets roughly 10 and 4 ms after browser overhead; a 2–8 ms React commit per frame is therefore the canonical React animation sin — render, reconcile, and commit purchased sixty times a second to move one number. The alternatives ladder runs CSS first, WAAPI second, rAF with ref mutation for genuinely JS-computed values, setState never. will-change is a promotion hint with a price — width × height × 4 bytes × DPR² per layer, plus raster and compositing time that scale with layer count — so it is applied just-in-time and removed, not sprinkled. Verification is empirical: the Performance panel’s Frames track shows the dropped frames, recurring purple Layout blocks betray a layout-property animation, paint flashing and layer borders show what repaints and what got promoted. And the stakes exceed smoothness: input handlers share the main thread with your animation frames, so jank is interaction latency — INP past 200 ms is rated poor — while compositor-driven motion, by construction, cannot block a thread it never touches. Now when you see an animation stuttering during a React reconcile, you will know exactly which pipeline stage to look at — and which two properties let you skip it entirely.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.