Compositing and GPU Acceleration
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.
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.
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.
// ❌ 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.
Worker-Based Rendering
When heavy pixel manipulation cannot be expressed in CSS, 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.
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.
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.
| Metric | Target |
|---|---|
| INP (p75) | < 200ms |
| TBT | < 200ms |
| Dropped frames during continuous scroll | < 2% |