How browsers render contentLayers and compositing

100%

Layers and compositing.

Painting is expensive and moving is cheap, so browsers try not to paint twice. They draw some parts of the page into separate bitmaps, called layers, and hand those to a compositor thread that slides, fades and scrolls them on the GPU, frame after frame, while JavaScript is still busy elsewhere. That one arrangement explains why a transform animation keeps gliding through a long task, why scrolling rarely freezes, and why a stylesheet that promotes everything can make a phone run out of memory.

Intermediate34 minUpdated 2 Oct 2026

Builds on From bytes to pixels and The frame and the event loop.

The idea.

Moving a picture is cheaper than drawing it again. Browsers exploit that by keeping some parts of the page as separate pictures, layers, and letting another thread shuffle them into each frame.

Take Copperpot's product page for the 24 cm enamel casserole. A shopper taps Add to basket, and an "Added to basket" bar should rise from the bottom of the screen over 250 ms. In the same instant the page starts hydrating its reviews widget, a 180 ms burst of JavaScript on the main thread. If the bar's motion depends on the main thread, it simply stops: no frame can be produced while the script runs, the bar sits half hidden, then jumps to its end. Shoppers read that as a broken page.

Browsers avoid that by splitting the work in two. The main thread works out what everything looks like and records how to draw it. Some parts of the page are drawn into their own bitmaps, layers, instead of into the page's main one. A second thread, the compositor, holds those layers and decides each frame where each one sits, how transparent it is and how far the page has scrolled, then asks the GPU to stack them. Changing those few properties needs no new drawing, so the compositor can do it alone, sixty or more times a second, whatever JavaScript is doing.

That is the whole contract. If the bar moves with transform, it is a layer slid by the compositor and keeps gliding through the reviews task. If it moves with bottom, every frame needs a new layout and a new paint, which only the busy main thread can do. Layers are not free, though: every one is a bitmap held in GPU memory, so the craft is choosing which parts deserve one.

Copperpot's product page as the compositor sees it

Copperpot's product page as the compositor sees it. The numbered component cards that follow describe each part.
Copperpot's product page as the compositor sees itComponents: 2. Compositor thread (Holds a copy of the layers and property trees. Decides the layer list, schedules raster, applies scroll offsets and transform or opacity animations, and builds each frame.), 4. GPU process (Viz) (Keeps the tile textures in GPU memory, combines every frame on screen into one and draws it.).

Painted by the main threadonce each, until their content changes

tiles

tiles

tiles

tiles

frame: where each layer goes

Page layer
title, price, options, reviews

Sticky header
stays put while the page scrolls

Gallery scroller
five photos side by side

Basket bar
rises from below the screen

2Compositor thread
scroll offset 640 px
gallery scrollLeft 393 px
bar translateY(40px), opacity 0.8

4GPU process (Viz)
stacks the four layers into one frame

Which changes the compositor can make on its own

Change on Copperpot's pageWhat it touchesNeeds the main thread each frame?
Page scrolls 640 pxThe scroll offset of the root scrollerNo, unless a non-passive wheel or touch listener has to be asked first
Gallery swipes to photo 2The scroll offset of the gallery's scroll-snap scrollerNo; the photos are already painted and the compositor scrolls them
Basket bar slides up and fades inIts transform and opacityNo, once the animation has been handed to the compositor
Basket bar slides up by animating bottomThe bar's position in layoutYes: style, layout and paint every frame
Basket count changes from 2 to 3Text inside the headerYes, once: the header is repainted and re-rastered, then composited
Swatch turns from Cream to TealThe colour of pixels inside the page layerYes, once: paint and raster, no layout
Was this section helpful?

How it works.

Follow Copperpot's page from the end of paint to a frame on the glass: how the browser cuts the painted page into layers, what earns a part its own layer, how layers become tiles, and what the compositor does each frame without the main thread. Chromium's names are used throughout, because its pipeline is the one documented in the most detail.

From a display list to a layer list

Paint does not make pixels. It walks the laid-out page in paint order and records drawing commands: fill this rectangle cream, draw this run of text, draw this decoded photo here. Each command is tagged with the visual context it is drawn in, and in Chromium that context is a position in four property trees. Consecutive commands that share a context are grouped into a paint chunk.

The property trees are what make compositing possible. A transform, a clip, an opacity or a scroll offset is not baked into the drawing commands; it sits in a tree node that the commands point to. Change the node and every chunk under it moves, fades or scrolls, with the commands themselves untouched. The compositor thread keeps its own copy of those trees, so it can change a node between commits and draw the next frame without asking the main thread.

Layerization then decides which chunks share a bitmap. Since Composite After Paint shipped in Chromium M94, this step runs after paint, on the compositor side of the commit, with the full painted result in hand. Merging chunks into one layer saves GPU memory; keeping a chunk apart means it can move without the layer under it being drawn again. Chromium splits where it expects a property to change on the compositor: a scroller, a running transform or opacity animation, a will-change hint.

The four property trees, on Copperpot's page

TreeWhat its nodes holdA node on the casserole page
transform2D or 3D matrices, and the offset of every scroller (scrolling is a translation)The basket bar's translateY; the root scroller's 640 px offset; the gallery's sideways offset
clipOverflow clips and clip rectanglesThe gallery scroller's box that hides photos 2 to 5
effectOpacity, filters, masks, blend modesThe basket bar's opacity while it fades in
scrollWhich boxes scroll, their sizes and how a scroll chains to the parent at its endThe root page and the gallery

What earns a part of the page its own layer

Common reasons, and whether you asked for them

ReasonExampleDirect or side effect
A hint that a property is about to changewill-change: transform / will-change: opacityDirect; you asked
A running transform or opacity animationbar.animate([{ transform: 'translateY(100%)' }, …])Direct; for the length of the animation
A 3D transform or perspectivetransform: translateZ(0)Direct; the old trick, now better written as will-change
Content drawn outside the page's own paint<video>, <canvas> with WebGL, an <iframe>Direct; the browser decides
A scroller the compositor scrollsoverflow-x: auto on the galleryDirect, when the browser composites the scroller
Fixed or sticky content over a composited scrollposition: sticky on the headerUsually; it must move against the scrolled content
Overlapping a composited layer and painting above itthe "Free delivery" badge over the gallerySide effect: it needs a layer too, or it would be drawn under the gallery

The last row is the one that surprises people. The compositor stacks layers in a fixed order, so anything that paints on top of a composited layer and overlaps it cannot stay inside the page layer underneath; it would end up hidden. The browser has to give the overlapping content a layer too. Put will-change on one element early in paint order and later siblings that overlap it get pulled up with it. Chromium limits the damage by squashing several overlapping elements into one shared layer, but a careless hint can still multiply the layer count.

Two side effects travel with will-change and with a real transform. The element becomes a stacking context, so a z-index inside it no longer competes with the rest of the page (stacking has its own topic). And will-change: transform makes the element the containing block for its position: fixed descendants, so a fixed close button inside a promoted panel now sticks to the panel, not to the screen. The CSS Will Change specification requires both: the hint must have the layout effects the property itself would have.

Tiles, raster and the checkerboard

A layer can be far bigger than the screen: Copperpot's gallery scroller is five photos wide, about 1,965 CSS px. The compositor cuts every layer into tiles and has them rastered (turned from drawing commands into pixels) a tile at a time, by raster workers that in current Chromium run in the GPU process. Tiles in the viewport go first, then tiles just outside it. Tiles far away may never be rastered at all.

This is why scrolling is cheap and also why it can go blank. While the shopper swipes at a normal pace, the next photo's tiles are ready before they slide in. A hard fling can outrun raster: the compositor still moves the layer on time, because it never waits for pixels, but tiles that arrive on screen unrastered are drawn as a plain background (the checkerboard) for a frame or two until raster catches up. A smooth scroll with a gap is a trade the compositor makes on purpose.

The gallery scroller as tiles, swipe by swipe

Gallery tiles
  1. photo 1, 1 item, On screen and rastered
  2. photo 1, 1 item, On screen and rastered
  3. photo 2, 1 item, Rastered, off screen
  4. photo 2, 1 item, Rastered, off screen
  5. photo 3, 1 item, Not rastered
  6. photo 3, 1 item, Not rastered
  7. photo 4, 1 item, Not rastered
  8. photo 4, 1 item, Not rastered
  9. photo 5, 1 item, Not rastered
  10. photo 5, 1 item, Not rastered
  • viewport, from 0 to 2
  • On screen and rastered
  • Rastered, off screen
  • Queued for raster
  • Not rastered
  • On screen, blank (checkerboard)
Start

As it starts. 4 steps follow.

Each segment is one column of tiles, half a photo wide. The compositor moves the strip on every frame; raster fills tiles in and near the viewport first. Illustrative: real tile sizes and how far ahead Chromium rasters depend on the device and the build.

One frame while the main thread is busy

A gallery swipe during the reviews task

A gallery swipe during the reviews task, as an ordered list of steps:
A gallery swipe during the reviews task10 steps between Shopper's finger, Compositor thread, Renderer main thread, Raster and decode threads, GPU process (Viz). The steps are listed as text after the diagram.GPU process (Viz)Raster and decode threadsRenderer main threadCompositor threadShopper's fingerrunning the reviews widget task (180 ms)no blocking touch listener here: no need to ask the main threadgallery offset += 24 px in its copy of the scroll and transform treesframe on screen within the 16.7 ms budgettouchmove: 24 px to the left1raster photo 3 tiles (now near the viewport)2tile textures3compositor frame: same tiles, new offset4scroll event, queued until the task ends5next commit: updated display lists, if anything was repainted6
  1. Note over Renderer main thread: running the reviews widget task (180 ms)
  2. Shopper's finger → Compositor thread: touchmove: 24 px to the left
  3. Note over Compositor thread: no blocking touch listener here: no need to ask the main thread
  4. Note over Compositor thread: gallery offset += 24 px in its copy of the scroll and transform trees
  5. Compositor thread → Raster and decode threads: raster photo 3 tiles (now near the viewport)
  6. Raster and decode threads → GPU process (Viz) (reply): tile textures
  7. Compositor thread → GPU process (Viz): compositor frame: same tiles, new offset
  8. Note over GPU process (Viz): frame on screen within the 16.7 ms budget
  9. Compositor thread → Renderer main thread: scroll event, queued until the task ends
  10. Renderer main thread → Compositor thread (reply): next commit: updated display lists, if anything was repainted

This is threaded scrolling: the compositor turns finger and wheel input into a new scroll offset itself. It can only do that when nothing on the main thread could change the outcome. A scroll listener cannot; the scroll event is not cancelable and fires after the page has already moved. A touchstart, touchmove or wheel listener can, because calling preventDefault on those cancels the scroll. So over the area such a listener covers, the compositor has to send the event to the main thread and wait for the answer before it moves anything (Chromium calls that area a non-fast scrollable region). If the reviews task is running, the swipe waits up to 180 ms.

A passive listener is a promise not to call preventDefault. With { passive: true } the compositor scrolls at once and hands the event to the listener whenever the main thread is free; a preventDefault inside it is ignored. Browsers already treat touchstart, touchmove and wheel listeners on window, document and body as passive by default, so the trap is a listener on an element such as the gallery. When a component really needs to stop some gestures, the CSS touch-action property says which ones up front (touch-action: pan-y lets the browser scroll vertically and leaves sideways drags to script), so the compositor knows without asking. The full listener options belong to the events topic. The same logic applies to script-driven motion: a drag that sets transform from a pointermove handler moves no faster than the main thread delivers those handlers, even though the transform itself is cheap.

A swipe tracker on the gallery that does not hold up scrolling

// Non-passive: every touchmove over the gallery must reach the
// main thread before the compositor may scroll it.
gallery.addEventListener('touchmove', trackSwipe);
// Passive: the compositor scrolls first; trackSwipe runs later,
// when the main thread is free, and cannot cancel the scroll.
gallery.addEventListener('touchmove', trackSwipe, { passive: true });

The same slide-up on three routes

Which stages a change to each property runs again

Property animatedLayoutPaintComposite
width, height, top, left, bottomYes, every frameYes, every frameYes
background-color, box-shadow, colorNoYes, every frameYes
transform, opacityNoNo (once the layer is rastered)Yes, on the compositor thread
filter (some functions, some engines)NoDepends on the engineYes, where the engine composites it

The basket bar's 250 ms slide-up, with and without the main thread

Scenario 1 of 3: As described.

Timeline as a list

The basket bar's 250 ms slide-up, with and without the main thread: 4 lanes, from 0 ms to 300 ms.

  1. 0–6 ms · Main thread · tap: start animation, paint bar
  2. 6 ms · Main thread → Compositor thread, arriving 8 ms: commit
  3. 8–258 ms · Compositor thread · samples transform + opacity per frame
  4. 8–258 ms · GPU process · a frame every 16.7 ms
  5. 8 ms · Screen · bar starts rising
  6. 12–192 ms · Main thread · reviews widget hydrates
  7. 258 ms · Screen · bar in place (ok, ok)
Illustrative timings on Copperpot's mid-range phone at 60 Hz. The shopper taps Add to basket at 0 ms; the reviews widget starts hydrating at 12 ms and holds the main thread for 180 ms. The animation is time-based, so a frame drawn late shows the bar where it should be by then, not where the last frame left it.

The two versions of Copperpot's basket bar

.basket-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: -120px;            /* hidden below the screen */
  transition: bottom 250ms ease-out;
}
.basket-bar.is-open {
  bottom: 0;                 /* layout + paint every frame */
}
.basket-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;                 /* layout never changes */
  transform: translateY(100%);
  opacity: 0;
  transition: transform 250ms ease-out, opacity 250ms ease-out;
}
.basket-bar.is-open {
  transform: translateY(0);  /* compositor only */
  opacity: 1;
}
function openBasketBar(bar) {
  // Render one frame with the hint, so the layer exists before the motion starts.
  bar.style.willChange = 'transform, opacity';
  requestAnimationFrame(() => {
    requestAnimationFrame(() => bar.classList.add('is-open'));
  });
  bar.addEventListener('transitionend', function settle(event) {
    if (event.propertyName !== 'transform') return; // opacity ends too
    bar.style.willChange = 'auto'; // let the layer go when it rests
    bar.removeEventListener('transitionend', settle);
  });
}

A layer's life

The basket bar, from part of the page to its own layer and back

States of2Compositor thread

The basket bar, from part of the page to its own layer and back. 5 states, 8 transitions. The table below lists them.
The basket bar, from part of the page to its own layer and backThe states of Compositor thread. 5 states, 8 transitions. The table below lists them.

will-change set / layerize on next commit

tiles rastered

transition starts / compositor owns it

vsync / new transform and opacity

transitionend

”3 items” becomes ”4 items”

paint and commit / re-raster the bar only

will-change auto / merged back on a later commit

Painted into the page layer

Own layer, not rastered yet

Own layer, tiles ready

Moving on the compositor

Content changed

6 steps. The path the code above is written for.

What the browser holds for the bar at each moment. Moving it never needs a repaint; changing what is inside it always does, layer or no layer. Removing the hint lets the bar merge back into the page layer on a later commit.

Transitions of The basket bar, from part of the page to its own layer and back
From → ToEventActionActor
Painted into the page layer → Own layer, not rastered yetwill-change setlayerize on next commitmain thread
Own layer, not rastered yet → Own layer, tiles readytiles rasteredraster
Own layer, tiles ready → Moving on the compositortransition startscompositor owns itcompositor
Moving on the compositor → Moving on the compositorvsyncnew transform and opacitycompositor
Moving on the compositor → Own layer, tiles readytransitionendcompositor
Own layer, tiles ready → Content changed"3 items" becomes "4 items"main thread
Content changed → Own layer, not rastered yetpaint and commitre-raster the bar onlymain thread
Own layer, tiles ready → Painted into the page layerwill-change automerged back on a later commitmain thread
Painted into the page layerstart
No bitmap of its own; drawn with everything around it
Own layer, not rastered yet
Layerize gave it a layer; tiles are queued
Own layer, tiles ready
Can be moved or faded at no paint cost
Moving on the compositor
translateY and opacity change every frame
Content changed
Its display list is stale; the main thread must repaint it
Was this section helpful?

In practice.

On Copperpot's product page, which parts deserve a layer, what each one costs in memory, how to check what the browser actually did, and how to say all of this in a front-end interview in a couple of sentences.

The casserole page and its moving parts

Two layers beyond the page itself: the sticky header (it must stay put against the scrolled page) and the gallery scroller. The basket bar has no layer until it is about to move.

At rest. Store “Copperpot”, showing the product view. Cart button “Basket” with badge 2 (its accessible name says “Cart, 2 items”, not the number alone). Product (showing) “Enamel casserole, 24 cm” by “Copperpot Kitchen”: rating 4.8 (“312 reviews”); price £64.00. Gallery: image 1 of 5 (“Enamel casserole in cream, lid on”); thumbnails beside it. Colour (swatches, a radio group): Cream (selected), Teal, Rust. Quantity 1 (stepper “Quantity”). Buttons: “Add to basket”. Delivery: “Delivered Thursday”. Folded sections: Details, Reviews, Care and cleaning. Note on header: sticky: own layer, moves against the scroll Note on gallery: scroll-snap scroller: own layer, tiled

  1. Composited scroller2Compositor thread
  2. Basket bar layer2Compositor thread
  3. Tap handler1Renderer main thread

What each layer costs Copperpot's phone

Assumptions
Device pixels per CSS pixel
3assumption; a common phone ratio
Bytes per rastered pixel
4RGBA, 8 bits each; before any GPU compression
Viewport
393 × 852 CSS pxassumption
One gallery photo
393 × 393 CSS px
Basket bar
393 × 120 CSS px
One review card
393 × 160 CSS px36 cards on the page
Working
  1. A viewport-sized layer(393 × 3) × (852 × 3) × 4 = 1,179 × 2,556 × 412.1 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and Viewport
  2. One photo1,179 × 1,179 × 45.6 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and One gallery photo
  3. The whole gallery if every tile were rastered5 × 5.6 MB27.8 MBfrom One photo · Raster near the viewport only keeps the real figure well below this
  4. The basket bar while it is a layer1,179 × 360 × 41.7 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and Basket bar
  5. will-change on every review card36 × (1,179 × 480 × 4) = 36 × 2.26 MB81.5 MBfrom Device pixels per CSS pixel, Bytes per rastered pixel and One review card
What it means
  • The memory follows area, multiplied by the square of the pixel ratio: the same card costs 9 times as much at DPR 3 as at DPR 1.
  • A layer that is promoted only while it moves, like the basket bar, costs 1.7 MB for a quarter of a second. A hint left on 36 cards that never move costs about 81 MB for as long as the page is open.
  • Tiling keeps the real cost of big layers below the worst case, but it cannot help when many layers sit in the viewport at once.

GPU memory per layer choice on Copperpot's page

GPU memory per layer choice on Copperpot's pagePromoting the basket bar while it moves costs under 2 MB, while a will-change rule left on 36 still review cards costs about 81 MB, more than six full screens.020406080100Basket barOne photoViewport-sized layerWhole gallery36 review cards1.7 MB5.6 MB12.1 MB27.8 MB81.5 MBLayerMemory if fully rastered (MB)GPU memory per layer choice on Copperpot's pagePromoting the basket bar while it moves costs under 2 MB, while a will-change rule left on 36 still review cards costs about 81 MB, more than six full screens.020406080100Basket barOne photoViewport-sized la…Viewport-sized layerWhole gallery36 review cards1.7 MB5.6 MB12.1 MB27.8 MB81.5 MBLayerMemory if fully rastered (MB)
Fully rastered sizes at three device pixels per CSS pixel and 4 bytes per pixel, from the estimate above.
Data
LayerMB
Basket bar1.7
One photo5.6
Viewport-sized layer12.1
Whole gallery27.8
36 review cards81.5

Main-thread time per frame of the basket bar's slide-up

Main-thread time per frame of the basket bar's slide-upAnimating bottom costs about 5 ms of main-thread time in every frame and background-color about 2.4 ms, while transform and opacity cost nothing per frame once the compositor has the animation.02 ms4 ms6 ms8 ms10 ms12 msbottom (layout route)background-color (paint route)transform + opacity (composite route)5 ms2.4 ms0budget for page workProperty animatedMain-thread work per frame (ms)Main-thread time per frame of the basket bar's slide-upAnimating bottom costs about 5 ms of main-thread time in every frame and background-color about 2.4 ms, while transform and opacity cost nothing per frame once the compositor has the animation.04 ms8 ms12 msbottom (layout ro…bottom (layout route)background-color …background-color (paint route)transform + opaci…transform + opacity (composite route)5 ms2.4 ms0budget for page workProperty animatedMain-thread work per frame (ms)
Illustrative figures, not measurements, for one 393 × 120 bar on a mid-range phone; the frame is 16.7 ms at 60 Hz and web.dev suggests leaving about 6 ms of it to the browser.
Data
Property animatedms per frame
bottom (layout route)5
background-color (paint route)2.4
transform + opacity (composite route)0
  • budget for page work: Main-thread work per frame (ms) = 10

Checking what the browser actually did (Chrome DevTools)

ToolWhat it showsWhat you want to see on Copperpot
Rendering → Paint flashingGreen flashes wherever the page is repaintedNothing flashes while the gallery swipes or the bar slides; only the header flashes when the count changes
Rendering → Layer bordersOutlines around layers and their tilesBorders around the header, the gallery and, briefly, the bar; none around review cards
Layers panelEvery layer with its size, compositing reasons, memory estimate and paint countA handful of layers, a few MB each; the bar's reason is will-change or an active animation, and its paint count stays at 1 while it moves
Performance panelMain-thread tasks next to the frames that were drawnFrames keep arriving through the 180 ms reviews task
Rendering → Scrolling performance issuesElements whose listeners can slow scrollingNo touch or wheel listener over the gallery that is not passive

Not only Chromium

Layer promotion, squashing and the Layers panel are Chromium's machinery, and the details above are its details. Safari also composites layers on the GPU with its own rules for when to make one. Firefox's WebRender takes a different route: it draws the page's display list on the GPU every frame rather than keeping many painted bitmaps, so a misplaced will-change costs it less and a layer count means little there. What holds everywhere is the part an interview cares about: transform and opacity changes can be applied without layout or paint and away from the main thread, and geometry changes cannot.

Where the system flows lean on it

Stories viewer (instagram/stories-viewer)
Progress bars that fill for five seconds and a swipe between people's stories: both are transform on layers, so a story's video decode or a prefetch never stalls the bar.
Home rows (netflix/home-rows)
Horizontal rows that scroll on the compositor, and a tile that scales up on hover. Only the hovered tile is promoted, and only while it grows.
Product page (amazon/product-detail-page)
The gallery and zoom lens move as layers; a price or stock change is a repaint of one small region, never of the gallery.
Live cursors (figma/live-cursors)
Other people's cursors are small promoted elements moved with transform, so dozens of remote updates a second never trigger layout of the canvas UI.
Infinite canvas (figma/infinite-canvas)
The opposite choice on purpose: the canvas's wheel listener is not passive because it must call preventDefault to turn pinch into zoom, so that one surface accepts waiting for the main thread and keeps its handler short.
Map search (airbnb/map-search)
Map tiles pan and zoom as transformed layers while the result list re-renders on the main thread; the two must never wait for each other.

Saying it in an interview

Q1
PromptThe interviewer asks how you would make the add-to-basket confirmation feel smooth on a low-end phone.
Animate only transform and opacity, so the bar is a layer the compositor moves without the main thread; set will-change just before it moves and remove it after. Then check in DevTools that nothing repaints during the motion and that the bar's paint count stays at one. Mention that it keeps moving even when hydration or analytics hold the main thread.
Q2
Follow-upWhy not put will-change: transform on every card in the feed so they all animate well?
Each layer is a GPU bitmap: at a pixel ratio of 3 a 393 × 160 card is about 2.3 MB, so 36 of them are about 81 MB for elements that never move. It also turns each card into a stacking context and a containing block for fixed children. Promote what is about to move, for as long as it moves.
Q3
Follow-upScrolling is smooth in testing but janky in production. What do you look at?
First a touch or wheel listener that is not passive, which makes every scroll wait for the main thread; then a scroll handler that writes geometry each frame, and large areas that repaint on scroll (Paint flashing). Finally layer memory: too many or too large layers on low-end devices lead to re-raster and blank tiles.
Q4
Where it fitsWhere does this belong in a 45-minute front-end system design answer?
In the performance part, as one or two sentences tied to the specific moving parts of the design: the carousel, the sticky header, the drawer, the toast. Name the route each change takes and which thread does it; do not recite the pipeline.
Was this section helpful?

Trade-offs.

A layer trades memory and upload time for the freedom to move without repainting. Three decisions follow from that, and a handful of ways it goes wrong.

01
When the basket bar gets its layer
Chosen:Just before it moves, removed when it rests
  • Pro:The layer exists for the first frame of the motion
  • Pro:Memory is held for a quarter of a second
  • Pro:No permanent stacking-context side effects on a resting element
Downside we accept:
  • Con:A little code to add and remove the hint
  • Con:Adding or removing a hint has a one-time cost of its own
Ruled out:Always, in the stylesheet

Holds GPU memory for the life of the page; Permanent stacking context and containing block for fixed children; Spreads by copy-paste to elements that never move

Ruled out:Never; let the animation promote it

The first frame can be late while the layer is made and rastered; Does not help a script-driven change

02
Which properties carry the motion
Chosen:transform and opacity only
  • Pro:Composite route: no layout or paint per frame
  • Pro:Keeps moving through main-thread tasks
  • Pro:Works across engines
Downside we accept:
  • Con:Layout around the element does not follow it
  • Con:Scaling text can look soft until it is rastered again
Ruled out:Geometry such as bottom, height or left

Layout and paint every frame on the main thread; Freezes during long tasks

Ruled out:Paint-only properties such as background-color or box-shadow

Repaint and re-raster every frame; Still waits for the main thread

03
How finely to split a long list of cards
Chosen:One page layer; promote a card only while it moves
  • Pro:Memory stays near one screenful
  • Pro:Cheap to keep rastered
Downside we accept:
  • Con:Changing one card repaints its tiles of the shared layer
Ruled out:A layer per card

Memory grows with the number of cards (81 MB for 36 on Copperpot); More layers to upload and manage every frame; Overlap can pull in neighbours

What goes wrong

FailureImpactDetectionMitigationMeanwhile
Layer explosion4GPU process (Viz)Hundreds of layers, from will-change in a shared class or from overlap with one promoted element; tiles are evicted and re-rastered, frames are late, a low-memory tab may be reloadedLayers panel count and memory estimate; compositing reason 'overlaps' on many layersRemove blanket hints; promote only what moves, while it moves; give the moving element a higher stacking order than its neighboursAnimations still run, but scroll and raster fall behind
Blank tiles in a fast fling3Raster and decode threadsThe checkerboard shows for a frame or two at the leading edge of a scrollRecording a fling on a low-end device; Layer borders show which tiles existLighter content in long scrollers (smaller images, fewer effects); avoid huge layers with expensive paintSmooth motion with content arriving late, which is the intended trade
Soft or blurry text on a scaled layer2Compositor threadA will-change: transform element scaled from script keeps its old raster, so text looks softVisual check at the end of a scale; compare with the hint removedRemove the hint when the scale settles so the element is rastered at its new sizeReadable but fuzzy until re-rastered
Fixed child or z-index breaks after promotion1Renderer main threadA fixed close button scrolls with its panel; a dropdown in a promoted card hides under the next cardLayout bugs that appear only when will-change or a transform is presentPut fixed overlays outside promoted ancestors, or in the top layer (a dialog or popover)Wrong placement, not a crash
Scroll waits for the main thread2Compositor threadA touch or wheel listener that is not passive covers the scroller, so every scroll waits for JavaScriptScrolling performance issues in DevTools; slow scroll regions in the Layers panelpassive listeners, touch-action for gestures, native scroll-snap instead of script-driven swipesScroll still works, but can start a whole long task late
This topic
Paint chunks, property trees and how layers are chosen
Tiles, raster and checkerboarding
What the compositor thread does without the main thread
The memory a layer costs, and will-change's side effects
Threaded scrolling and why listeners must be passive
Elsewhere
The whole pipeline from bytes to the first frameFrom bytes to pixels
When frames run, requestAnimationFrame and long tasksThe frame and the event loop
FLIP, reduced motion and scroll-driven animationsCompositor animations
z-index and stacking contextsStacking contexts and the top layer
The listener options API in full (capture, once, signal)Events and delegation
Was this section helpful?
Builds on this
Animating on the compositor
Read next