Optimizing render performance: requestAnimationFrame, layout thrashing and paint cost
A janky animation, a scroll that stutters, an interaction that lags by a fraction of a second. The cause is rarely the machine and almost always the rendering pipeline. How to hold 60 frames per second with requestAnimationFrame, avoid layout thrashing and control paint cost.

An animation that should be smooth stutters. A scroll catches in jerks. A menu takes half a second too long to react to a click. On most sites, the instinct is to blame the visitor's machine or to scale back the visual ambition. The real culprit is elsewhere: the browser cannot produce its frames fast enough because it is being asked to do the wrong work at the wrong time.
A 60 Hz screen refreshes its image 60 times per second. The browser therefore has roughly 16.6 milliseconds to produce each frame, meaning each image drawn on screen: run the JavaScript, recalculate styles, measure layout, paint the pixels and composite the whole thing. Exceed that budget, even on a few frames, and the eye perceives a stutter. This is what we call jank.
The good news is that this budget can be respected without sacrificing any creative ambition. You only need to understand what the browser does on each frame and stop forcing it to redo the same work over and over. Today we dissect the rendering pipeline, the real role of requestAnimationFrame, the trap of layout thrashing and the hidden cost of paint.
The rendering pipeline: what the browser does on each frame
To display an image, the browser runs through a sequence of steps known as the pixel pipeline. Every visual change triggers all or part of this chain. Understanding which steps your code wakes up is the foundation of any render optimization.
| Step | Role | Cost |
|---|---|---|
| JavaScript | Runs the logic: events, animations, DOM manipulation | Variable. Blocks everything else while it runs |
| Style | Computes which CSS rules apply to which elements | Proportional to the number of elements touched |
| Layout (reflow) | Computes geometry: position and size of every element | High. One change can affect the whole page |
| Paint | Fills in pixels: colors, text, shadows, borders | High on large surfaces or complex effects |
| Composite | Assembles the painted layers in the right order on screen | Low. Offloaded to the GPU when possible |
The five steps of the pixel pipeline, from JavaScript execution to final display
The key point: not all changes cost the same. Changing a text color triggers Paint and Composite but not Layout. Moving an element with transform triggers only Composite. Modifying its width or top, however, wakes the whole chain from Layout onward. The more steps a change wakes upstream, the more expensive it is.
The goal of a smooth animation is therefore to aim as far right as possible in this chain. An animation that touches only Composite fits effortlessly within the 16 ms budget. An animation that forces a Layout on every frame blows past it as soon as the page grows more complex.
requestAnimationFrame: syncing with the screen's rhythm
The first classic mistake is animating with setTimeout or setInterval. The problem is not precision: it is that these timers are not synchronized with the screen refresh. An animation running on setInterval(fn, 16) produces frames that fall beside the display cycle. The result: images computed for nothing and others shown twice. The eye sees micro-stutter even when the code runs fast.
requestAnimationFrame (rAF) solves exactly this problem. As the MDN documentation (opens in a new tab) details, the callback passed to rAF is run by the browser just before the next paint, aligned with the real rhythm of the screen: 60 times per second on a 60 Hz display, 120 on a 120 Hz display. The code adapts automatically to the hardware.
// Anti-pattern: a timer desynced from the screen
setInterval(() => {
box.style.left = (position += 2) + 'px'; // dropped frames, micro-jank
}, 16);
// Recommended pattern: aligned with the screen refresh
function animate(timestamp) {
// timestamp is supplied by the browser: we compute the motion
// from elapsed time, not from a fixed increment per frame
const progress = (timestamp - start) / duration;
box.style.transform = `translateX(${progress * distance}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
let start;
requestAnimationFrame((t) => {
start = t;
animate(t);
});Animate with requestAnimationFrame rather than a timer
Two details make all the difference. First, the timestamp passed to the callback lets you compute the motion from real elapsed time rather than a fixed per-frame increment. A time-driven animation stays consistent whether the screen runs at 60 or 120 Hz and whether a frame is dropped or not. Second, when the tab moves to the background, the browser automatically suspends rAF callbacks. A setInterval would keep running and burning battery and CPU for nothing.
rAF also serves a less obvious purpose: deferring a DOM read or write until the right moment in the cycle. That is the basis of the technique that solves the next problem, the most expensive of all.
Layout thrashing: the forced reflow trap
Layout thrashing is arguably the number one cause of stutter in JavaScript code that manipulates the DOM. It happens when you alternate reads and writes of geometric properties within the same sequence, which forces the browser to recalculate layout several times instead of once.
The browser is actually smart: it batches DOM changes and recomputes layout only once, at the last moment. Unless you ask it to read a geometric value that depends on changes still pending. Then it has no choice: it must recalculate immediately to answer. That is the forced synchronous layout.
// Anti-pattern: each offsetWidth read forces a reflow
// because a write has just invalidated the layout
const boxes = document.querySelectorAll('.box');
boxes.forEach((box) => {
const width = box.offsetWidth; // READ: forces a reflow
box.style.width = width + 10 + 'px'; // WRITE: invalidates the layout
});
// For 100 elements: 100 synchronous reflows. The frame blows upLayout thrashing: interleaved read and write
The fix comes down to one rule: read first, write second. Group all geometric reads into a first pass, then all writes into a second. The browser then recomputes layout only once, after the full batch of writes. This is the official recommendation from Google's web.dev team (opens in a new tab).
const boxes = document.querySelectorAll('.box');
// 1. READ pass: collect every value at once
const widths = Array.from(boxes).map((box) => box.offsetWidth);
// 2. WRITE pass: no read in between
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});
// A single reflow for the whole set, no matter the element countSolution: batch the reads then the writes
You still need to know which properties trigger this forced reflow on read. They are all the ones that describe a geometry computed from the current state of the DOM.
- Dimensions and position:
offsetWidth,offsetHeight,offsetTop,offsetLeft,clientWidth,clientHeight,scrollWidth,scrollHeight. - Scroll:
scrollTop,scrollLefton read. - Computed geometry:
getBoundingClientRect(),getComputedStyle()on layout properties. - Focus and visibility:
element.focus()can force a layout to compute the target's position.
When logic inevitably alternates reads and writes (for example in decoupled components that do not know about each other), the solution is to schedule the writes inside a requestAnimationFrame. Libraries such as FastDOM formalize this pattern by exposing two separate queues, measure() and mutate(), run at the right point in the cycle. The principle stays the same: never read geometry right after invalidating it.
Paint cost: paint less, paint smaller
Once layout is computed, the browser fills in the pixels. That is the Paint step. It is invisible in the code but very real in the budget: some visual effects are expensive to paint, and doing it on every frame of an animation is enough to cause jank, even without any reflow.
The most expensive properties to paint are the ones that ask the browser to compute each pixel from several sources: box-shadow with a large blur radius, filter: blur(), complex gradients, border-radius on large surfaces. A single large animated box-shadow can be enough to drop a page below 60 fps, that is 60 frames per second.
Two levers reduce this cost. First: paint a smaller surface. The larger the area to repaint, the more expensive the paint. Animating an effect on a small element costs less than animating the same thing on a full-screen block. Second: avoid repainting what does not change by isolating animated elements on their own compositing layer.
transform and opacity: the two free properties
There are two properties the browser can animate without triggering Layout or Paint: transform and opacity. They are handled directly at the Composite step, offloaded to the GPU. web.dev recommends sticking to these two properties (opens in a new tab) for animations. That is why any animation of movement, scale, rotation or fade should go through them rather than their geometric equivalents.
| Instead of | Use | Steps avoided |
|---|---|---|
top / left | transform: translate() | Layout + Paint |
width / height | transform: scale() | Layout + Paint |
margin to shift | transform: translate() | Layout + Paint |
visibility / color to fade | opacity | Paint |
The substitutions that move an animation from Layout to Composite only
For an element to be animated on the compositor, the browser promotes it onto its own GPU layer. You can hint at this promotion in advance with will-change, which avoids a hitch at the start of the animation while the browser prepares the layer.
/* We tell the browser we are about to animate transform on this element.
It prepares a dedicated GPU layer ahead of time */
.card-interactive {
will-change: transform;
}
.card-interactive:hover {
transform: translateY(-8px) scale(1.02);
transition: transform 0.3s ease;
}
/* Always respect reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
.card-interactive {
transition: none;
}
}Prepare the compositing layer with will-change
will-change is a tool to use sparingly. Each compositing layer consumes video memory. Putting it on dozens of elements, or worse leaving it on permanently across the whole site, saturates the GPU and produces the opposite of the intended effect. The rule: only promote elements that are genuinely about to be animated, and remove the property once the animation is over if it does not repeat. It is also a direct cousin of the layer leaks found in memory leaks and cleanup patterns (opens in a new tab).
When an animation can be described entirely in CSS, it is often better to hand it to the browser rather than drive it in JavaScript frame by frame. CSS Scroll-Driven Animations (opens in a new tab) now cover a large share of scroll-related cases without a single line of rAF.
Measure before optimizing: the DevTools method
Optimizing by guesswork wastes time and adds complexity where it is not needed. The rendering pipeline can be measured precisely in the browser DevTools. Here is the protocol to pin down where a stutter comes from.
1. Record a profile in the Performance tab
Open the Performance tab, start a recording, reproduce the interaction that stutters, stop. The timeline shows each frame with its color breakdown: yellow for JavaScript, purple for Layout, green for Paint. Frames that exceed 16.6 ms are flagged in red. The dominant color of a red frame instantly tells you which step is to blame.
2. Hunt forced synchronous layouts
In the same profile, the browser flags forced reflows with a warning triangle. Hovering over the mark reveals the exact line of code that triggered the geometric read at the wrong moment. It is the fastest way to locate layout thrashing in a codebase you do not know.
3. Visualize repainted areas
Enable "Paint flashing" in DevTools (Rendering menu). Each repainted area flashes green on screen. If a large surface flashes while only a small element is supposed to move, the repaint is overflowing: the animated element is not isolated on its layer and drags its whole neighborhood into the repaint. That is the signal to switch to transform or isolate the layer.
4. Inspect the compositing layers
The Layers tab lists the GPU layers created by the page and their memory weight. Too many layers signals excessive use of will-change or properties that force promotion. Zero layers on an element meant to be animated on the compositor signals the opposite: the animation falls back to the CPU. This tab reconciles what you think you coded with what the browser actually does.
Beyond animations, this per-frame budget is exactly what INP (Interaction to Next Paint) measures, now one of the Core Web Vitals. A slow-to-respond interaction is a rendering pipeline saturated on the few frames that follow the click. Optimizing the render is therefore also optimizing a signal Google watches, as detailed in SEO and Core Web Vitals in 2026 (opens in a new tab).
The 6 most common mistakes
- Animating
top,left,widthorheightinstead oftransform. Each frame forces a full Layout. Switching totransformis often the single most rewarding optimization for a janky animation. - Reading geometry inside a write loop. Each
offsetWidthorgetBoundingClientRect()after a mutation forces a synchronous reflow. Batching reads then writes removes the problem. - Using
setIntervalto animate. Desynced from the screen, it produces micro-jank and keeps running in the background.requestAnimationFramealigns with the display and suspends itself. - Leaving
will-changeon many elements permanently. Each layer consumes video memory. Promoted too broadly, the tool saturates the GPU and degrades rendering instead of improving it. - Animating a
box-shadoworfilter: blur()directly. These effects, repainted on every frame, are expensive. Prefer animating theopacityof a shadow already painted on a separate layer. - Optimizing without measuring. Without a DevTools profile, you are guessing. The Performance timeline tells you in thirty seconds whether the culprit is JavaScript, Layout or Paint. Measure first to avoid complicating code that was never the problem.
Smoothness is not a question of raw power
A site that stutters on a recent machine is not short on power: it is squandering the little time the browser has to draw each image. The visitor's browser does exactly what it is asked, and what it is asked is too often to redo the same layout calculation sixty times per second for a motion that could have stayed on the compositor.
Mastering the rendering pipeline changes everything across the site: animations that hold 60 fps on mobile and desktop alike, a scroll that never drops, interactions that respond instantly and an INP that turns green. That is what separates an experience that looks polished from one that genuinely is.