Compositing and GPU Acceleration
This section covers the final stages of the browser rendering pipeline — where painted content is split into layers, rasterized into GPU textures, and assembled into frames. Getting this phase right is what keeps animation and scrolling at the full 16.6ms frame budget even while the main thread is busy. It is the compositing stage of the wider browser rendering pipeline, picking up where layout and paint hand off a painted display list.
The Role of the Compositor Thread
The browser rendering pipeline assigns different work to different threads. The main thread handles DOM mutations, style resolution, layout, and paint record generation. The compositor thread takes those paint records, rasterizes them into GPU textures, and submits finished frames to the display. When an element is promoted to an independent compositing layer, subsequent changes to transform or opacity can be applied by the compositor thread directly — without any involvement from the main thread.
This separation is the foundation of smooth animation. At 60Hz the compositor has 16.6ms to deliver each frame. When the main thread is occupied with JavaScript or layout work, the compositor can still continue scrolling and running transitions on promoted elements, keeping the frame delivery cadence intact. The diagram below contrasts the two update paths: a compositor-only property short-circuits the pipeline, while a layout-affecting property drags every downstream stage back onto the main thread.
Blink implements this via the cc (Chromium Compositor) pipeline. WebKit uses GraphicsLayer trees. Gecko uses WebRender, which batches draw calls into a scene graph processed entirely on the GPU thread.
// ❌ Animating 'left' triggers layout on every frame
// main-thread cost: ~8–12ms on mid-tier devices
function animateWithLayout(element, progress) {
element.style.left = `${progress * 100}px`
}
// ✅ Animating 'transform' stays on the compositor thread
// compositor cost: <1ms, main thread stays free
function animateWithTransform(element, progress) {
element.style.transform = `translateX(${progress * 100}px)`
}
In the Chrome Performance panel, the left version produces a Layout and Paint event on the Main thread every frame. The transform version produces only a Composite Layers event on the Compositor thread, leaving the main thread completely free for input handling and script execution.
Core Pipeline Stages
The full rendering sequence — DOM/CSSOM construction, style calculation, layout, paint, compositing — still executes for the initial paint and any time a non-compositor property changes. The compositing optimisation applies only to updates that affect properties the compositor can handle independently: currently transform, opacity, and (with caveats) filter. Everything else forces the main thread to re-run at least the paint phase.
For the rules that govern which elements get their own layer, see Layer Promotion and Composition. For the specific reason transform and opacity bypass layout and paint, see Transform and Opacity Best Practices. The layer stack below shows where each pipeline stage lives and which surfaces the compositor keeps resident on the GPU.
Scroll and Input
Scroll events fire at up to 120Hz on modern high-refresh displays, faster than the main thread can reliably process them. Attaching synchronous DOM reads to scroll handlers forces the main thread to perform layout on every event, blocking the compositor. The dedicated guide on scroll and input performance covers passive listeners, content-visibility, and hit-testing cost in depth.
// ❌ Synchronous read inside scroll handler blocks main thread
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect() // forced layout flush
header.style.opacity = 1 - rect.top / 500
})
// ✅ Passive listener signals that preventDefault() will not be called,
// allowing the compositor to proceed without waiting
window.addEventListener('scroll', () => {}, { passive: true })
The { passive: true } option tells the browser that this listener will not call preventDefault(), so the compositor can begin compositing the scroll position update before the main thread finishes handling the event. This eliminates the one-frame input latency penalty that non-passive scroll listeners introduce. The sequence below traces both paths after a wheel event.
Worker-Based Rendering
When heavy pixel manipulation cannot be expressed in CSS, off-main-thread rendering with OffscreenCanvas moves the rasterization work to a dedicated worker thread:
const canvas = document.getElementById('gpu-canvas')
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker('raster-worker.js')
worker.postMessage({ canvas: offscreen }, [offscreen])
// raster-worker.js
self.onmessage = (e) => {
const ctx = e.data.canvas.getContext('2d')
// pixel manipulation runs here, completely off the main thread
}
transferControlToOffscreen hands ownership of the canvas to the worker. All subsequent draw calls happen on the worker thread; the main thread receives no overhead from them. The transfer is one-way — once ownership moves, the main thread can no longer get a rendering context for that canvas.
Hardware Limits and Debugging
Over-promoting elements to compositor layers can exhaust GPU memory and trigger fallback rendering paths. Mobile GPUs commonly cap texture memory at 256–512MB. When that limit is hit, the browser evicts the least-recently-used textures to system RAM and must re-rasterize them on demand, causing frame pacing degradation. For the practical limits and how to stay within them, see Hardware Acceleration Limits. The diagram shows what happens as promoted layers overflow the texture budget.
Frame Budget Monitoring
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long task: ${entry.duration.toFixed(2)}ms`)
}
}
})
observer.observe({ type: 'longtask', buffered: true })
Combine longtask monitoring with Lighthouse CI to catch frame budget regressions. Check chrome://gpu (or its equivalent in other browsers) to confirm hardware acceleration is active on the target device classes your users run. The timeline below shows how a single overlong task consumes the budget of several frames.
| Metric | Target |
|---|---|
| INP (p75) | < 200ms |
| TBT | < 200ms |
| Dropped frames during continuous scroll | < 2% |
The Layer Tree and GPU Memory Budget
Every compositor layer is a texture that lives in GPU memory, and that memory is finite — often a few hundred megabytes shared with every other tab, the browser UI, and the operating system’s own compositor. A layer’s cost is its painted area in device pixels multiplied by four bytes per pixel, so a full-screen layer on a 3× device retina phone is not a rounding error: a 400×800 CSS element at 3× is 1200×2400 device pixels, roughly 11.5MB for a single texture. Promote a dozen of those and you have consumed more than 130MB before any content has scrolled. When the budget is exhausted the engine evicts textures, which forces a re-raster the next time the layer is needed — the stutter you feel when returning to a background tab or scrolling back to content that was discarded.
This is why “just promote everything to a layer” is the wrong instinct. The layer tree is a balance: each layer removes an element from the main-thread paint path but adds a texture to the GPU’s working set and a quad to the compositor’s per-frame assembly. The compositor still has to walk the layer tree, apply each layer’s transform, and composite the quads into the final frame every vsync, so a layer tree with thousands of nodes has its own traversal cost. The right number of layers is the smallest set that isolates the elements that actually animate or scroll independently — the discipline covered in layer promotion and composition and, from the failure side, hardware acceleration limits.
// Estimate the GPU texture cost of the current layer set.
// Layer count and composited memory are exposed in the Layers panel;
// this approximates the per-layer texture footprint for a promoted element.
function textureBytes(el) {
const r = el.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
return Math.ceil(r.width * dpr) * Math.ceil(r.height * dpr) * 4 // RGBA
}
// A single full-viewport promoted element on a 3x phone can exceed 10MB —
// multiply by every element carrying will-change: transform.
const promoted = document.querySelectorAll('[style*="will-change"], .promoted')
const totalMB = [...promoted].reduce((s, el) => s + textureBytes(el), 0) / 1e6
console.log(`~${totalMB.toFixed(1)}MB of compositor textures across ${promoted.length} layers`)
From Painted Layer to Presented Frame
The compositor’s job between paint and pixels is more involved than “copy the texture to the screen.” After the main thread produces a display list for each layer, the layer is divided into tiles — typically 256×256 device pixels — and only the tiles near the viewport are rasterized, so a long scrolling page never pays to raster its full extent at once. Rasterization runs on a dedicated raster thread (or is handed to the GPU process for GPU rasterization), turning the display list’s drawing commands into actual pixels in each tile’s texture. The compositor then assembles a compositor frame: a set of quads, one per visible tile, each with its layer’s transform and opacity applied, and submits that frame to the GPU process, which draws the quads and presents at vsync.
The consequence for performance work is that scroll and transform animation only touch the last two steps — reassembling quads with new offsets — which is why they can run at full frame rate even while the main thread is completely blocked. A new element, a colour change, or anything that dirties a tile, however, re-enters rasterization, and if the dirty tiles are large or use expensive paint operations (wide blurs, large boxshadows, filters) the raster thread can miss its deadline and the compositor submits a frame with missing tiles — the grey checkerboard you occasionally catch during a fast fling. The full raster track and how to read it is the subject of compositor thread and rasterization, and moving the expensive paint work off the critical path is what off-main-thread rendering is for.
When Compositing Backfires
Compositing is a tool with a sharp edge. The three most common ways it makes a page slower are worth naming because each looks like an optimisation. First, layer explosion: applying will-change: transform to a rule that matches hundreds of elements (a list row, a card) tells the engine to promote every match, multiplying texture memory and layer-tree traversal until the GPU evicts textures mid-scroll. Second, permanent promotion: leaving will-change set on an element that only animates occasionally keeps its texture resident forever, stealing budget from content that needs it — the hint should be added right before an animation and removed when it settles. Third, promoting the wrong element: promoting a large container when only a small child moves rasterizes the whole container into a texture, when promoting just the child would have cost a fraction of the memory.
Each of these traces back to the same root cause — treating a layer as free when it is actually a memory allocation with a traversal cost. The diagnostic is always the Layers panel in DevTools: it shows every composited layer, its memory, and the reason the engine promoted it, so a layer you did not intend to create is immediately visible. The safe patterns — narrow selectors, dynamic toggling, and promoting the moving element rather than its container — are detailed in transform and opacity best practices and scroll and input performance.
In This Section
- Compositor Thread and Rasterization — how painted layers become GPU tiles and how to read the raster track.
- Off-Main-Thread Rendering —
OffscreenCanvas, workers, and Houdini paint worklets that protect the frame budget. - Layer Promotion and Composition — the rules that decide which elements get their own layer.
- Transform and Opacity Best Practices — why these two properties bypass layout and paint.
- Animation Performance Patterns — compositor-driven animation techniques that never stall on the main thread.
- Scroll and Input Performance — passive listeners, hit-testing, and keeping scroll on the compositor.
- Hardware Acceleration Limits — GPU memory ceilings and how over-promotion backfires.
A Compositing Workflow in DevTools
Confirming that work is actually on the compositor is a repeatable four-step loop, and it is worth internalising because compositing failures are silent — the page still renders, just on the wrong thread. Start in the Rendering tab and enable Layer borders: every composited layer gets an orange outline, so an element you expected to be promoted but is not shows no border, and a swarm of borders where you expected one reveals layer explosion. Next enable Paint flashing: a purely compositor-driven animation should produce no green repaint rectangles while it runs, because reassembling quads does not repaint; if the element flashes green every frame, it is repainting and the property you are animating is not compositor-only.
Third, open the Layers panel for the ground truth: it lists every layer, its memory footprint in megabytes, and the compositing reason the engine recorded (will-change, transform: translateZ, an animating opacity, a video, and so on). This is where you catch a layer you did not intend and the memory cost of the ones you did. Finally, record a Performance trace and read the two tracks together: a healthy compositor animation shows Composite Layers on the Compositor track with a flat Main track, while a broken one shows Recalculate Style, Layout, or Paint firing on the Main track on every frame.
// Instrument dropped frames so a compositing regression shows up in the field,
// not just in a manual DevTools session. requestAnimationFrame timestamps that
// slip past a vsync interval indicate the compositor missed its deadline.
let last = performance.now()
function frameWatch(now) {
const delta = now - last
last = now
// ~16.6ms is one frame at 60Hz; >32ms means at least one frame was dropped.
if (delta > 32) reportDroppedFrame(delta)
requestAnimationFrame(frameWatch)
}
requestAnimationFrame(frameWatch)
Run this loop before and after any change that promotes a layer or animates a property, and the difference between an optimisation and a regression becomes a measurement rather than a guess. The per-metric field instrumentation that turns these local checks into a monitored budget lives in rendering performance metrics and tooling.
Frequently Asked Questions
Which CSS properties can the compositor animate without the main thread?
Only transform, opacity, and — on engines that support it — filter can be interpolated entirely on the compositor thread. Every other property (left, top, width, height, margin, box-shadow) forces at least a paint, and geometry properties force a full layout, so animating them re-enters the main thread on every frame. See Transform and Opacity Best Practices for the mechanism.
Why does adding more compositor layers sometimes make a page slower?
Each promoted layer needs its own GPU texture, and mobile GPUs commonly cap texture memory at 256 to 512MB. Once the budget is exceeded the browser evicts the least-recently-used textures to system RAM and re-rasterizes them on demand, which shows up as frame-pacing stalls. Promote only the elements that actually animate. Hardware Acceleration Limits covers the ceilings in detail.
What does the passive option on a scroll listener actually change?
Passing { passive: true } promises the browser your listener will never call preventDefault(). With that promise the compositor can scroll immediately instead of waiting to see whether the handler cancels the event, which removes a one-frame input latency penalty on every scroll. The scroll and input performance guide expands on this.
When should I move rendering into a worker with OffscreenCanvas?
Use it when heavy pixel work — a canvas draw loop, a procedurally generated visual, or a data-to-pixels transform — sits inside a long main-thread task and cannot be expressed in CSS. Transferring the canvas with transferControlToOffscreen moves that work off the main thread entirely. It is not free, though: the transfer is one-way and message passing has a cost, so read Off-Main-Thread Rendering for when not to reach for it.
How do I confirm hardware acceleration is actually running?
Open chrome://gpu to see which features are hardware-accelerated on the current device, and record a Performance trace: compositor-only updates should produce a Composite Layers event on the Compositor track with no Layout or Paint on the Main track. If you see per-frame paint for a purely visual change, the element is not on its own layer or you are animating a non-compositor property.
How much GPU memory does a single compositor layer cost?
A layer’s texture is its painted size in device pixels times four bytes (RGBA). A 400×800 CSS element on a 3× display is 1200×2400 device pixels, about 11.5MB. That is why promoting a handful of full-screen elements can consume over 100MB and trigger texture eviction on memory-constrained phones. Estimate the footprint before promoting, and prefer promoting the small element that actually moves over its large container.
Why do I see a grey checkerboard during a fast scroll or fling?
The compositor rasterizes tiles near the viewport, not the whole page. During a fast fling it can outrun the raster thread, so it submits a compositor frame before the newly exposed tiles are painted and fills the gap with a placeholder checkerboard. Reducing per-tile raster cost — narrower blurs, smaller shadows, and content-visibility on off-screen rows — lets raster keep pace. The full mechanism is in compositor thread and rasterization.
Related Guides
- Compositor Thread and Rasterization — trace exactly where raster time goes on long scrolling pages.
- Off-Main-Thread Rendering — keep canvas and generated visuals off the critical path.
- Layout and Paint Optimization — the upstream stages that produce the display list the compositor consumes.
- Rendering Performance Metrics and Tooling — measure INP, TBT, and dropped frames to catch regressions.