PaintGENERALENGINE-SPECIFICDEVICE-SPECIFIC

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.

The question

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?

The user intent

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 obvious build

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.

Why it breaks

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).

How it breaks in a real browser
  • 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).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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: transform hint, an accelerated CSS animation of transform or opacity, position: fixed or a sticky element in some engines, video and canvas elements, 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-change that 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 each thread owns
when something changedtextureslayer treehandled here when it can bewhen a listener must run firstYour JavaScriptScroll / touch inputMain thread: DOM, style, layout, paint recordCommit: layer tree + display listsRaster workersCompositor thread: transforms + blendFrame at the display refresh
UserLLMAgentToolDataDecisionHumanGuardrail

What creates a layer, and what it costs

ENGINE-SPECIFICEvery row here is a Blink-leaning description: WebKit promotes 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.
TriggerWhy the engine promotesWhat it buysWhat it costs
will-change: transform / opacityYou declared an intent to animate the propertyThe compositor can animate without the main threadA bitmap held for as long as the declaration is present — including when nothing is animating
A running transform/opacity animationPromotion for the animation's duration, then demotionThe correct lifecycle, for freeOne raster at the start; a possible blank first frame if raster is late
video, canvas, WebGLThe content updates independently of the documentIndependent update rates; no document repaint per video frameA surface per element, sized to the element
Overlap with a composited elementPaint order must be preservedCorrectness — nothing, from your point of viewAdditional layers you did not ask for and will not find without the layers panel
Scrollable regionsScrolling can be handled by the compositorScroll that survives a busy main thread (Scroll and Input Latency)Tiles for the scrollable content, including some off-screen
position: fixed / stickyThe element must stay put while content moves beneath itSticky headers that do not stutterEngine-dependent; several engines promote only under some conditions
filter, backdrop-filter, some blend modesThe effect needs an isolated surface to sampleThe effect works at allExtra 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.

A drawer that slides in
Permanent hint
.drawer {
  will-change: transform;   /* always promoted, always allocated */
  transform: translateX(-100%);
  transition: transform 220ms;
}
.drawer[data-open] { transform: none; }
Hint scoped to the interaction
.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.

Adding and removing the hint around the animation
1function openDrawer(el: HTMLElement) {
2 el.dataset.animating = '' // hint: promote now
3 requestAnimationFrame(() => { // let the hint take effect first
4 el.dataset.open = ''
5 })
6 el.addEventListener(
7 'transitionend',
8 () => { delete el.dataset.animating }, // demote: release the layer
9 { 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/opacity is 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 transform does 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-motion applies 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

Failure modes
  • 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: hidden ancestor, a filter on 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-change applied to a parent to "help" promotes a huge subtree, and the resulting allocation is worse than the repaint it avoided.
What can arrive out of order
  • 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.
Security
  • 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.
Misreads
  • "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-change promotes 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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.

Concurrencymessage-passing
Domains that do not exist yet
  • Computer Graphics — the compositor as a scene graph of textured quads, and why blending order rather than pixel count is what bounds a frame.