Paint, Layers, and GPU Compositing
From Geometry to Pixels
After layout, the browser knows where every element goes and how big it is. But it still hasn't drawn a single pixel. The next stages — paint and compositing — are where the render tree becomes the actual image on your screen. Understanding the boundary between paint and compositing is the single most impactful performance insight for frontend engineers.
Think of the browser as a painter working on a multi-layered canvas. First, the painter creates paint instructions — a list like "draw a blue rectangle here, then white text there, then a shadow behind this box." Then the painter works on separate transparent sheets (layers). Background on one sheet, content on another, a fixed header on a third. Finally, all sheets are stacked and photographed together (composited) into the final image. The key insight: if only one sheet changes (say, the fixed header moves), you only repaint that one sheet and re-photograph. You don't repaint the entire canvas.
The Paint Phase
Paint (also called rasterization) converts the render tree's geometric boxes into actual pixel colors. The browser creates paint records — an ordered list of drawing operations:
Paint Records for a button:
1. drawRect(x: 100, y: 200, w: 120, h: 40, fill: #3b82f6, radius: 8)
2. drawShadow(x: 100, y: 200, w: 120, h: 40, blur: 4, color: rgba(0,0,0,0.1))
3. drawText("Submit", x: 130, y: 225, font: Inter 14px, color: #ffffff)
Paint records are generated in stacking context order — backgrounds first, then floats, then in-flow content, then positioned elements. The order matters because later operations paint over earlier ones.
What Triggers a Repaint?
Any visual change that doesn't affect geometry triggers paint without layout:
/* These trigger Paint + Composite (no layout) */
.element {
color: red; /* Text color change */
background-color: blue; /* Background change */
box-shadow: 0 2px 4px; /* Shadow change */
border-color: green; /* Border color change */
outline: 2px solid; /* Outline change */
visibility: hidden; /* Visibility toggle */
}
Paint Layers
The browser does not paint the entire page onto a single surface. It divides the page into layers (also called compositing layers or paint layers), each of which is rasterized independently.
Why Layers Exist
Layers exist for one reason: to avoid repainting the entire page when only part of it changes. If a fixed header has its own layer, scrolling the page below it requires zero repainting of the header — the compositor just moves the page layer while keeping the header layer stationary.
What Creates a New Layer?
The browser creates layers based on specific triggers (called "compositing reasons" in Chrome DevTools):
/* Properties that promote to their own layer */
.element {
transform: translateZ(0); /* 3D transform — forces layer */
will-change: transform; /* Hint to browser — creates layer */
position: fixed; /* Fixed elements often get layers */
position: sticky; /* Sticky elements during scroll */
opacity: 0.5; /* Opacity < 1 when animated */
filter: blur(4px); /* Filters promote to a layer */
}
/* video, canvas, and WebGL elements always get layers */
Every layer costs GPU memory. A 1000x1000px layer at 2x device pixel ratio consumes approximately 8MB of GPU memory (1000 * 2 * 1000 * 2 * 4 bytes RGBA). Promoting too many elements to layers can cause worse performance than the repaints you're trying to avoid. On mobile devices with limited GPU memory, excessive layers cause the browser to thrash — repeatedly uploading and evicting textures. Only promote elements that actually animate.
The Compositor Thread
This is where the real performance magic happens. Modern browsers have a compositor thread separate from the main thread. The compositor:
- Takes the painted layers (as GPU textures)
- Applies compositor-only operations (transform, opacity)
- Combines layers into the final frame
- Sends the frame to the GPU for display
The critical insight: the compositor runs on a separate thread from JavaScript. Even if JavaScript is running a heavy computation on the main thread, compositor-only animations keep running smoothly at 60fps.
Main Thread: [JS execution...............][Layout][Paint]
Compositor: [compositeFrame][compositeFrame][compositeFrame]
↑ These run independently — no jank
The CSS Property Cost Table
This is the most practical section in the entire rendering pipeline module. Every CSS property change triggers one of three cost levels:
Composite Only (CHEAPEST)
These properties are handled entirely by the compositor thread. No layout. No paint. No main thread involvement.
/* transform — position, scale, rotate without layout/paint */
.animate { transform: translateX(100px); }
.animate { transform: scale(1.2); }
.animate { transform: rotate(45deg); }
/* opacity — transparency without repaint */
.animate { opacity: 0.5; }
Cost: ~0.1ms per frame. Can run at 120fps.
These are the only two properties you should animate in production. Everything else is a compromise.
Paint + Composite (MODERATE)
These properties require repainting the affected area but do not trigger layout recalculation.
/* Visual-only changes — repaint but no layout */
.change { color: red; }
.change { background-color: blue; }
.change { box-shadow: 0 4px 8px rgba(0,0,0,0.2); }
.change { border-color: green; }
.change { background-image: url(new.jpg); }
.change { outline: 2px solid red; }
.change { text-decoration: underline; }
Cost: ~1-4ms per frame depending on area size. 60fps possible but tight.
Layout + Paint + Composite (MOST EXPENSIVE)
These properties change element geometry, triggering layout recalculation that can cascade through the tree, followed by repaint and composite.
/* Geometric changes — full pipeline */
.change { width: 200px; }
.change { height: 100px; }
.change { top: 50px; }
.change { left: 100px; }
.change { margin: 20px; }
.change { padding: 16px; }
.change { border-width: 2px; }
.change { font-size: 18px; }
.change { line-height: 1.6; }
.change { text-align: center; }
.change { display: flex; }
.change { float: left; }
.change { position: absolute; }
Cost: ~4-16ms+ per frame. Often breaks 60fps budget (16.6ms).
The Transform vs Top/Left Comparison
This is the most common real-world performance decision. Moving an element:
/* BAD: Animating top/left triggers Layout + Paint + Composite every frame */
@keyframes slide-bad {
from { top: 0; left: 0; }
to { top: 100px; left: 200px; }
}
.element {
position: absolute;
animation: slide-bad 1s;
}
/* GOOD: Animating transform is Composite only — 60x cheaper */
@keyframes slide-good {
from { transform: translate(0, 0); }
to { transform: translate(200px, 100px); }
}
.element {
animation: slide-good 1s;
}
With top/left, every frame the browser must: recalculate layout (the element moved, do siblings need to adjust?), repaint the old and new positions, and composite.
With transform, the browser just tells the GPU: "take this texture and move it 200px right and 100px down." No layout. No repaint. The main thread isn't even involved.
Why transform doesn't trigger layout
When you use transform, the element's position in the document flow doesn't actually change. Its "layout box" stays where it was originally placed. The transform is applied as a visual-only operation on the compositor layer — it's like moving a sticker on glass without changing what's underneath. This is why transform: translateX(100px) doesn't push sibling elements aside, while left: 100px (on a relatively positioned element) would still occupy its original space too — but the key difference is that transform never asks the layout engine to recalculate, while left on an absolutely positioned element triggers layout because position values participate in containing block calculations.
will-change: The Performance Hint
will-change tells the browser to prepare for an upcoming change by promoting the element to its own layer before the animation starts:
/* Tell the browser this element will animate transform */
.card:hover {
will-change: transform;
}
.card:hover .card-image {
transform: scale(1.05);
}
/* After animation, remove will-change to free GPU memory */
Do not apply will-change to many elements or leave it on permanently. Each will-change: transform creates a new compositor layer, consuming GPU memory. Use it only when you've measured a jank problem, apply it just before the animation starts (e.g., on :hover of a parent), and remove it when the animation completes. Never do .everything { will-change: transform; }.
Production Scenario: The 300-Layer Mobile Page
A marketing page had smooth desktop animations but dropped to 8fps on mobile. Chrome DevTools Layers panel revealed 300+ compositor layers. The cause:
- Every product card had
will-change: transformset permanently in CSS - Each card had a
box-shadowtransition that promoted to a layer - 50 cards visible at once = 100+ layers (card + shadow for each)
- On a phone with 512MB GPU memory, the browser couldn't hold all textures simultaneously
The fix:
- Removed permanent
will-change. Applied it only on:hover/:focus - Replaced animated
box-shadowwith::afterpseudo-element technique — animate the opacity of a pre-painted shadow instead of the shadow itself - Used
content-visibility: autoon off-screen cards to skip their rendering entirely
Result: Layers dropped from 300+ to 12. Mobile went from 8fps to 60fps.
| What developers do | What they should do |
|---|---|
| Animate width/height for resize effects width/height triggers layout every frame. scale() is compositor-only — runs on GPU at zero layout cost. | Use transform: scale() for visual resize, width/height only for final state |
| Apply will-change to many elements permanently Each will-change: transform creates a GPU layer (~4-8MB each). 50 permanent layers can exhaust mobile GPU memory. | Apply will-change just before animation starts, remove after |
| Animate box-shadow directly box-shadow changes trigger repaint of the shadow area. Opacity changes on a promoted layer are compositor-only. | Pre-paint the shadow on a pseudo-element and animate its opacity |
| Assume more layers = better performance Layers cost GPU memory. Too many layers cause texture thrashing — worse than the repaints you avoided. | Minimize layers. Only promote elements that actually animate. |
- 1Only two CSS properties are compositor-only: transform and opacity. Prefer these for all animations.
- 2Compositor-only properties run on a separate thread from JavaScript — they won't jank even during heavy computation.
- 3Layout-triggering properties (width, height, top, left, margin, padding, font-size) are the most expensive to animate.
- 4Paint-only properties (color, background, box-shadow, border-color) are moderate cost — avoid animating large areas.
- 5will-change promotes elements to GPU layers. Use sparingly: apply before animation, remove after.
- 6Every layer costs ~4-8MB of GPU memory. Excessive layers cause texture thrashing on mobile devices.