CSS Performance — Repaints, Compositing, and will-change
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:
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)
JavaScript execution
Style recalculation
Layout (reflow)
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 */@keyframesslide-bad{from{left:0;}to{left:200px;}}/* ✓ Composite only — GPU, cannot jank even under main-thread load */@keyframesslide-good{from{transform:translateX(0);}to{transform:translateX(200px);}}/* ❌ Triggers repaint on every frame */@keyframesfade-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 */@keyframesfade-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 */functionanimate() {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
Tool / Panel
How to open
What to look for
Performance panel
F12 → Performance → Record
Purple "Layout" bars and green "Paint" bars in the main thread flamechart. Any bar taking more than 16ms in a frame is a jank source. Look for repeated purple+green pairs triggered by JS.
Rendering → Paint Flashing
F12 → ⋮ → Rendering → Paint flashing
Green overlay shows any area that was repainted. Should only flash when content genuinely changes. Constant flashing on scroll or hover means expensive repaints on a non-composited layer.
Rendering → Layer Borders
F12 → ⋮ → Rendering → Layer borders
Orange borders = composited layer boundaries. Each orange border is a GPU-promoted layer. Check that will-change and 3D transforms are creating the expected layers — and not too many.
Layers panel
F12 → ⋮ → More tools → Layers
3D visualisation of compositor layers. Each layer shows its memory usage. Too many layers or unexpectedly large layers indicate over-promotion. Hover a layer to see why it was promoted.
Rendering → FPS meter
F12 → ⋮ → Rendering → Frame Rendering Stats
Live FPS counter and GPU memory overlay. Aim for 60fps during animation. Drops below 30fps are visibly jerky.
Performance Insights
F12 → Performance Insights
Guided analysis — flags Long Animation Frames, layout shifts, and render-blocking resources with specific recommendations. Faster than reading raw flamecharts.
coverage tab
F12 → ⋮ → More tools → Coverage
Shows what percentage of loaded CSS is actually used. Unused CSS still gets parsed, increasing style recalculation cost. High unused CSS % suggests code-splitting opportunity.
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.