Compositor Animations and Layout Thrashing
Compositor Animations and Layout Thrashing
The Symptom
The e-commerce product image gallery has a carousel that animates between product photos. On a mid-tier Android phone, the carousel animation stutters visibly. The Performance panel shows frames taking 45-80ms instead of the 16.7ms budget, with “Layout” consuming 20-35ms per frame.
The Cause
The carousel animates using left to slide images horizontally:
.carousel-track {
position: relative;
transition: left 300ms ease-out;
}
Changing left triggers Layout, Paint, and Composite on every frame of the animation. Layout is the expensive stage because the browser must recalculate the position of the carousel track and all its children.
The alternative is transform: translateX(), which triggers only Composite. The compositor thread handles transform animations independently of the main thread. Even if the main thread is busy with JavaScript, a transform animation runs at 60fps.
The Baseline
Carousel swipe animation profile on Moto G Power (4x throttle):
| Property | Frames Rendered | Avg Frame Time | Jank Frames (>16ms) |
|---|---|---|---|
left | 18/18 | 52ms | 16/18 (89%) |
transform | 18/18 | 4ms | 0/18 (0%) |
With left, 89% of frames exceed the 16.7ms budget. The animation is visibly choppy. With transform, every frame completes in 4ms, well within budget.
The Fix
/* SLOW: Layout-triggering animation */
.carousel-track {
position: relative;
transition: left 300ms ease-out;
}
/* FAST: Compositor-only animation */
.carousel-track {
transition: transform 300ms ease-out;
will-change: transform;
}
// SLOW: Animating left position
function slideToIndex(index: number): void {
const track = trackRef.current;
if (!track) return;
const offset = index * slideWidth;
track.style.left = `-${offset}px`;
}
// FAST: Animating transform
function slideToIndex(index: number): void {
const track = trackRef.current;
if (!track) return;
const offset = index * slideWidth;
track.style.transform = `translateX(-${offset}px)`;
}
The will-change: transform hint tells the browser to promote the element to its own compositing layer ahead of the animation. Without it, the browser promotes the layer when the animation starts, causing a one-frame stutter on the first slide. With it, the layer is pre-promoted and the first frame is smooth.
Use will-change sparingly. Every promoted layer consumes GPU memory. On the e-commerce product detail page, the carousel track is the only element that needs will-change. Applying it to dozens of elements would increase GPU memory usage and potentially cause the GPU to run out of texture memory on low-end devices, which is worse than the jank it prevents.
Properties that trigger only Composite (safe for animation):
transformopacityfilter
Properties that trigger Layout (avoid animating):
width,heighttop,right,bottom,leftmargin,paddingfont-sizeborder-width
Layout Thrashing
Layout thrashing occurs when JavaScript reads a layout property, writes to the DOM, reads again, writes again, in a tight loop. Each read after a write forces the browser to compute layout synchronously (a “forced synchronous layout”) to return an accurate value.
// SLOW: Layout thrashing - read/write/read/write pattern
function resizeCards(cards: HTMLElement[]): void {
for (const card of cards) {
const height = card.offsetHeight; // READ (forces layout)
card.style.height = `${height + 20}px`; // WRITE (invalidates layout)
// Next iteration: offsetHeight forces layout again
}
}
// With 48 product cards: 48 forced layouts, ~15ms each = 720ms
// FAST: Batch reads, then batch writes
function resizeCards(cards: HTMLElement[]): void {
// Read phase: collect all measurements
const heights: number[] = [];
for (const card of cards) {
heights.push(card.offsetHeight); // READ
}
// Only one layout computation for all reads
// Write phase: apply all changes
for (let i = 0; i < cards.length; i++) {
cards[i].style.height = `${heights[i] + 20}px`; // WRITE
}
// One layout invalidation, resolved on next frame
}
// With 48 product cards: 1 forced layout + 1 deferred layout = ~18ms total
The batched version reduces layout computation from 720ms to 18ms, a 40x improvement. The Performance panel shows the difference clearly: the thrashing version has 48 narrow purple “Layout” blocks interleaved with scripting blocks. The batched version has one wide “Layout” block followed by scripting, followed by one deferred layout.
For cases where reading and writing must interleave (e.g., measuring an element after applying a class to determine the next action), use requestAnimationFrame to defer the write to the next frame:
function animateExpansion(element: HTMLElement): void {
const startHeight = element.offsetHeight; // READ
requestAnimationFrame(() => {
element.classList.add("expanded"); // WRITE
requestAnimationFrame(() => {
const endHeight = element.scrollHeight; // READ (next frame)
element.style.height = `${endHeight}px`; // WRITE
});
});
}
The Proof
After fixing the carousel animation and layout thrashing on the product detail page:
| Metric | Before | After | Delta |
|---|---|---|---|
| INP (carousel swipe) | 280ms | 35ms | -245ms |
| Jank frames during animation | 89% | 0% | -89pp |
| Layout time during card resize | 720ms | 18ms | -702ms |
| INP (p75, product detail) | 320ms | 140ms | -180ms |
The p75 INP improvement of 180ms moved the product detail page from “needs improvement” to “good” territory. The carousel animation is now visually smooth on all tested devices including the Moto G Power.
The Trade-off
Compositor-layer promotion increases GPU memory usage. Each promoted layer stores a bitmap of the element’s content in GPU memory. For the carousel track with five 800x533 product images, the layer bitmap is approximately 800 _ 533 _ 4 bytes * 5 = 8.5MB of GPU texture memory. On devices with limited GPU memory (some low-end Android phones have 256MB total), this is significant.
The will-change property should be removed after the animation completes if the element does not animate frequently. For the carousel, which animates on user swipe, keeping will-change active is justified. For a one-time entrance animation, add will-change before the animation and remove it in the transitionend event handler.
Layout thrashing detection is difficult to automate in CI. The Chrome DevTools Performance panel highlights forced synchronous layouts, but there is no programmatic API to detect them in a test. The practical approach: code review guidelines that flag read-write-read patterns in DOM manipulation code, and periodic manual performance audits of interactive pages.