Layer Promotion and Composition
What Layer Promotion Is and When It Goes Wrong
Layer promotion isolates a DOM element into its own GPU-backed compositing layer. The compositor thread can then update that layerβs position, scale, or opacity without touching the main thread. This is the mechanism that makes CSS transform and opacity animations smooth even when the main thread is busy.
The problem is cost. Each promoted layer requires:
- A GPU texture allocation (backed by VRAM or shared memory).
- Re-rasterization whenever the layerβs content changes (not just its transform).
- Compositor tree reconciliation every frame to merge all layers into the final output.
Excessive promotion β caused by indiscriminate will-change, stacking context explosions, or unbounded transform: translateZ(0) patterns β exhausts VRAM, inflates compositor thread work, and triggers texture eviction. Once the GPU memory pool fills, new allocations block frame submission. See Compositing and GPU Acceleration for the broader architectural context.
Trace Analysis
Use Chrome DevTools to find compositor bottlenecks:
- Open the Performance panel. Enable Layers and Paint in the Capture settings.
- Record a 5-second interaction trace.
- In the Layers panel, map layer boundaries and look for unexpectedly large or numerous promoted layers.
- Filter the Main thread for
UpdateLayerTree. Spikes above 8ms indicate the layer tree is being reconciled after a change that promoted or demoted layers mid-frame.
[Frame #842] Budget: 16.67ms | Actual: 24.1ms β DROPPED
ββ Main Thread (11.4ms)
β ββ Layout (3.8ms)
β ββ Script Evaluation (7.6ms)
ββ Compositor Thread (12.7ms)
ββ Rasterize Layer #overlay-bg (9.2ms) β newly promoted, not pre-rasterized
ββ Composite Layers (3.5ms)
The 9.2ms rasterization spike is from a newly promoted layer that the compositor had not pre-rasterized. will-change: transform on an element tells the browser to rasterize it in advance; omitting the hint means the first frame after promotion pays the full rasterization cost synchronously.
Mitigation
Reserve will-change and transform: translateZ(0) for elements that genuinely undergo frequent geometric or opacity transitions. Promote too broadly and you lose the benefit; promote nothing and smooth animations require main-thread help.
/* β
Correct: promotes only the element that will animate */
.promoted-element {
will-change: transform, opacity;
}
/* β Incorrect: will-change on layout-triggering properties defeats the purpose */
.promoted-element.invalid {
will-change: width, top; /* forces main-thread layout; can't be compositor-only */
}
Manage promotion lifecycle with IntersectionObserver to limit active GPU textures to visible elements:
const element = document.querySelector('.promoted-element')
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
element.style.willChange = 'transform, opacity'
} else {
element.style.willChange = 'auto' // release GPU texture
}
})
},
{ threshold: 0.1 },
)
observer.observe(element)
Setting will-change: auto demotes the element and releases its GPU texture. On mobile GPUs with 256β512MB VRAM budgets, this approach can reduce compositor memory by 15β30MB when several animated components are cycling in and out of view.
For preventing unintended stacking context promotion and the layer tree fragmentation that causes depth-ordering bugs, see Fixing z-index stacking context bugs. For the safe properties to animate to stay on the compositor thread, see Transform and Opacity Best Practices.
Validation
// Monitor for long tasks that indicate compositor overload
const perfObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long task: ${entry.duration.toFixed(2)}ms`)
}
}
})
perfObserver.observe({ type: 'longtask', buffered: true })
// Frame timing for real-time budget monitoring
let lastFrameTime = performance.now()
function monitorFrameBudget() {
const now = performance.now()
const delta = now - lastFrameTime
if (delta > 16.67) {
console.warn(`Frame drop: ${delta.toFixed(2)}ms`)
}
lastFrameTime = now
requestAnimationFrame(monitorFrameBudget)
}
requestAnimationFrame(monitorFrameBudget)
| Metric | Target |
|---|---|
| Active compositor layers | < 100 per viewport on mobile |
UpdateLayerTree duration |
< 4ms |
| GPU texture memory | < 256MB on mid-tier devices |
| Frame drop rate (10s scroll) | < 2% |
Cross-device profiling is essential. Compositor behaviour varies between Blink, WebKit, and Gecko. A promotion strategy that works on desktop Chrome can fail on Safari iOS (which uses WebKitβs GraphicsLayer model) or Firefox (WebRender). Validate on at least one mid-tier Android device and an iPhone before considering a compositing optimisation complete.