Part 5 · 10 chapters · ~75 min

Paint and Composite

Paint records what to draw, in a fixed order that every z-index bug is a question about. Layers decide what the compositor can move without repainting, and the compositor turns layers into frames on its own thread, which is why scrolling survives a busy page and why a transform costs nothing while a width costs everything. This part is paint order and stacking, layers and their price, raster and tiles, the compositor and the 16.7 ms frame, animation by property tier, paint invalidation, images and text, and how all of it reads in a profile.

55

What paint is, and what it is not

the question

"Layout gave every box a rectangle. Isn't painting just filling them in?"

Paint does not put pixels anywhere. It records: for each box, in a defined order, the drawing commands needed to render it. Backgrounds, borders, text runs with their glyphs, images, shadows, outlines. The output is a display list, a serialised sequence of draw operations with their geometry. Pixels come later, from raster, on other threads, into tiles. Separating "what to draw" from "drawing it" is what lets the compositor cache and reuse.

the phases of a box's paint
  1. Background phase. Background colour, then background images, then border.
  2. Float phase, block phase, inline phase. Children by their type, in the stacking order of the next chapter.
  3. Foreground. Text, replaced content, outlines.
  4. Effects applied as a group. Opacity, filters, masks, clips and blend modes wrap the box's entire output; they are the reason those properties create stacking contexts.
worked numbers
a display list, roughly:

  DrawRect    (0,0,1280,2400) fill #fff
  DrawRRect   (24,80,400,200) r=8 fill #f6f7f9 stroke #e2e5ea
  DrawImage   (40,96,120,120) image#7
  DrawTextBlob (176,120) "Balance" font#3 color #16213a
  SaveLayer   opacity .5
    DrawRect ...  (the children of a half-transparent element)
  Restore
  ...

paint invalidation re-records only the dirty rectangles. an unchanged box keeps its entries.
cost model
Paint cost scales with the area and complexity of what changed, not the page. A tiny change to a box with a large shadow or blur repaints the whole blurred area. Box-shadow with a large blur radius and filter: blur() are the two paint properties that show up in profiles, because the raster cost of a blur is proportional to area times radius.
56

Paint order and stacking contexts, drawn

Part 4 introduced stacking contexts from the positioning side. Here is the paint order they define, because every z-index bug is a paint-order question.

within one stacking context, in order
  1. The context root's background and borders.
  2. Descendant stacking contexts with negative z-index, most negative first.
  3. In-flow, non-positioned block descendants, in tree order.
  4. Floats.
  5. In-flow inline content, including the root's own text.
  6. Positioned descendants with z-index auto or 0, and any stacking contexts with z-index 0, in tree order.
  7. Descendant stacking contexts with positive z-index, ascending, ties in tree order.
what creates a stacking context (the full list matters)
  1. The root element.
  2. position fixed or sticky; position absolute or relative with z-index not auto.
  3. A flex or grid item with z-index not auto.
  4. opacity less than 1; mix-blend-mode not normal; isolation: isolate.
  5. transform, filter, backdrop-filter, perspective, clip-path, mask, mask-image, mask-border not none.
  6. will-change naming any of those properties.
  7. contain: layout, paint, or strict or content.
  8. Elements in the top layer: dialog shown with showModal, fullscreen elements, popovers.
the top layer
Modal dialogs and popovers are promoted to a special layer above the root stacking context, which is why <dialog>.showModal() is above every z-index on the page without any z-index at all. If you are fighting z-index for a modal in 2026, the answer is the dialog element or the popover attribute, which escapes every ancestor context by design.
PAINT ORDER
what draws on top of what, and why z-index lies
swipe the figure sideways, or tap expand for full screen
1/6
the setup
A page with a header, a card containing an image and a tooltip with z-index 9999, and a modal overlay with z-index 10. The question is why the tooltip renders under the modal.
57

Layers: what gets one, and what it costs

After paint, the engine decides how to split the page into compositing layers. A layer is a separately rasterised surface the compositor can move, scale, fade and clip without repainting. Getting one is a trade: independence for memory.

direct reasons for a layer
  1. 3D or perspective transforms; will-change: transform or opacity.
  2. A running CSS animation or transition on transform or opacity.
  3. video, canvas (accelerated), iframe, plugins.
  4. The scrolling contents of a scroll container, so scrolling happens on the compositor.
  5. position: fixed, and sticky in many cases.
  6. backdrop-filter; certain blend modes; contain: paint in some combinations.
indirect reasons, which are where the surprises are
  1. Overlap. An element that paints after a composited layer and overlaps it must also be composited, or it would render under the layer. One will-change on a header can promote everything that overlaps it.
  2. Squashing. Chrome tries to put several overlapping elements into one "squashing layer" to limit the count. It helps, until it cannot.
  3. Layer explosion. A promoted element inside a list, or will-change on every card, produces hundreds of layers, each a texture, and the GPU memory and upload time swamp the gain.
worked numbers
memory per layer:

  width × height × device pixel ratio² × 4 bytes
  a full-width 375×600 CSS px card at 3x = 375 × 600 × 9 × 4 ≈ 8 MB
  fifty of them in a list = 400 MB

the fix is almost always: promote only the thing that moves, only while it moves.
will-change set in a mousedown handler and removed on transitionend.
the lens
DevTools' Layers panel shows every compositing layer, its size in memory, and the reason it was created ("compositingReasons"). A reason of "overlaps other composited content" on dozens of layers is the fingerprint of an accidental explosion. The Rendering panel's "Layer borders" overlay draws them on the page.
LAYERS AND THE COMPOSITOR
why a transform is cheap and a width is not
swipe the figure sideways, or tap expand for full screen
1/6
one layer
After paint, the engine has a display list for the page. By default the whole document is one layer: one bitmap, rasterised in tiles, handed to the compositor.
58

Raster, tiles and the GPU process

Rasterisation turns a layer's display list into pixels. It runs on raster threads, per tile, with the GPU doing the drawing when it can.

how a layer becomes pixels
  1. Tiling. A layer is divided into tiles, 256×256 device pixels by default. Only tiles near the viewport are rasterised; far-off ones are skipped until scrolling approaches them. This is why a very long page is not a very large bitmap.
  2. Priority. Visible tiles first, then a band around the viewport in the scroll direction, so fast scrolling reveals rastered content instead of checkerboard. On a slow device you still see the blank tiles; that is raster not keeping up.
  3. GPU raster. The display list is replayed by Skia into a GPU texture directly, on the GPU process. Fast for shapes and transforms; text rendering uses glyph atlases. Software raster, on CPU, remains for some content and some devices.
  4. Low-resolution tiles. During a fast scroll or a pinch zoom, the compositor can show a quick low-res raster and replace it when the full tiles arrive.
  5. Invalidation. When paint changes a region, only the tiles that intersect it are re-rastered. A 10 px change in a 2,000 px layer re-rasters one tile.
what makes raster expensive
  1. Large blurs and shadows: the whole blurred area, every time it changes.
  2. Many small text runs with different fonts: glyph atlas misses.
  3. Huge images drawn small: decoded at full size, scaled every raster. Serve the size you draw.
  4. Semi-transparent overlapping content: blending costs per pixel per layer.
  5. Device pixel ratio: 3x means nine times the pixels of 1x. Phones are the hard case.
59

The compositor thread, scrolling and the frame

The compositor thread holds a copy of the layer tree and produces frames from it. For scrolling and for compositor-driven animations it can do so without the main thread at all, which is the single largest reason modern pages feel smooth under load.

what runs on the compositor alone
  1. Scrolling of any scroll container whose contents are composited and whose scroll has no main-thread dependency. The compositor offsets the layer; raster fills tiles ahead.
  2. CSS transitions and animations on transform, opacity, filter (some), and background-color (recently). The animation curve is evaluated per frame on the compositor.
  3. Pinch zoom and overscroll effects.
  4. Animations started via Element.animate() on the same properties.
what drags scrolling back to the main thread
  1. A non-passive wheel or touch listener. The compositor must wait to see whether you call preventDefault. Mark them { passive: true } or do not add them to the document.
  2. position: fixed or sticky inside the scroller in some combinations, and background-attachment: fixed.
  3. A scroll handler that reads layout (Part 4). The scroll itself still composites, but the work you do each scroll event runs on the main thread and competes with everything.
  4. Scroll-linked effects done in JS instead of with animation-timeline: scroll(), which the compositor can run itself.
the two thread story, finished
Part 0 said scrolling and clicking fail differently. Now the mechanism is complete: scroll lives on the compositor and survives a busy main thread; a click handler is a main-thread task and dies with it. When a user says "it scrolls fine but nothing responds", the main thread is blocked and the compositor is coasting.
THE LIFE OF A FRAME
16.7 ms, and where it goes
swipe the figure sideways, or tap expand for full screen
1/6
vsync + input
Vsync fires. The compositor asks the main thread for a frame. The main thread begins: pending input events are dispatched first (a click, a scroll, a key).
60

Animation: which properties cost what

the question

"Why does the guide say only animate transform and opacity? My left animation looks the same."

It looks the same and costs ten to fifty times more, because of which stage of the pipeline it dirties. Every property belongs to a tier.

Changing thisRerunsThreadTypical cost per frame
transform, opacitycompositecompositorunder 1 ms
filter (blur, etc.)composite, sometimes rastercompositor, raster1 to 5 ms, area-dependent
color, background-color, box-shadow, border-color, visibilitypaint, raster, compositemain, raster2 to 10 ms, area-dependent
width, height, top, left, margin, padding, font-size, displaylayout, paint, raster, compositemain, raster5 to 50 ms, subtree-dependent
Anything that changes text wrappinglayout of all following contentmainworst case
the practice
  1. Transform for motion, opacity for fades. Scale instead of width, translate instead of left.
  2. will-change just in time. Set it when the interaction begins, remove it when the animation ends. Permanent will-change is a permanent layer.
  3. The FLIP technique for things that must change layout: let layout happen once, measure the before and after rectangles, then play the difference as a transform. Layout once, composite sixty times.
  4. View Transitions do FLIP for you across DOM states, including across navigations, on the compositor.
  5. Prefer CSS or the Web Animations API to rAF loops for transform and opacity, because only the first two can run off the main thread.
  6. Respect prefers-reduced-motion.
TWO ANIMATIONS
left: translateX. right: left
swipe the figure sideways, or tap expand for full screen
1/6
the setup
Two boxes slide 300 px to the right over one second. Visually identical. The left one uses transform: translateX; the right one uses left with position: relative.
61

Paint invalidation and what triggers a repaint

Like style and layout, paint is incremental. The engine tracks dirty rectangles per layer and re-records and re-rasters only those. The question is what dirties a rectangle.

what invalidates paint
  1. Any layout change dirties the old and new rectangles of every moved box.
  2. Visual property changes on a box: colours, backgrounds, borders, shadows, outlines, text decoration, visibility.
  3. Image decode completion: the image's rectangle.
  4. Selection and caret changes: the affected lines.
  5. Hover and focus style changes: the element, and anything that overlaps it in the same layer.
  6. Scrolling a non-composited scroller: the entire scroller, every frame. This is why non-composited scrolling is slow.
what does not
  1. Transform and opacity changes on a composited layer.
  2. Changes inside a different layer: layers invalidate independently.
  3. Changes to elements with contain: paint do not invalidate outside their box, and nothing outside invalidates them.
go to the lab
  1. Open the Rendering panel and enable Paint flashing. Route /paint/hover-shadow: hover over cards. Green flashes show repainted areas; the shadow blur makes them larger than the card.
  2. Route /paint/transform-vs-left: two identical animations. Enable Layer borders; the transform one has its own orange border, the left one flashes green every frame.
  3. Route /paint/layer-explosion: a list with will-change on every row. Open the Layers panel, read the total memory, then toggle the fix and read it again.
  4. Route /paint/non-passive-scroll: scroll with a wheel listener that is not passive, then with passive. The Performance panel's Interactions track shows the difference as input delay.
62

Images, decode and the paint pipeline

Images cross three stages: fetched by the network, decoded on a worker thread into a bitmap, then drawn by raster. Decode is the one people forget, and it is often the largest.

the lifecycle
  1. Fetch. Bytes arrive, as any resource.
  2. Decode. JPEG, PNG, WebP, AVIF bytes become raw pixels, on a dedicated decode thread. A 4,000×3,000 JPEG decodes to 48 MB of pixels and takes tens of milliseconds on a phone. It is decoded at the size needed if the format supports it, which is why srcset and sizes matter for CPU as well as bandwidth.
  3. Upload. The bitmap is uploaded as a GPU texture for raster.
  4. Draw. Raster samples it into the tiles.
  5. Discard. Decoded bitmaps are cached and evicted under memory pressure; scrolling back to an image may decode it again.
what you control
  1. decoding="async" (default for most cases now) lets paint proceed without the image and swaps it in when decoded. sync forces the decode before paint, which is right only for the LCP image if you want it to appear in one step.
  2. loading="lazy" defers fetch and decode for off-screen images. Never on the LCP image.
  3. fetchpriority="high" on the LCP image moves it up the network queue.
  4. Sizes. Serve the pixel size you draw times DPR. A 400 px wide slot on a 3x phone wants a 1,200 px image, and no more.
  5. Formats. AVIF and WebP decode cost more per byte than JPEG but there are far fewer bytes. On balance they win for photos. For UI graphics, SVG is vector and skips decode entirely.
  6. Image.decode() lets JavaScript decode ahead of time and await it, so inserting the image does not cause a decode stall.
63

Text rendering: from glyphs to pixels

Text is the most common thing painted, and it has its own pipeline between layout's line boxes and raster's pixels.

the steps
  1. Shaping (Part 4) gave each run a sequence of glyph ids and positions.
  2. Font loading. The glyph outlines come from the font file, parsed once and cached per font face and size.
  3. Rasterising glyphs. Each glyph at each size is rasterised once into a glyph atlas, a texture of small bitmaps, and reused. Subpixel positioning means a glyph may be rasterised at a few horizontal offsets. Hinting and anti-aliasing (grayscale or subpixel LCD) are chosen per platform and per layer: text on a layer with transforms loses LCD anti-aliasing because the compositor cannot do subpixel blending through a transform, which is the "text looks thinner during an animation" effect.
  4. Drawing. Raster copies atlas entries into the tile. Thousands of glyphs per frame is routine.
what costs
  1. Many sizes and weights of a font means many atlas entries. Fluid typography with continuous sizes is more atlas churn than a fixed scale.
  2. Text in a transformed layer may be re-rastered as the transform changes, and loses LCD AA.
  3. Text with text-shadow or a filter repaints its blurred area.
  4. Variable fonts are one file but each axis setting is a new rasterisation.
64

Reading paint and composite in the profile

What you seeWhat it meansWhat to do
Paint (green) every frame during an animationAnimating a paint or layout propertyMove to transform/opacity; FLIP
Large Paint after a small hoverShadow, blur, or an overlapping large element in the same layerReduce blur radius; promote the hovered element; contain: paint
Rasterize Paint (on raster threads) dominatingBig layers re-rastering, large images, blursLayer sizing; serve correctly sized images; fewer effects
Composite Layers taking several msToo many layers, or very large onesLayers panel; remove will-change; fix overlap promotions
Image Decode blocks before a paintSynchronous decode of a large imagedecoding="async"; right-size the image; Image.decode()
Frames track shows dropped (red) frames with little main-thread workGPU or raster bound, not main-thread boundLayer memory; raster cost; check the GPU track
Scroll handling on the main thread per scroll eventNon-passive listener or layout reads in handlerpassive: true; IntersectionObserver; scroll-driven animations
the single most useful overlay
Rendering panel → Frame Rendering Stats puts a live FPS meter and GPU memory counter on the page. If the number drops while the main thread is idle in the profile, the problem is raster or GPU, and the Layers panel is where to look. If it drops with a busy main thread, Parts 3 and 4.