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. It is a core concern of Compositing and GPU Acceleration: get promotion right and the rest of that areaβs techniques fall into place.
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. Promotion churn driven by scrolling shows up the same way β keep scroll work composited via Scroll and Input Performance.
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. The translateZ(0) idiom in particular is easy to over-apply β Promoting Layers Safely with translateZ walks through scoping the hack so it forces a layer without leaking one texture per repeated element.
/* β
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, and for driving those properties without re-triggering layout, Animation Performance Patterns.
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% |
Track these continuously rather than spot-checking; Rendering Performance Metrics and Tooling covers capturing layer counts and frame timing in CI. 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.
In This Section
- Promoting Layers Safely with translateZ β scope the
translateZ(0)hack so it forces one layer without leaking a texture per element. - Fixing z-index stacking context bugs β untangle depth-ordering regressions caused by unintended stacking contexts.
What Actually Forces a Compositor Layer
An element gets its own compositor layer only when the engine decides it needs one, and the triggers fall into two categories. Explicit hints are the ones you write: will-change: transform (or opacity), a 3D transform such as translateZ(0) or translate3d(0,0,0), and β historically β backface-visibility: hidden. Implicit promotions are the ones the engine derives from what an element does: a <video> or <canvas>, an element running a compositor-driven transform/opacity animation, a position: fixed element, an element with CSS filter, and an element that must be composited because it overlaps another composited element. That last one β overlap-driven promotion β is the source of most surprise layers: promoting one element can force the browser to promote everything painted on top of it, because the compositor must preserve paint order.
Knowing the trigger list matters because the difference between a layer you intended and a layer you accidentally created is often a single overlapping element or a stray will-change in a shared rule. The DevTools Layers panel records the compositing reason for every layer, which is the fastest way to distinguish an explicit promotion from an implicit one you did not expect. When layer count balloons, the reason column almost always points at either an over-broad will-change selector or an overlap cascade, and the fix follows directly from which one it is. Safe, deliberate promotion β narrow and temporary β is the subject of promoting layers safely with translateZ.
Stacking Contexts Are Not Layers
A persistent source of confusion is the relationship between stacking contexts and compositor layers: they are related but not the same thing. A stacking context is a paint-order concept β it determines how z-index values are resolved and which elements paint in front of which. A compositor layer is a memory-and-threading concept β a texture the compositor can transform independently. Many properties that create a stacking context (opacity less than 1, transform, filter, will-change on certain properties, position with a z-index) also tend to promote to a layer, which is why the two get conflated, but you can have a stacking context with no layer and, less commonly, reasoning about one when you mean the other leads to bugs.
The practical consequence shows up in z-index debugging. When an element refuses to sit above another despite a higher z-index, the cause is almost always that the two are in different stacking contexts β a parent established a new context with transform or opacity, trapping the childβs z-index inside it. Fixing it means understanding the stacking-context tree, not adding more z-index, and the same properties that created the context may also have spawned a layer that is now costing memory. Untangling these is covered in fixing z-index stacking context bugs; the key discipline is to create stacking contexts and layers deliberately, not as accidental side effects of a visual tweak.
/* opacity < 1 creates a stacking context AND typically a layer.
The child's z-index is now resolved *inside* .card, not against the page. */
.card { opacity: 0.99; } /* accidental stacking context + layer */
.card__badge { z-index: 9999; } /* still trapped beneath a sibling of .card */
The Cost Side of Promotion
Every layer buys independent compositing at the price of GPU texture memory and compositor-tree traversal, so the discipline is to promote the smallest element that needs to move and to promote it only while it is moving. Promoting a large container when a small child animates rasterizes the whole container into a texture β often megabytes β when promoting the child would cost a fraction. Leaving will-change set permanently keeps the texture resident forever, stealing budget from content that needs it; the hint should be added just before an animation starts and removed when it settles, so the layer exists only for the duration of the motion. Over-promotion does not announce itself as an error β the page still renders β which is why it has to be caught by inspecting the Layers panel, where an unexpected layer or an oversized texture is immediately visible. The GPU-memory ceiling that punishes over-promotion is the subject of hardware acceleration limits.
A Promotion Checklist
Before adding a promotion hint, a short checklist keeps a layer intentional rather than accidental. Confirm the element actually animates a compositor property (transform or opacity) β if it does not, a layer buys nothing and only costs memory. Promote the moving element itself, not a wrapper, so the texture is as small as the motion requires. Prefer will-change: transform added dynamically right before the animation and removed when it settles, over a permanent translateZ(0) that keeps the texture resident for the life of the page. Check the Layers panel afterward to confirm you created exactly one layer and not a cascade of overlap-promoted siblings, and note its memory cost against the deviceβs budget. Finally, verify in a Performance trace that the animation now shows Composite Layers on the Compositor track with no Layout or Paint on the Main track β the proof that the promotion moved the work where you intended.
The failure this checklist prevents is the most common one: reaching for will-change as a reflexive fix for jank that is actually caused by a forced reflow or an expensive paint upstream. Compositing hides a symptom, but if the underlying cost is a synchronous layout or a wide blur, the layer only relocates it, and now you are paying texture memory on top. The order that works is to fix layout and paint cost first β using the techniques in layout and paint optimization β and reach for a compositor layer only once the remaining cost is genuinely the per-frame transform of a specific moving element. Deliberate promotion, verified in the Layers panel and a trace, is what keeps the GPU an asset rather than a liability. It is also worth revisiting promotions periodically, because a layer that was justified when it was added can outlive its animation β a will-change left on a component after its entrance transition, or a translateZ(0) hack copied from an old fix whose original cause is long gone. Auditing the Layers panel on a mature page routinely surfaces a handful of these stale layers, each quietly holding texture memory for motion that no longer happens; removing them frees budget for the elements that genuinely need it, with no visible change to the page. On memory-constrained mobile devices, where the texture budget is smallest and eviction stalls are most noticeable, that reclaimed headroom is frequently the difference between a scroll that holds its frame rate and one that checkerboards as the compositor waits on evicted textures to re-raster.
Frequently Asked Questions
What actually triggers a compositor layer promotion?
A promotion happens when the compositor decides an element needs its own GPU texture: an animated transform or opacity, will-change: transform, the transform: translateZ(0) idiom, position: fixed, and elements like <video> or <canvas>. Each trigger allocates a texture, so promote only elements that genuinely undergo frequent geometric or opacity changes.
Why does the first frame after promotion drop even though transforms are cheap?
The transform itself is cheap, but the compositor must rasterize the layerβs content into a texture the first time. Without will-change telling it to rasterize in advance, that raster cost is paid synchronously on the frame that promotes the element. In a trace this shows up as a rasterization spike on the compositor thread, not as extra main-thread work.
How many compositor layers is too many?
Aim for under 100 active layers per viewport on mobile and keep UpdateLayerTree under 4ms. Beyond that, compositor tree reconciliation per frame and GPU texture memory become the bottleneck, and on mobile GPUs with 256β512MB VRAM budgets you start hitting texture eviction, which blocks frame submission.
How do I release a GPU texture once an element stops animating?
Set will-change: auto (or remove the property) to demote the element and free its texture. Driving that from an IntersectionObserver so only visible elements stay promoted keeps the texture pool bounded and can reclaim 15β30MB when several animated components cycle in and out of view.
Does a promotion strategy that works in Chrome work everywhere?
No. Blink, WebKit, and Gecko use different compositing models β WebKitβs GraphicsLayer on Safari iOS and WebRender on Firefox promote and rasterize differently from desktop Chrome. Validate the same budget on at least one mid-tier Android device and an iPhone before considering the optimisation complete.
Related Guides
- Promoting Layers Safely with translateZ β the disciplined way to force a layer without exhausting VRAM.
- Fixing z-index stacking context bugs β resolve depth-ordering bugs from unintended promotion.
- Transform and Opacity Best Practices β the properties that stay compositor-only.
- Animation Performance Patterns β drive promoted layers without re-triggering layout.
- Compositing and GPU Acceleration β the parent guide tying these techniques together.