Chapter 9 — CSS Performance: Repaints, Compositing, and will-change
Smooth animation means 60 frames per second — the browser has 16.7 ms to
produce each frame. Every frame that takes longer than that produces visible
jank. Understanding what CSS properties trigger which parts of the browser's
rendering pipeline is the foundation of performance work: once you know why
something is slow, the fix is usually a one-line change.
1. The Rendering Pipeline
Every visual change goes through some or all of these stages. The further
left a property change triggers, the more work the browser must repeat and
the more expensive it is.
🔴
Style
Recalculate which CSS rules apply to which elements
🟠
Layout
Calculate element geometry — size and position on the page
🟡
Paint
Fill in pixels: colours, borders, shadows, text
🟢
Composite
Combine painted layers in the correct order on the GPU
A property change can enter the pipeline at different stages:
- Layout (reflow): changing geometry properties —
width, height, margin, padding, font-size, display. Forces everything below it: layout → paint → composite.
- Paint (repaint): changing visual-only properties —
color, background-color, border-color, box-shadow. Skips layout: paint → composite.
- Composite only: changing
transform and opacity on composited layers. Skips layout and paint entirely. Runs on the GPU compositor thread.
2. Property Rendering Cost Reference
| Property / Change |
Pipeline entry |
Cost |
Notes |
| Geometry — triggers layout |
| width, height, min/max-* |
Layout |
High |
Full reflow from the changed element outward |
| margin, padding, border-width |
Layout |
High |
Affects box model — sibling and parent geometry can shift |
| top, left, right, bottom (position) |
Layout |
High |
On static/relative/absolute — triggers full reflow |
| font-size, line-height, font-family |
Layout |
High |
Changes text metrics — everything reflows |
| display, position, float |
Layout |
High |
Changes the formatting context — major reflow |
| overflow (changing to/from hidden) |
Layout |
High |
Can change scrollable area and clipping |
| Visual — triggers paint only |
| color, background-color |
Paint |
Medium |
Repaints the element; no geometry change |
| background-image (non-gradient) |
Paint |
Medium |
Image decode may add cost |
| border-color, outline, text-decoration |
Paint |
Medium |
Visual only — no layout impact |
| box-shadow, text-shadow |
Paint |
Medium |
Expensive to paint — blur is rasterised each frame |
| border-radius (on non-composited element) |
Paint |
Medium |
Clips during paint — use on composited layers to push to GPU |
| Composite only — GPU, fast |
| transform (translate, rotate, scale, skew) |
Composite |
Low ✓ |
GPU compositing — no layout or paint. Use for ALL motion. |
| opacity |
Composite |
Low ✓ |
GPU compositing. Composited layer alpha fade. |
| filter (on composited element) |
Composite |
Low ✓ |
GPU filter. blur() is still costly — use sparingly. |
| clip-path (on composited element) |
Composite |
Low ✓ |
With will-change: transform — stays on compositor thread |
3. Main Thread vs Compositor Thread
The browser has two threads relevant to rendering. The main thread
runs JavaScript, style calculation, layout, and paint. The
compositor thread runs on the GPU and handles compositing.
When an animation only needs compositing, the GPU thread handles every frame
independently — even if the main thread is busy running JavaScript.
Main Thread (CPU)
Paint (rasterise)
can be threaded
Long JS tasks block layout and paint — causes jank even for CSS animations that trigger layout
Compositor Thread (GPU)
transform animations
never blocks
opacity animations
never blocks
Scroll compositing
fast scroll
Layer compositing
combine layers
Runs independently — JS-heavy main thread cannot block a compositor-only animation
/* ── The golden rule: animate only transform and opacity ─────── */
/* ❌ Triggers layout → paint → composite on every frame */
@keyframes slide-bad {
from { left: 0; }
to { left: 200px; }
}
/* ✓ Composite only — GPU, cannot jank even under main-thread load */
@keyframes slide-good {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}
/* ❌ Triggers repaint on every frame */
@keyframes fade-bad {
from { background-color: oklch(0.55 0.22 248); }
to { background-color: oklch(0.55 0.22 300); }
}
/* ✓ Fade in/out with opacity — compositor only */
@keyframes fade-good {
from { opacity: 0; }
to { opacity: 1; }
}
/* ✓ Use translateY instead of top/margin-top for vertical movement */
.toast {
transform: translateY(-100%);
transition: transform 0.3s ease, opacity 0.3s ease;
opacity: 0;
}
.toast.visible {
transform: translateY(0);
opacity: 1;
}
4. will-change
will-change hints to the browser that an element is about to be
animated, allowing it to promote the element to its own compositor layer in
advance. This eliminates the one-frame cost of the first animation frame
where the layer would otherwise need to be created mid-animation.
Is this element animated with transform or opacity?
→ Yes: Is the animation triggered immediately on load (not on hover/scroll)?
→ Yes, starts immediately:
Add will-change: transform or will-change: opacity. The browser pre-promotes the layer before the first frame.
→ No, triggered later (hover, click, scroll):
Apply will-change just before the animation starts (add class in JS) and remove it afterwards. Or accept the negligible first-frame cost — often not worth the complexity.
→ No: Does animating it cause visible jank even with transform/opacity?
Profile first with DevTools. If a layer boundary issue is confirmed, use will-change: transform as a last resort — but this has a memory cost and can backfire.
When NOT to use will-change:
On every element pre-emptively. Each promoted layer uses GPU memory. Overuse can exhaust GPU memory, causing worse performance than no optimisation at all.
As a fix for layout or paint jank. will-change only helps compositor-stage work. Fix the root cause (use transform instead of left/top) rather than promoting layers.
/* ── will-change values ──────────────────────────────────────── */
will-change: transform;
/* Promotes to GPU layer. Use for translateX/Y/Z, rotate, scale animations. */
will-change: opacity;
/* Promotes to GPU layer for opacity fade animations. */
will-change: transform, opacity;
/* Comma-separated for multiple properties. */
will-change: scroll-position;
/* Hint that the element will be scrolled — browser optimises scroll compositing. */
will-change: contents;
/* Hint that the element's children will change. Lets the browser optimise */
/* subtree rendering but does NOT promote to its own compositor layer. */
will-change: auto;
/* Default. Remove will-change after the animation to release the GPU memory: */
/* ── Pattern: apply and remove around animations ─────────────── */
.card {
transition: transform 0.25s ease;
}
.card:hover {
will-change: transform;
transform: translateY(-4px) scale(1.02);
}
/* :hover is a natural scope — GPU layer is promoted on hover-start, */
/* released on hover-end. No JavaScript needed. */
/* ── JavaScript: apply/remove pattern for click animations ──── */
btn.addEventListener('pointerdown', () => {
btn.style.willChange = 'transform';
});
btn.addEventListener('transitionend', () => {
btn.style.willChange = 'auto'; /* release GPU memory after animation */
});
/* ── The 3D hack — old pattern, mostly unnecessary now ──────── */
/* You may see: transform: translateZ(0) or translate3d(0,0,0) */
/* These force GPU promotion without will-change. */
/* Use will-change: transform instead — it's explicit and clear. */
/* The translateZ(0) hack is only needed for very old browsers. */
5. CSS Containment — contain
The contain property tells the browser that an element and its
subtree are independent from the rest of the page. This lets the browser
skip large portions of the rendering pipeline when only the contained element
changes — a huge win for complex, dynamic UIs.
📐
contain: layout
The element's children cannot affect the layout of elements outside it. Internal reflows stay contained. Use on widgets that resize their own contents dynamically.
🎨
contain: paint
The element's children are clipped to its border box and cannot visually overflow. Tells the browser it never needs to repaint outside the box when internal content changes.
🔡
contain: style
Counters and quote scopes are contained. Rarely needed directly — mostly a concern for CSS custom properties used with @property.
📦
contain: size
The element's size does not depend on its children. The browser can skip children when computing the element's size. Almost always paired with a fixed size.
↔️
contain: inline-size
Like size but inline-axis only. The element's inline size does not depend on children. This is what container-type: inline-size applies under the hood.
⚡
contain: content
Shorthand for layout + paint + style. The most common production value. Does not contain size, so the element can still grow with its children.
/* ── contain syntax ─────────────────────────────────────────── */
contain: none; /* default — no containment */
contain: layout; /* layout isolation only */
contain: paint; /* paint isolation only — clips overflow */
contain: layout paint; /* combine any subset */
contain: content; /* layout + paint + style — most useful */
contain: strict; /* layout + paint + style + size — most restrictive */
/* ── Practical use on a card grid ────────────────────────────── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.card {
contain: content;
/* Each card is now isolated: */
/* - Updating one card's content triggers layout/paint for that card only */
/* - Other cards are not re-laid-out */
/* - Browser can skip off-screen cards more aggressively */
}
/* ── contain: layout enables absolute positioning scoping ────── */
/* An absolutely-positioned child is contained within the element */
/* just like position: relative — because contain: layout creates a */
/* containing block for absolute positioning */
.widget {
contain: layout; /* absolute children are scoped to this element */
}
/* ── contain vs overflow: hidden ─────────────────────────────── */
/* overflow: hidden clips visually but does NOT tell the browser */
/* anything about layout independence. */
/* contain: paint clips visually AND declares layout independence. */
/* Prefer contain: content over overflow: hidden for performance. */
6. content-visibility and contain-intrinsic-size
content-visibility: auto is the most impactful single-line
performance optimisation for long pages. It tells the browser to skip
rendering — layout, paint, and compositing — for any element that is not
currently visible in the viewport.
Typical long article page — time to first render
Without
Renders all sections — even those far below fold
~1,200ms
With auto
Visible only
~200ms
/* ── content-visibility values ───────────────────────────────── */
content-visibility: visible; /* default — no skipping */
content-visibility: hidden; /* always skip rendering — like display:none but keeps layout */
content-visibility: auto; /* skip when off-screen, render when approaching viewport */
/* ── content-visibility: auto on article sections ────────────── */
.article-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
/* contain-intrinsic-size tells the browser how tall to treat the section */
/* while it is not rendered. Without it, the scrollbar would jump as */
/* sections get rendered/unrendered, because their height is unknown. */
/* 'auto' remembers the last-known size; '600px' is the initial estimate. */
}
/* ── contain-intrinsic-size syntax ───────────────────────────── */
contain-intrinsic-size: 800px; /* width and height both 800px */
contain-intrinsic-size: 100% 400px; /* width: 100%, height: 400px */
contain-intrinsic-size: auto 400px; /* auto: use remembered size if available */
contain-intrinsic-block-size: auto 400px; /* logical property form */
/* ── content-visibility: hidden for off-screen panels ────────── */
/* Like display:none but preserved layout — ideal for tab panels, drawers */
.tab-panel { content-visibility: hidden; }
.tab-panel.active { content-visibility: visible; }
/* The panel's layout box still exists — no layout shift when shown */
/* The panel's contents are not rendered until made visible */
/* ── When NOT to use content-visibility: auto ────────────────── */
/* - Elements that contain sticky or fixed positioned children (breaks stacking) */
/* - Elements that must be included in fragment navigation (#anchor links) */
/* - The above-the-fold content — always rendered, no benefit */
/* - Elements that respond to IntersectionObserver (observer fires on skip) */
7. Avoiding Layout Thrash from JavaScript
/* Layout thrash: reading a layout property forces the browser to flush */
/* pending style changes and run layout immediately — interleaving reads */
/* and writes causes repeated reflows in the same frame */
/* ❌ Layout thrash — read/write alternation forces 3 separate reflows */
elements.forEach(el => {
const height = el.offsetHeight; /* READ — forces layout flush */
el.style.height = height + 10 + 'px'; /* WRITE — invalidates layout */
});
/* Each iteration: write from previous iteration → read forces flush → layout runs */
/* ✓ Batch reads, then batch writes */
const heights = elements.map(el => el.offsetHeight); /* all READs first */
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; /* all WRITEs after */
});
/* One layout flush, not N */
/* Properties that force layout when READ: */
/* offsetWidth, offsetHeight, offsetTop, offsetLeft */
/* scrollWidth, scrollHeight, scrollTop, scrollLeft */
/* clientWidth, clientHeight, clientTop, clientLeft */
/* getBoundingClientRect(), getComputedStyle() */
/* innerText (triggers layout for text measurement) */
/* ✓ Use requestAnimationFrame to batch DOM work in animation loops */
function animate() {
const reads = [];
elements.forEach(el => reads.push(el.getBoundingClientRect()));
elements.forEach((el, i) => {
el.style.transform = `translateX(${reads[i].width}px)`;
});
requestAnimationFrame(animate);
}
8. DevTools Performance Workflow
Profile on a slow device. Animations that look smooth on a development
machine can jank badly on a mid-range phone. Use DevTools CPU throttling (6× slowdown)
to simulate realistic conditions, or connect a real Android device via USB for
remote debugging. Most animation jank is invisible on fast hardware.
9. Practical Performance Patterns
/* ── Pattern 1: GPU-safe modal animation ─────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
opacity: 0;
transition: opacity 0.25s ease;
}
.modal-panel {
transform: translateY(24px) scale(0.97);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.2s ease;
}
.modal.open .modal-backdrop,
.modal.open .modal-panel {
opacity: 1;
transform: none;
}
/* Both animations run on the compositor thread — zero layout impact */
/* ── Pattern 2: Scroll-optimised infinite list ────────────────── */
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 80px; /* estimated row height */
contain: content;
}
/* 1000 list items render only the ~10 visible ones + buffer */
/* No virtualisation library required */
/* ── Pattern 3: Safe hover card lift ─────────────────────────── */
.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 16px 40px oklch(0 0 0 / 0.25);
}
/* transform is compositor; box-shadow triggers repaint but only once per hover */
/* Acceptable: one-time repaint on hover is far cheaper than per-frame repaint */
/* ── Pattern 4: Contained sidebar widget ─────────────────────── */
.sidebar-widget {
contain: content;
}
/* A stock ticker updating every second triggers layout/paint only within */
/* the widget — never causes the rest of the page to re-layout */
/* ── Pattern 5: Carousel with GPU compositing ────────────────── */
.carousel-track {
display: flex;
transition:transform 0.5s cubic-bezier(0.77, 0, 0.175, 1);
will-change:transform; /* pre-promote — carousel always animating */
}
/* JS only ever sets transform: translateX(Npx) — never left/scrollLeft */
/* Smooth even with a heavy main-thread JS load */
/* ── Pattern 6: Reduce motion safely ─────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Chapter Summary
| Concept | Key point |
| Rendering pipeline | Style → Layout → Paint → Composite. A property change enters the pipeline at a specific stage and forces everything after it. Geometry properties (width, top, margin) trigger layout. Visual properties (color, background-color, box-shadow) trigger paint. transform and opacity trigger only compositing. |
| Compositor thread | transform and opacity animations run on the GPU compositor thread independently of the main thread. Long JavaScript tasks cannot block them. All other properties animate on the main thread and can be blocked by JS. This is why transform/opacity are the animation properties of choice. |
| will-change | Hint to promote an element to its own compositor layer in advance of animation. Use will-change: transform for GPU animations that start immediately on load. Apply on :hover for triggered animations — the CSS scope naturally manages the layer lifetime. Remove after animation completes (style.willChange = 'auto') to release GPU memory. Never apply site-wide as a blanket optimisation. |
| contain | Isolates an element's subtree from the rest of the page's rendering. contain: content (= layout + paint + style) is the most useful production value. Limits the scope of reflow and repaint to within the element. Also creates a containing block for absolute positioning and a stacking context. |
| content-visibility: auto | Skips rendering (layout, paint, composite) for off-screen elements. Biggest single-line win for long-scrolling pages — can cut initial render time by 5–10×. Always pair with contain-intrinsic-size: auto [estimate] to prevent scrollbar jumping as elements are rendered/unrendered. Do not use on above-fold content or elements with sticky children. |
| Layout thrash | Interleaving DOM reads (offsetHeight, getBoundingClientRect) and DOM writes (style changes) in JavaScript forces the browser to flush and re-run layout repeatedly in a single frame. Fix by batching: all reads first, all writes after. In animation loops, use requestAnimationFrame to ensure writes happen before the next frame's reads. |
| DevTools workflow | Performance panel → look for purple Layout + green Paint bars. Rendering → Paint Flashing → anything that flashes constantly needs investigation. Rendering → Layer Borders → count compositor layers. Layers panel → check memory per layer. Profile at 6× CPU throttle to simulate real devices. |
Exercises
- Pipeline audit — before and after: Create a card that slides in on hover. Write version A using
margin-left for motion and background-color for a highlight effect. Write version B using translateX for motion and opacity for fade. Open DevTools → Rendering → Paint Flashing. Hover over both versions and observe the difference in flash frequency. Record a Performance trace for each and compare how many purple Layout bars appear per hover interaction.
- will-change budget test: Create a grid of 200 cards. Apply
will-change: transform to all 200. Open DevTools → Layers panel and note the total GPU memory consumed. Then remove will-change from all cards and add it only on :hover. Reload the Layers panel and compare GPU memory at rest. Document the difference — this illustrates why blanket will-change is counterproductive.
- content-visibility benchmark: Build a page with 500 article sections, each ~300px tall with a heading, two paragraphs, and an image. Measure Time to First Byte and the Rendering timeline in Performance (without
content-visibility). Then add content-visibility: auto; contain-intrinsic-size: auto 300px to each section and re-measure. Note the render time difference and verify that scrolling remains smooth and the scrollbar height is stable.
- Layout thrash fix: Write a function that reads each list item's
offsetTop and then sets a --stagger-delay custom property based on that value (for an entrance animation). Write version A that interleaves reads and writes in a single forEach loop. Write version B that batches reads first, then writes. Record a Performance trace for each and show the reflow count difference in the Summary tab.
- contain: content in a live dashboard: Build a mock dashboard with four widgets: a line chart that updates every 500ms, a scrolling news feed, a counter, and a static map placeholder. Without
contain, observe (via Paint Flashing) how updating the counter repaints other widgets. Add contain: content to each widget and confirm that paint flashing is now contained to only the widget that actually changed its content.
Next: Chapter 10 — CSS in Component Frameworks.
CSS Modules — scoped class names, composes, and local vs global scope;
CSS-in-JS concepts — inline styles, style objects, and tagged template literals;
utility-first CSS (Tailwind thinking) and when it wins vs loses;
styling strategy trade-offs in React, Vue, and Svelte; and how modern
bundlers handle CSS — tree-shaking, code splitting, and critical CSS extraction.