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.
What paint is, and what it is not
"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.
- Background phase. Background colour, then background images, then border.
- Float phase, block phase, inline phase. Children by their type, in the stacking order of the next chapter.
- Foreground. Text, replaced content, outlines.
- 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.
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.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.
- The context root's background and borders.
- Descendant stacking contexts with negative z-index, most negative first.
- In-flow, non-positioned block descendants, in tree order.
- Floats.
- In-flow inline content, including the root's own text.
- Positioned descendants with z-index auto or 0, and any stacking contexts with z-index 0, in tree order.
- Descendant stacking contexts with positive z-index, ascending, ties in tree order.
- The root element.
- position fixed or sticky; position absolute or relative with z-index not auto.
- A flex or grid item with z-index not auto.
- opacity less than 1; mix-blend-mode not normal; isolation: isolate.
- transform, filter, backdrop-filter, perspective, clip-path, mask, mask-image, mask-border not none.
- will-change naming any of those properties.
- contain: layout, paint, or strict or content.
- Elements in the top layer: dialog shown with showModal, fullscreen elements, popovers.
<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.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.
- 3D or perspective transforms;
will-change: transformoropacity. - A running CSS animation or transition on transform or opacity.
- video, canvas (accelerated), iframe, plugins.
- The scrolling contents of a scroll container, so scrolling happens on the compositor.
- position: fixed, and sticky in many cases.
- backdrop-filter; certain blend modes;
contain: paintin some combinations.
- 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-changeon a header can promote everything that overlaps it. - Squashing. Chrome tries to put several overlapping elements into one "squashing layer" to limit the count. It helps, until it cannot.
- 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.
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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Large blurs and shadows: the whole blurred area, every time it changes.
- Many small text runs with different fonts: glyph atlas misses.
- Huge images drawn small: decoded at full size, scaled every raster. Serve the size you draw.
- Semi-transparent overlapping content: blending costs per pixel per layer.
- Device pixel ratio: 3x means nine times the pixels of 1x. Phones are the hard case.
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.
- 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.
- CSS transitions and animations on transform, opacity, filter (some), and background-color (recently). The animation curve is evaluated per frame on the compositor.
- Pinch zoom and overscroll effects.
- Animations started via Element.animate() on the same properties.
- 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. - position: fixed or sticky inside the scroller in some combinations, and
background-attachment: fixed. - 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.
- Scroll-linked effects done in JS instead of with
animation-timeline: scroll(), which the compositor can run itself.
Animation: which properties cost what
"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 this | Reruns | Thread | Typical cost per frame |
|---|---|---|---|
| transform, opacity | composite | compositor | under 1 ms |
| filter (blur, etc.) | composite, sometimes raster | compositor, raster | 1 to 5 ms, area-dependent |
| color, background-color, box-shadow, border-color, visibility | paint, raster, composite | main, raster | 2 to 10 ms, area-dependent |
| width, height, top, left, margin, padding, font-size, display | layout, paint, raster, composite | main, raster | 5 to 50 ms, subtree-dependent |
| Anything that changes text wrapping | layout of all following content | main | worst case |
- Transform for motion, opacity for fades. Scale instead of width, translate instead of left.
- will-change just in time. Set it when the interaction begins, remove it when the animation ends. Permanent will-change is a permanent layer.
- 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.
- View Transitions do FLIP for you across DOM states, including across navigations, on the compositor.
- 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.
- Respect prefers-reduced-motion.
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.
- Any layout change dirties the old and new rectangles of every moved box.
- Visual property changes on a box: colours, backgrounds, borders, shadows, outlines, text decoration, visibility.
- Image decode completion: the image's rectangle.
- Selection and caret changes: the affected lines.
- Hover and focus style changes: the element, and anything that overlaps it in the same layer.
- Scrolling a non-composited scroller: the entire scroller, every frame. This is why non-composited scrolling is slow.
- Transform and opacity changes on a composited layer.
- Changes inside a different layer: layers invalidate independently.
- Changes to elements with
contain: paintdo not invalidate outside their box, and nothing outside invalidates them.
- 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. - 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. - 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. - 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.
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.
- Fetch. Bytes arrive, as any resource.
- 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
srcsetandsizesmatter for CPU as well as bandwidth. - Upload. The bitmap is uploaded as a GPU texture for raster.
- Draw. Raster samples it into the tiles.
- Discard. Decoded bitmaps are cached and evicted under memory pressure; scrolling back to an image may decode it again.
- decoding="async" (default for most cases now) lets paint proceed without the image and swaps it in when decoded.
syncforces the decode before paint, which is right only for the LCP image if you want it to appear in one step. - loading="lazy" defers fetch and decode for off-screen images. Never on the LCP image.
- fetchpriority="high" on the LCP image moves it up the network queue.
- 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.
- 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.
- Image.decode() lets JavaScript decode ahead of time and await it, so inserting the image does not cause a decode stall.
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.
- Shaping (Part 4) gave each run a sequence of glyph ids and positions.
- Font loading. The glyph outlines come from the font file, parsed once and cached per font face and size.
- 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.
- Drawing. Raster copies atlas entries into the tile. Thousands of glyphs per frame is routine.
- Many sizes and weights of a font means many atlas entries. Fluid typography with continuous sizes is more atlas churn than a fixed scale.
- Text in a transformed layer may be re-rastered as the transform changes, and loses LCD AA.
- Text with text-shadow or a filter repaints its blurred area.
- Variable fonts are one file but each axis setting is a new rasterisation.
Reading paint and composite in the profile
| What you see | What it means | What to do |
|---|---|---|
| Paint (green) every frame during an animation | Animating a paint or layout property | Move to transform/opacity; FLIP |
| Large Paint after a small hover | Shadow, blur, or an overlapping large element in the same layer | Reduce blur radius; promote the hovered element; contain: paint |
| Rasterize Paint (on raster threads) dominating | Big layers re-rastering, large images, blurs | Layer sizing; serve correctly sized images; fewer effects |
| Composite Layers taking several ms | Too many layers, or very large ones | Layers panel; remove will-change; fix overlap promotions |
| Image Decode blocks before a paint | Synchronous decode of a large image | decoding="async"; right-size the image; Image.decode() |
| Frames track shows dropped (red) frames with little main-thread work | GPU or raster bound, not main-thread bound | Layer memory; raster cost; check the GPU track |
| Scroll handling on the main thread per scroll event | Non-passive listener or layout reads in handler | passive: true; IntersectionObserver; scroll-driven animations |