Compositing Layers
A layer is a separately rasterised surface the compositor can transform and blend on its own thread — powerful, conditional, and paid for in memory.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What is a compositing layer, what causes one to exist, and what does the compositor get to do with it that the main thread would otherwise have to?
Someone wants a sticky header, a drawer that slides in, and a video that keeps playing while the page scrolls — all of them smooth, none of them stuttering when the application is busy.
The page is one image. The browser paints it, and when something changes it paints it again. Layers are a Photoshop concept that leaked into a devtools panel.
If the page were one surface, scrolling would require the main thread to repaint on every frame — which is exactly what happens in the cases where the compositor cannot handle a scroll, and it is immediately obvious to the user (Scroll and Input Latency).
- If the page were one surface, scrolling would require the main thread to repaint on every frame — which is exactly what happens in the cases where the compositor cannot handle a scroll, and it is immediately obvious to the user (Scroll and Input Latency).
- A video, a canvas and a CSS-animated panel all update on completely different schedules. Forcing them into one surface would mean the slowest of them dictates the frame rate of all of them.
- The single-surface model cannot explain why an animation keeps running perfectly while a long task blocks every click on the page — a discrepancy that misleads people into thinking the page is fine when it is frozen.
- It also cannot explain the opposite failure: a page that becomes *slower* after adding
will-change, which only makes sense once layers have a cost (Layer Explosion).
What is actually happening
In the browser, not in the framework.
- The browser divides the render tree into compositing layers: groups of content that are rasterised into their own bitmap rather than being drawn into a shared one.
- A separate compositor thread holds the layer tree, its bitmaps and their transforms. Producing a frame from an unchanged layer tree means re-drawing existing textures at possibly-new positions and opacities — no style, no layout, no paint, and crucially no main thread.
- Layers exist because something made them necessary. Common triggers: a 3D or
will-change: transformhint, an accelerated CSS animation of transform or opacity,position: fixedor a sticky element in some engines,videoandcanvaselements, scrollable regions, and elements that must be composited because they overlap one that already is. - That last trigger is the one that surprises people: overlap forces promotion. If a composited element is painted below something else, that something else may need its own layer too, to preserve paint order. One promotion can create several.
- Each layer costs memory proportional to its area in device pixels times bytes per pixel, plus the bookkeeping of one more surface to draw and blend per frame. This is why promotion is a budget, not a switch (The Transfer You Forgot to Count).
- The main thread still owns the layer *tree*. It decides what is promoted, records display lists and hands the compositor a commit. The compositor can then produce many frames from one commit — which is the entire source of the asymmetry this module is about.
What this makes the browser do
And which of it is avoidable.
- Deciding, on every style update, which elements need their own layer — a compositing-assignment pass with its own cost that grows with the number of promoted elements.
- Allocating and rasterising a bitmap per layer, tiled for large layers, and uploading those tiles as GPU textures.
- Holding all of that in memory for as long as the layer exists, whether or not it is currently animating.
- Per frame: walking the layer tree, applying each layer's transform and opacity, and drawing them in order — GPU work proportional to layer count and total drawn area, including overdraw where layers overlap.
- Avoidable: layers created by a blanket
will-changethat never animates, and layers that exist only to preserve paint order behind something that could have been positioned to not overlap.
Two threads, one frame
The whole point of compositing is that a frame can be produced without the main thread. The main thread owns the DOM, style, layout, paint recording and your JavaScript. The compositor owns the layer tree and the textures, and it can redraw them at new transforms and opacities on its own schedule.
This is what makes a composited animation survive a busy main thread, and it is also what makes that survival misleading. The page is not fine; the compositor is simply still able to do its job. Understanding which thread is producing your smoothness tells you what your smoothness is evidence of.
What creates a layer, and what it costs
position: fixed and filters under narrower conditions and rations layer memory hard on iOS, while Gecko's WebRender does not maintain a one-texture-per-layer model at all, so "how many layers" is not even the same question in Firefox — use each engine's own layer tooling before concluding anything.Promotion is not something you switch on; it is something the engine concludes. The table below lists the causes that account for most layers in real pages, together with what each one buys and what it charges. Read the last column first — it is the part that gets left out of the advice.
- Overlap-forced promotion is the reason layer counts grow non-linearly: one promoted element under a busy region can produce a handful of layers.
- A layer whose content changes every frame gains nothing from promotion — you have paid for a surface and are still repainting it.
- Demotion matters as much as promotion. A hint left in a stylesheet never demotes.
| Trigger | Why the engine promotes | What it buys | What it costs |
|---|---|---|---|
will-change: transform / opacity | You declared an intent to animate the property | The compositor can animate without the main thread | A bitmap held for as long as the declaration is present — including when nothing is animating |
| A running transform/opacity animation | Promotion for the animation's duration, then demotion | The correct lifecycle, for free | One raster at the start; a possible blank first frame if raster is late |
video, canvas, WebGL | The content updates independently of the document | Independent update rates; no document repaint per video frame | A surface per element, sized to the element |
| Overlap with a composited element | Paint order must be preserved | Correctness — nothing, from your point of view | Additional layers you did not ask for and will not find without the layers panel |
| Scrollable regions | Scrolling can be handled by the compositor | Scroll that survives a busy main thread (Scroll and Input Latency) | Tiles for the scrollable content, including some off-screen |
position: fixed / sticky | The element must stay put while content moves beneath it | Sticky headers that do not stutter | Engine-dependent; several engines promote only under some conditions |
filter, backdrop-filter, some blend modes | The effect needs an isolated surface to sample | The effect works at all | Extra surfaces plus per-frame effect cost, and backdrop-filter samples what is behind it every frame |
Promote for the interaction, not for the stylesheet
The most common compositing mistake is a permanent hint for a temporary need. will-change written into a class means the layer exists whenever the class does — on every card in a list of two hundred, on every route, for the entire session.
The alternative is to scope the hint to the moment. Add it when the interaction becomes likely, remove it when the animation finishes. The code below is slightly more work and orders of magnitude less memory.
.drawer {
will-change: transform; /* always promoted, always allocated */
transform: translateX(-100%);
transition: transform 220ms;
}
.drawer[data-open] { transform: none; }.drawer {
transform: translateX(-100%);
transition: transform 220ms;
}
.drawer[data-animating] { will-change: transform; }
@media (prefers-reduced-motion: reduce) {
.drawer { transition-duration: 1ms; }
}The hint only has to be present slightly before the transform changes, and only until it settles. Held permanently it is a full-viewport bitmap in GPU memory for the life of the page; held for the interaction it is a bitmap for a fifth of a second. The engine also promotes a running transform transition on its own, so the hint is an optimisation for the first frame, not a requirement.
1function openDrawer(el: HTMLElement) {2 el.dataset.animating = '' // hint: promote now3 requestAnimationFrame(() => { // let the hint take effect first4 el.dataset.open = ''5 })6 el.addEventListener(7 'transitionend',8 () => { delete el.dataset.animating }, // demote: release the layer9 { once: true },10 )11}The transitionend listener is the part people leave out, and it is the part that makes this cheaper rather than merely later. Note also that transitionend does not fire if the transition is interrupted or if reduced-motion has collapsed the duration to nothing — production code needs a timeout fallback, or the hint leaks exactly as before.
How to build it
Most important first.
- Promote deliberately and temporarily. Add the hint when an interaction is about to start, remove it when the animation ends — a layer that exists for the 200ms it is needed costs almost nothing; one that exists for the session costs for the session.
- Let the browser promote for you where it already does: an accelerated CSS animation or transition of
transform/opacityis promoted for its duration and demoted afterwards, which is exactly the lifecycle you want (Cheap and Expensive Animation). - Keep promoted layers small. Layer cost is area in device pixels; a full-viewport promoted overlay on a high-density tablet is a large allocation.
- Avoid accidental overlap promotion by controlling stacking deliberately rather than sprinkling
z-index(Positioning and Stacking Contexts). - Treat the layers panel as the source of truth. Layer creation is heuristic and version-dependent; reasoning from a blog post about compositing triggers is how pages end up with hundreds of surfaces.
- Remember what a layer does *not* fix: it isolates paint, but style and layout are still global work on the main thread (Style Invalidation).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Compositor-driven animation continues while the main thread is blocked. That is a feature for smoothness and a hazard for honesty: a spinner that keeps spinning during a five-second freeze tells every user that the page is working when it is not, and tells a screen-reader user nothing at all.
- Announce state changes through live regions or focus, not through motion. Motion is not perceivable by everyone, and a composited animation is exactly the kind of feedback that survives a freeze while the real status update does not (Live Regions and Announcement).
- A composited overlay or drawer must still manage focus. Sliding a panel in with
transformdoes not move focus into it, and does not stop the content behind it from remaining in the tab order (Focus Management). - Promotion can degrade text rendering. Users with low vision are affected first by antialiasing changes, so check the transformed text, not just the frame rate.
prefers-reduced-motionapplies regardless of how cheap the animation is. Compositor-driven motion is still motion, and vestibular triggers do not care which thread produced them (Contrast, Colour and Motion).
What can go wrong
- Too many layers: memory pressure, longer compositing per frame, and on constrained devices the browser may refuse further promotion or evict tiles, producing blank flashes (Layer Explosion).
- A layer that is repainted every frame anyway — promoting an element whose *content* changes each frame gains nothing, because the expensive part was never the compositing.
- Losing promotion silently. Adding an
overflow: hiddenancestor, afilteron a parent, or an element that now overlaps can change the compositing decision, and a previously smooth animation quietly starts hitting the main thread again. - Text quality regressions: content rasterised into a layer that is then transformed can lose subpixel antialiasing, which is a legibility issue and not merely cosmetic.
- The mitigation failing:
will-changeapplied to a parent to "help" promotes a huge subtree, and the resulting allocation is worse than the repaint it avoided.
- A commit from the main thread and a compositor frame race: the compositor may produce a frame from the previous commit, so a state change and its visual result can be one frame apart.
- Raster of a newly promoted layer can complete after the frame that needed it, producing one frame of blank or stale content at the start of an animation — the reason pre-promotion before an interaction helps.
- A compositor-driven animation and a main-thread mutation of the same property can both be live at once; the winner depends on when the commit lands, which is why mixing the two produces jumps.
- Layers do not create a trust boundary. Content in a promoted layer is in the same document, same origin, same DOM — the isolation is a rendering optimisation, not a sandbox (Origins and the Sandbox).
- A composited overlay can cover a target the user believes they are interacting with. Transparent or nearly-transparent promoted layers over sensitive controls are the classic clickjacking construction, and the defence is frame and interaction policy, not layer hygiene (Clickjacking and Framing).
- Cross-origin iframes get their own compositing surfaces, and in a site-isolated browser their content is rendered in a different process entirely. You cannot read their textures, and that is by design (The Multi-Process Browser).
- GPU memory is a shared, finite resource across the whole browser. Aggressive layer allocation by one page degrades others, which is why engines cap and evict rather than honour every promotion request.
- "Layers make things faster." Layers make *some* changes cheaper by removing them from the main thread, and make everything else slightly more expensive in memory and composite time.
- "
will-changepromotes the element." It is a hint. The engine decides, and it may promote more than you named or nothing at all (Layer Explosion). - "If it is smooth, it must be composited." Plenty of animations are smooth because the page is quiet. The test is whether they stay smooth while the main thread is busy.
- "Layers isolate everything." They isolate paint. Style recalculation and layout are still document-wide main-thread work.
- "More layers means more parallelism." Layers are not threads. They are surfaces drawn by one compositor, and each one adds to the per-frame draw cost.
Measuring it, and what changes in the field
- The Layers panel: the definitive answer to how many layers exist, why each was promoted, and how much memory it occupies.
- Layer borders in the rendering tools, which turn "how many surfaces does this page have" into something you can see while interacting.
- The Performance panel's compositor track: if frames are late and the main thread is idle, the cost is here, not in your code (A Mental Model of the Devtools).
- GPU process memory in the browser's own task manager — the fastest way to catch a promotion that allocated far more than expected.
- In the field you see none of this. It surfaces as dropped frames during interaction and as memory-pressure crashes on low-end devices (Real User Monitoring).
- On a memory-constrained phone, promotion is rationed. The engine may decline to promote, or evict tiles under pressure, so the desktop layer count is not the mobile layer count.
- On a high-density display, the same CSS-pixel layer costs several times the memory. A full-screen layer at 3x is a large allocation by any standard.
- On a device without GPU rasterisation — software rendering, an old driver, a blocklisted GPU — the compositor still exists but the economics shift, and heavily layered pages degrade sharply (CPU or GPU: Two Bets About What Work Looks Like).
- In a long-lived tab, layers created per interaction and never removed accumulate; the page gets heavier the more the user does (Long-Lived Clients and Version Skew).
- Every layer trades main-thread paint work for GPU memory and per-frame compositing work. That trade is excellent for a handful of animating surfaces and terrible at scale.
- Temporary promotion is the right lifecycle and the more complex code: something has to add the hint and something has to remove it, including on the paths where the animation was interrupted.
- Relying on the browser to promote automatically keeps your code simple and hands the decision to a heuristic that changes between browser versions.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALAll modern engines separate a compositor from the main thread and can produce frames from an existing layer tree without re-running style, layout or paint; the architectural claim holds everywhere.
- ENGINE-SPECIFICThe promotion triggers are engine heuristics and differ concretely: Blink maintains a documented and frequently revised list of compositing triggers including overlap-forced promotion, WebKit is markedly more conservative on iOS because layer memory is rationed, and Gecko's WebRender batches primitives rather than maintaining the same one-bitmap-per-layer model — so identical CSS yields different layer counts in each.
- DEVICE-SPECIFICLayer memory is area in device pixels, so a promotion that is free on a 1x desktop display is nine times as expensive on a 3x phone, and engines on constrained devices will decline promotions the desktop engine grants.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Computer Graphics — the compositor as a scene graph of textured quads, and why blending order rather than pixel count is what bounds a frame.