Promoting Layers Safely with translateZ
An animated element still janks at 30fps even though you added transform: translateZ(0) to force it onto the GPU β the culprit is a compositing-phase decision that either never happened or promoted far more of the tree than you intended.
This guide sits under Layer Promotion and Composition, part of the broader Compositing and GPU Acceleration area. The translateZ(0) and will-change hints are the two levers you have for telling Blinkβs compositor to give an element its own texture. Used with a scalpel they move an animation off the main thread; used with a shovel they flood the GPU process with backing stores and make the page slower than it was before you touched it.
The Symptom and a Minimal Reproduction
The classic report: βI promoted the element, and it is still janky β or worse, scrolling got choppy everywhere.β Two failure modes hide behind one hint. Either the promotion did nothing because the property was overridden or coalesced away, or the promotion succeeded but dragged a large subtree into a single oversized layer that the compositor must re-raster on every paint.
Here is the smallest reproduction that promotes far too much. A long feed applies the hint to the scroll container instead of to the one moving badge inside it:
<div class="feed"> <!-- 4,000px tall, thousands of DOM nodes -->
<article class="card">β¦</article>
<article class="card">β¦</article>
<!-- β¦400 more cardsβ¦ -->
<span class="live-badge">LIVE</span> <!-- the only thing that animates -->
</div>
<style>
.feed {
transform: translateZ(0); /* BAD: promotes the entire 4,000px feed into one texture */
}
.live-badge {
animation: pulse 1s infinite;
}
@keyframes pulse { 50% { transform: scale(1.2); } }
</style>
The hint on .feed allocates one backing store sized to the whole container. On a 2x display a 1200Γ4000 CSS-pixel layer is 1200 Γ 4000 Γ 4 Γ 4 bytes β 76 MB of GPU memory for a single element, and any repaint inside the feed re-uploads a slice of that giant texture. The badge that actually needed promotion is still riding along inside its parentβs layer.
How translateZ(0) Promotes a Layer
When style resolution finishes, Blink builds a layer tree. The compositor walks the render tree and asks, for each element, βdoes this need its own GraphicsLayer?β transform: translateZ(0) and will-change: transform both answer yes: they set a compositing reason (kTransform3D / kWillChangeTransform) on the elementβs PaintLayer, and the CompositingReasonFinder promotes it. That elementβs pixels are rasterized once into a texture that lives in the GPU process. From then on the compositor thread can transform that texture β translate, scale, change opacity β without waking the main thread, which is exactly why a promoted transform animation survives a busy main thread.
The critical distinction between the two hints is lifetime. translateZ(0) is a real, static transform value, so the layer is allocated the moment the style applies and is never released while the rule matches. will-change: transform is an advisory hint: the browser may allocate eagerly and, in current Chrome, also holds the backing store for the propertyβs lifetime β which is why applying either one statically in a stylesheet leaks a texture for the whole page lifetime, the failure documented in when to use will-change without memory leaks.
Both hints feed the same compositing-reason machinery β the layer-tree data structure the compositor rebuilds when reasons change. The difference that matters for you is scope: the reason attaches to one PaintLayer, so whatever element carries the property is exactly what gets its own texture. Put it on the container and the container is the layer.
Why Over-Promotion Shows Up as GPU Memory Pressure
Every promoted layer is a texture, and textures draw from a bounded pool in the GPU process. Promote a few small elements and the cost is trivial; promote a large element, or hundreds of small ones, and you cross into the eviction and readback behavior detailed in GPU memory limits in Chrome compositing. The memory of a layer is width Γ height Γ devicePixelRatioΒ² Γ 4 bytes, so promotion cost scales with area, not with how visually small the moving part is.
The .feed example is the trap in miniature: one hint on a 4000px container costs orders of magnitude more than the same hint on a 40px badge, and it buys you nothing because the badge still shares its parentβs layer until it is promoted. The diagram below contrasts the two layouts by memory footprint.
There is a second, quieter cost. When the compositor promotes an element mid-scroll or mid-interaction it must rebuild the layer tree and re-raster, and a very large new layer can stall a frame while its texture uploads. That is why a blanket transform: translateZ(0) on wrappers βto be safeβ often introduces the jank it was meant to cure. The rule of thumb: promote the smallest box that actually moves, and only while it moves.
| Pipeline phase | Constraint | Cost of over-promotion |
|---|---|---|
| Compositing (layer tree) | one reason per PaintLayer | large layer = large texture |
| Raster | area Γ dprΒ² Γ 4 bytes | 76 MB for a 4000px container |
| GPU process | bounded texture pool | eviction, readback, OOM on mobile |
| Per-frame | upload on first raster | frame stall when layer appears |
Reading the Layer in a DevTools Trace
Confirm what actually got promoted before and after your change. In DevTools open the Layers panel (or enable Rendering β Layer borders) to see every compositing layer, its size, its memory estimate, and its compositing reason. A Performance recording of the animation shows whether frames are handled entirely on the compositor thread (green, no main-thread raster) or are bouncing back to the main thread each frame. The over-promoted feed produces a trace like this:
Layers panel β before fix ββββββββββββββββββββββββββββββββ
#document
ββ div.feed [COMPOSITED]
ββ size: 1200 x 4000
ββ memory estimate: 76.8 MB β one giant texture
ββ compositing reason: "Has a 3D transform: translateZ"
ββ paint count: 214 β re-rasters on every feed change
Performance β pulse animation βββββββββββββββββββββββββββ
Frame 41 βΈ Composite Layers 0.4 ms
βΈ Raster 11.9 ms β main thread, feed texture
βΈ dropped β over budget (>16.6 ms)
After promoting only the badge, the giant layer disappears and the animation runs off-thread:
Layers panel β after fix βββββββββββββββββββββββββββββββββ
#document
ββ div.feed [not composited]
ββ span.live-badge [COMPOSITED]
ββ size: 40 x 20
ββ memory estimate: 12.8 KB β scalpel, not shovel
ββ compositing reason: "will-change: transform"
Performance β pulse animation βββββββββββββββββββββββββββ
Frame 41 βΈ Composite Layers 0.3 ms β compositor thread only
(no Raster, no main-thread work)
The decision of whether to promote at all is worth codifying, because promotion is only free when the element genuinely animates a compositable property. The tree below is the check to run before adding either hint.
The Fix: Promote Narrowly, Toggle Dynamically
The correct pattern promotes only the animated element and treats promotion as a state, not a permanent decoration. Add the hint when the animation is about to run and drop it when the animation ends so the texture is released back to the pool. Prefer will-change: transform toggled from JavaScript over a static translateZ(0), because you can remove will-change cleanly whereas a translateZ(0) in a matched rule stays for as long as the rule matches.
<!-- BEFORE: static promotion on the wrong element -->
<style>
.feed { transform: translateZ(0); } /* promotes 76 MB, never releases */
.live-badge { animation: pulse 1s infinite; }
</style>
<!-- AFTER: narrow, dynamic promotion on the element that moves -->
<span class="live-badge">LIVE</span>
<style>
/* no static promotion β the badge shares its parent layer at rest */
.live-badge.animating {
will-change: transform; /* promotes only the 40x20 badge, ~13 KB */
}
</style>
<script>
const badge = document.querySelector('.live-badge');
function startPulse() {
badge.classList.add('animating'); // sets compositing reason -> promote
const anim = badge.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.2)' }, { transform: 'scale(1)' }],
{ duration: 1000, iterations: 6 }
);
anim.finished.then(() => {
badge.classList.remove('animating'); // clears reason -> texture released
});
}
// Gate on visibility so offscreen badges never hold a texture.
new IntersectionObserver(([e]) => {
if (e.isIntersecting) startPulse();
}, { threshold: 0.1 }).observe(badge);
</script>
The browser now handles this differently in two ways. First, the compositing reason lives on span.live-badge, so the promoted layer is 40Γ20, not 1200Γ4000 β the CompositingReasonFinder promotes exactly one small PaintLayer. Second, removing the class clears the reason, and Chrome releases the backing store on the next compositing update, so an idle badge costs nothing. The animation itself runs on compositable properties (transform), so once promoted it never returns to the main thread β the same principle behind animating transforms without layout thrash. If you find promotion silently doing nothing, check that a parent has not already created a stacking context that swallows the element, a class of bug covered in fixing z-index stacking context bugs.
Verification Checklist
Frequently Asked Questions
Is translateZ(0) or will-change transform the better promotion hint?
Prefer will-change: transform toggled dynamically. Both set the same compositing reason and produce the same GPU-backed layer, but will-change is designed to be added and removed, letting you release the texture when the animation ends. A transform: translateZ(0) in a matched CSS rule holds its backing store for as long as the rule applies, which is why it so often becomes a static, page-lifetime leak.
Why did adding translateZ(0) make my page slower instead of smoother?
You almost certainly promoted too large an element. Promotion cost scales with layer area β width Γ height Γ devicePixelRatioΒ² Γ 4 bytes β so a hint on a tall container allocates a huge texture and re-rasters it on every change inside it. Move the hint to the smallest box that actually animates and the cost collapses.
How do I confirm an element was actually promoted?
Open the DevTools Layers panel or enable Rendering, then Layer borders. A promoted element appears as its own layer with a size, a memory estimate, and a listed compositing reason such as βHas a 3D transformβ or βwill-change: transformβ. If it is not listed, a parent stacking context or an overridden property likely prevented promotion.
Does removing will-change actually free the GPU memory?
Yes. When the compositing reason is cleared β by removing the will-change declaration or the class that carries it β Chrome drops the layer on the next compositing update and returns its backing store to the texture pool. You can watch GPU memory fall back to baseline in the DevTools performance memory track after the class is removed.
Related Guides
- Layer Promotion and Composition β the parent overview of when and why the compositor gives an element its own texture.
- When to Use will-change Without Memory Leaks β the leak that static promotion hints cause and how to scope them.
- GPU Memory Limits in Chrome Compositing β the bounded texture pool that over-promotion exhausts.
- Fixing z-index Stacking Context Bugs β why a parent stacking context can silently swallow the layer you tried to promote.