1Animation Performance and Best Practices
▶
Creating animations that feel smooth and responsive is one of the most demanding challenges in front-end development. A user perceives animation as fluid when the browser consistently delivers new frames at 60 frames per second — meaning every single frame must be fully calculated, painted, and composited within roughly 16 milliseconds. Miss that budget even occasionally and the result is jank: a visible stutter or hitch that immediately degrades the user experience. Achieving consistent performance requires understanding exactly what the browser does to turn your CSS properties and JavaScript instructions into pixels on screen, and then deliberately structuring your code to avoid the most expensive parts of that process.
This topic examines the full stack of animation performance concerns: the browser's internal rendering pipeline, how the GPU fits into that picture, the trap of layout thrashing, the trade-offs between CSS and JavaScript animation approaches, the correct use of requestAnimationFrame, and how to use browser tooling to measure and confirm real improvements.
Understanding the Browser Rendering Pipeline
Every time the browser needs to update what is shown on screen it walks through a sequence of stages. Not every frame requires every stage, and understanding which stages your animation triggers — and which it skips — is the foundation of animation performance work.
The full pipeline runs in this order: JavaScript → Style → Layout → Paint → Composite. Your animation code runs in the JavaScript stage, modifying styles or the DOM. The browser then recalculates which CSS rules apply (Style), figures out the size and position of every affected element (Layout), draws the visual appearance of those elements into bitmaps (Paint), and finally assembles all the bitmaps together on the screen (Composite). Each stage builds on the previous one, which means triggering an early stage forces every later stage to run as well.
Layout is the most expensive stage to trigger repeatedly. It is caused by any change to a property that affects the geometry of the page — width, height, margin, padding, top, left, font-size, and many others. When you animate an element's width from 100 px to 200 px, the browser must recalculate the position and size of every element that might be affected by that change — potentially the entire document. This is called a reflow.
Paint is triggered when the visual appearance of an element changes in a way that doesn't affect geometry — changing color, background-color, border-color, box-shadow, and similar properties. The browser must re-rasterize the pixels in the affected region, writing new colour values into the bitmap for that layer. Paint is cheaper than layout but still requires the CPU to do meaningful work on every frame it fires.
Compositing is the final and cheapest stage. The browser takes pre-painted bitmaps (stored as GPU textures after layer promotion) and combines them on the GPU using fast matrix mathematics. Moving, scaling, rotating, or fading a GPU layer requires only compositing — no geometry recalculation, no pixel rasterization. This is why compositing-only animations are the target: the GPU handles the work in a fraction of a millisecond, leaving the CPU almost entirely free. Keeping every frame of an animation in the composite stage alone is the single most important technique for reaching consistent 60 fps.
The table below summarises which pipeline stages each category of property triggers:
| Property category | Example properties | Triggers Layout | Triggers Paint | Triggers Composite |
|---|---|---|---|---|
| Geometry | width, height, margin, top, left |
Yes | Yes | Yes |
| Visual appearance | color, background, box-shadow |
No | Yes | Yes |
| Composited only | transform, opacity |
No | No | Yes |
GPU Acceleration and Composited Properties
Only two CSS properties are guaranteed to animate without triggering layout or paint on modern browsers: transform and opacity. This makes them the cornerstones of high-performance animation.
transform covers a wide range of visual effects through its functional notation: translate() moves an element, scale() resizes it, rotate() spins it, skew() shears it, and matrix() combines arbitrary transformations. Crucially, all of these operations are applied after layout — the browser already knows the element's size and position; the transform is just a mathematical operation applied to the GPU texture. A translated element does not push other elements around, and the browser never needs to reflow the document.
When the browser determines that an element needs to be composited independently — because it has a CSS animation on transform or opacity, uses position: fixed, has will-change set, or meets several other criteria — it promotes that element to its own compositor layer. The element is rasterized into a GPU texture once, and from that point forward the GPU can manipulate it at very low cost.
You can give the browser an early hint to promote an element before the animation starts using the will-change property:
/* Tell the browser this element will animate its transform */
.animated-card {
will-change: transform;
}
/* Or hint for opacity changes */
.fade-element {
will-change: opacity;
}
Without will-change, the browser may delay promotion until the first frame of the animation, causing a visible hitch on that initial frame as it rasterizes the element into a GPU texture. With will-change declared in advance (for example, on hover of a parent element before the child animates), the browser can perform that promotion during an idle moment.
However, will-change carries a real cost: each promoted layer consumes GPU memory. A promoted element's bitmap must live on the GPU for as long as the promotion is active. On desktop hardware with gigabytes of VRAM this is rarely a problem, but on mobile devices with shared and constrained memory it can cause the browser to evict other textures, slow compositing, and even crash low-memory tabs. The correct pattern is to apply will-change just before animation begins and remove it as soon as the animation ends:
const card = document.querySelector('.card');
card.addEventListener('mouseenter', () => {
card.style.willChange = 'transform';
});
card.addEventListener('mouseleave', () => {
// Remove hint once animation has settled
card.style.willChange = 'auto';
});
A practical rule: never apply will-change globally to large numbers of elements or as a blanket "performance fix." Measure first; apply selectively only to elements that demonstrably benefit.
Avoiding Layout Thrashing
Layout thrashing is a particularly insidious performance problem. It occurs when JavaScript alternately writes to the DOM and then immediately reads a layout-dependent property, forcing the browser to perform a synchronous reflow — abandoning deferred work to answer the read right now. Each forced reflow can take several milliseconds, and if you do this inside a loop over many elements you can easily spend 100 ms or more on layout alone within a single frame.
Consider this example of accidental layout thrashing:
// BAD: reads and writes are interleaved
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
const width = box.offsetWidth; // READ — forces synchronous reflow
box.style.width = (width * 1.1) + 'px'; // WRITE — invalidates layout
// On the next iteration, the READ forces another reflow
});
Because each offsetWidth read follows a style.width write, the browser cannot batch the layout work. It must perform a full reflow before returning the value of offsetWidth, then another reflow on the next iteration. With 20 boxes this produces 20 synchronous reflows in a single frame.
The fix is to batch all reads before all writes:
// GOOD: reads are batched first, then writes
const boxes = document.querySelectorAll('.box');
const widths = [];
// Phase 1: read all values (only one reflow needed, or possibly none)
boxes.forEach(box => {
widths.push(box.offsetWidth);
});
// Phase 2: write all values (layout is invalidated once, deferred to next frame)
boxes.forEach((box, i) => {
box.style.width = (widths[i] * 1.1) + 'px';
});
Layout-triggering properties you should be aware of include: offsetWidth, offsetHeight, offsetTop, offsetLeft, clientWidth, clientHeight, scrollTop, scrollLeft, getBoundingClientRect(), getComputedStyle(), and others. Any of these, read after a style mutation, will force a synchronous reflow.
Inside a requestAnimationFrame callback, the recommended pattern is to perform all reads at the very top of the callback — before any mutations — and all writes afterward. This respects the browser's frame lifecycle and prevents forced synchronous reflows:
requestAnimationFrame(timestamp => {
// READS first
const rect = element.getBoundingClientRect();
const scrollY = window.scrollY;
// WRITES after
element.style.transform = `translateY(${scrollY * 0.5}px)`;
});
A deeper issue is animating geometry properties at all. Animating top or left via JavaScript — for example incrementing them by a pixel count each frame — triggers layout on every frame by definition. The correct approach is to use transform: translate() instead. Both produce identical visual movement, but the transform version never triggers layout:
// BAD: triggers layout every frame
let pos = 0;
function animate() {
pos += 2;
element.style.left = pos + 'px'; // layout!
requestAnimationFrame(animate);
}
// GOOD: compositor-only, no layout
let pos = 0;
function animate() {
pos += 2;
element.style.transform = `translateX(${pos}px)`; // composite only
requestAnimationFrame(animate);
}
Choosing Between CSS and JavaScript Animations
The choice between CSS animations/transitions and JavaScript-driven animations is not purely stylistic; it has performance, architectural, and capability implications.
CSS transitions and animations are declarative. You describe the start state, end state, and the timing function, and the browser handles everything in between. When the animated properties are transform and opacity, the browser can hand the entire animation to the compositor thread, which runs independently of the main JavaScript thread. This means your animation can continue smoothly even if the main thread is busy parsing JavaScript, handling events, or executing application logic. CSS animations are the right choice for UI micro-interactions: hover effects, modal entrance/exit transitions, loading spinners, button state changes.
/* CSS transition — compositor-thread eligible */
.modal {
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.modal.is-visible {
opacity: 1;
transform: translateY(0);
}
JavaScript animations with requestAnimationFrame give you frame-by-frame control. This is essential when the animation depends on runtime values — physics simulations, scroll position, pointer coordinates, audio levels, game state — that CSS cannot express declaratively. The downside is that the animation logic runs on the main thread, so heavy computation in the same frame as the animation can cause jank. JavaScript animations are appropriate for parallax scroll effects, physics-based spring simulations, particle systems, canvas-based games, and anything where the target value is calculated dynamically.
The Web Animations API (WAAPI) offers a middle ground. It is a JavaScript API that drives animations with the same engine as CSS animations, meaning it retains compositor-thread eligibility for transform and opacity while giving JavaScript control over playback, timing, and dynamic values:
const animation = element.animate(
[
{ transform: 'translateX(0px)', opacity: 1 },
{ transform: 'translateX(300px)', opacity: 0 }
],
{
duration: 500,
easing: 'ease-in-out',
fill: 'forwards'
}
);
// JavaScript control over playback
animation.pause();
animation.currentTime = 250; // seek to midpoint
animation.play();
The following table summarises the trade-offs:
| Approach | Compositor thread eligible | Dynamic / runtime values | Best use case |
|---|---|---|---|
| CSS transitions / animations | Yes (transform & opacity) | No — values set at author time | UI state transitions, micro-interactions |
| requestAnimationFrame (JS) | Only with composited properties | Yes — full runtime flexibility | Physics, scroll effects, games, data-driven animation |
| Web Animations API | Yes (transform & opacity) | Partially — keyframes can be generated at runtime | Programmatic UI animations requiring playback control |
Using requestAnimationFrame Correctly
requestAnimationFrame (rAF) is the correct mechanism for driving JavaScript animation loops. Unlike setTimeout or setInterval, which fire on arbitrary timer intervals that have no relationship to the display's refresh cycle, rAF schedules your callback to run just before the browser paints the next frame. This synchronisation with the rendering pipeline means your changes are always incorporated into the upcoming frame rather than arriving mid-frame or being delayed to the frame after.
A minimal correct animation loop looks like this:
let rafId = null;
let startTime = null;
const duration = 1000; // 1 second
function animate(timestamp) {
if (startTime === null) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1); // 0 → 1
// Apply easing (ease-in-out cubic)
const ease = progress < 0.5
? 4 * progress ** 3
: 1 - (-2 * progress + 2) ** 3 / 2;
element.style.transform = `translateX(${ease * 300}px)`;
if (progress < 1) {
rafId = requestAnimationFrame(animate);
} else {
rafId = null; // animation complete
}
}
rafId = requestAnimationFrame(animate);
Several important points are illustrated here:
- The timestamp argument is a
DOMHighResTimeStampprovided by the browser, measured in milliseconds with sub-millisecond precision. Always use this timestamp — neverDate.now()— to calculate elapsed time and progress. This ensures the animation plays at the correct speed regardless of whether it is running at 30, 60, or 120 Hz. - Delta-time calculation (the
elapsedvariable above) decouples animation progress from frame rate. If a frame is delayed — because the user switched tabs, the device throttled the CPU, or an event handler ran long — the animation will jump forward appropriately rather than falling behind or losing frames silently. - Cancelling the loop with
cancelAnimationFrame(rafId)is essential when an animation completes, when the animated element is removed from the DOM, or when the containing component unmounts (in a framework context). Failing to cancel a loop leaves it running indefinitely, wasting CPU time and potentially causing memory leaks if the callback holds references to removed DOM nodes.
// Cancel the loop on user action or cleanup
document.querySelector('#stop').addEventListener('click', () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
});
- Avoid heavy computation inside rAF callbacks. You have roughly 16 ms for the entire frame, shared with style calculation, layout, paint, and compositing. If your callback spends 10 ms sorting an array or traversing a large data structure, you have already consumed most of the budget before any rendering work begins. Move expensive computation outside the animation loop or spread it across idle periods using
requestIdleCallback.
Measuring and Profiling Animation Performance
Optimising animation performance without measurement is guesswork. The browser's built-in tooling provides precise, actionable data about exactly what is happening on each frame.
The Performance panel in Chrome DevTools is the primary tool. To use it: open DevTools, navigate to the Performance tab, click Record, perform the animation, then stop recording. The resulting flame chart shows every activity on the main thread and compositor thread across time. Each horizontal band represents a different stage of the pipeline. Key things to look for:
- Long frames: frames taller than the 16 ms line in the frame timeline are immediately visible. Clicking them reveals what caused the delay.
- Purple "Recalculate Style" blocks: indicate CSS rule matching is being performed repeatedly, often caused by class toggling inside a loop.
- Green "Layout" blocks: indicate a reflow occurred. If these appear every frame, your animation is triggering layout. The Details pane shows the JavaScript call that caused the forced reflow.
- Green "Paint" blocks: indicate rasterization is occurring. Ideally, these should appear only once at the start of an animation (to create the GPU texture) and not on every frame.
The Rendering tab overlays (accessible via DevTools Menu → More tools → Rendering) provide real-time visual feedback:
- FPS Meter: displays a live frames-per-second counter and a graph of recent frame times in the corner of the page, making dropped frames immediately visible.
- Paint Flashing: overlays a green highlight on any region that is being repainted. If your composited animation is constantly flashing green, something in or near the animated element is triggering paint unnecessarily — often a
box-shadoworborder-radiuschange alongside the transform. - Layer Borders: draws outlines around each compositor layer, helping you see how many layers have been promoted and whether unexpected elements have been pulled into their own layers.
Frame time targets depend on the display:
| Display refresh rate | Target frame time | Frames per second |
|---|---|---|
| 60 Hz (standard) | < 16 ms | 60 fps |
| 90 Hz (mid-range mobile) | < 11 ms | 90 fps |
| 120 Hz (ProMotion / high-end) | < 8 ms | 120 fps |
When profiling, always test on target hardware. An animation that profiles at 60 fps on a developer laptop may deliver only 30 fps on an entry-level Android phone. Chrome DevTools' CPU throttling feature (in the Performance panel) allows you to simulate 4× or 6× slowdown, giving a rough approximation of less powerful devices without leaving your desk.
Animation Best Practices Summary
The following principles consolidate everything covered above into a concise working checklist:
- Animate only
transformandopacity. These are the only two properties that stay entirely in the compositor stage. Every other property either triggers paint or layout. If you need to move something, usetranslateX/translateYinstead ofleft/top. If you need to resize something visually, usescale()instead ofwidth/height. - Use
will-changeselectively and temporarily. Apply it just before an animation begins and remove it withwill-change: autoas soon as the animation ends. Never apply it to dozens of elements as a blanket optimisation; the GPU memory cost will outweigh any benefit. - Drive animation loops with
requestAnimationFrame, not timers. Use the provided timestamp to compute delta time, keeping animation speed consistent across different refresh rates. Always callcancelAnimationFramewhen the animation finishes or the component is torn down. - Separate DOM reads from DOM writes. Batch all layout reads at the beginning of your frame logic, then perform all writes. Never interleave reads and writes inside a loop. This pattern prevents forced synchronous reflows.
- Choose CSS for simple state transitions; JavaScript (or WAAPI) for dynamic animations. CSS animations running on the compositor thread are immune to main-thread jank. Use them wherever the animation parameters can be expressed in static CSS. Reserve JavaScript animation for cases requiring runtime-computed values.
- Limit the number of simultaneously promoted layers. Each promoted element consumes GPU memory. Animations involving dozens of promoted elements simultaneously — especially on mobile — can exhaust GPU memory budgets, causing the browser to de-promote layers mid-animation and triggering paint storms. Profile layer counts in DevTools' Layers panel when working on complex animations.
- Measure before and after every optimisation. The Performance panel gives you objective frame-time data. Record a baseline, make one change, record again, and compare. This prevents wasted effort on changes that have no measurable impact and ensures real improvements are confirmed rather than assumed.
Taken together, these principles form a discipline: design animations to live entirely in the compositor, write JavaScript that respects the browser's rendering lifecycle, and let measurement — not intuition — guide every optimisation decision.