Performance

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)
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 */ @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

Tool / PanelHow to openWhat 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

ConceptKey point
Rendering pipelineStyle → 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 threadtransform 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-changeHint 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.
containIsolates 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: autoSkips 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 thrashInterleaving 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 workflowPerformance 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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.