Paint Commands
Painting does not produce pixels directly: it records an ordered list of drawing commands per layer, which a rasteriser later turns into bitmaps.
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.
When the browser "paints", what is it actually producing, and why does filling the same rectangle sometimes cost ten times more than filling it a different way?
Someone wants a card with a soft shadow, rounded corners and a subtle gradient that still feels instant when they hover it. They are not asking for a rasteriser; they are asking for the interface to look finished and respond immediately.
Paint is when the browser draws the pixels. A pixel is a pixel, so painting a red box and painting a blurred, rounded, gradient-filled box cost roughly the same — it is the same area of screen.
Cost tracks the *commands*, not the area. A flat background-color fill is one cheap op over a rectangle; the same rectangle with a large-radius box-shadow requires a blur, which reads a neighbourhood of source pixels for every destination pixel and cannot use the fast fill path at all.
- Cost tracks the *commands*, not the area. A flat
background-colorfill is one cheap op over a rectangle; the same rectangle with a large-radiusbox-shadowrequires a blur, which reads a neighbourhood of source pixels for every destination pixel and cannot use the fast fill path at all. - A single element can force a much larger region to be repainted than the element itself occupies. Blur radius and spread extend the damaged rectangle outward, and anything overlapping that rectangle is repainted with it.
- Painting the "same" pixels twice is real work. A page with a background image, a translucent overlay, a card and a shadow can rasterise four values into one pixel before anything reaches the screen — overdraw the browser cannot skip because it cannot prove the top layer is opaque.
- Paint order is not source order. Stacking contexts,
z-indexand positioning decide it (Positioning and Stacking Contexts), so an element added at the end of the DOM can force repainting of things declared before it. - The work is not all on one thread, which is why the naive model fails asymmetrically: recording commands happens on the main thread, but turning them into bitmaps usually does not, so a page can appear stalled with a nearly idle main thread — or the reverse.
What is actually happening
In the browser, not in the framework.
- After layout has produced geometry, paint walks the box tree in paint order and records a display list: an ordered sequence of drawing commands — fill this rounded rect with this colour, draw this text run at this baseline with this font, clip to this path, draw this image scaled to this box.
- The display list is a description, not an image. Nothing is coloured yet. This is why paint and raster show as separate work: the record is cheap relative to executing it, and executing it can be deferred, parallelised, or skipped for regions that are off-screen.
- Rasterisation executes the display list into bitmaps, usually in tiles, and in modern engines usually not on the main thread — a raster worker pool or the GPU process does it, frequently by translating commands into GPU draw calls (What Is Actually Inside a GPU).
- Each compositing layer has its own display list and its own bitmaps (Compositing Layers). A change confined to one layer repaints only that layer's damaged region; a change that crosses layers repaints all of them.
- The browser tracks an invalidation rectangle. It repaints the union of the damaged regions, not the whole page, and not just the element — which is why "only the badge changed" and "the whole header repainted" are frequently the same change (Style Invalidation).
- Some commands are structurally expensive: blurs and filters are convolutions; complex clip paths and non-rectangular clipping defeat fast paths; text requires shaping and glyph rasterisation, cached per font-size-and-face but not free on first use.
What this makes the browser do
And which of it is avoidable.
- Walking the paint order and building a display list for every layer whose content was invalidated — main thread, and proportional to the number of painted boxes, not the number of DOM nodes.
- Rasterising the damaged tiles: allocating bitmap memory, executing draw commands, uploading textures to the GPU where a GPU raster path is in use (The Transfer You Forgot to Count).
- Shaping and rasterising glyphs for any text whose font, size or content changed, and maintaining the glyph atlas that makes the second occurrence cheap.
- Decoding images to the size they will actually be drawn at — a decode of an oversized source is paint-adjacent work that shows up as a paint stall (Responsive Images).
- Avoidable: repainting regions nothing changed in, because the invalidation was declared at too coarse a granularity; and overdraw from stacked translucent surfaces that could have been flattened in the design.
Record, then rasterise, then composite
The stage most people call "painting" is three stages, and they run in different places. Paint walks the laid-out boxes in paint order and produces an ordered list of drawing commands per layer. Raster executes those commands into actual bitmaps, in tiles, typically off the main thread. Composite assembles the finished bitmaps into the frame the display shows.
Separating them is not pedantry — it is diagnostic. If your main thread is busy in paint, you are recording too many commands over too much area. If the main thread is idle but frames are late or content flashes blank while scrolling, raster is behind. If neither is busy and the frame is still late, the cost is upstream in style or layout, or downstream in the compositor.
- The display list is a description, which is why it can be recorded once and executed for several tiles, or discarded for regions that are never seen.
- Damage is tracked as a rectangle, and the union of damaged rectangles is what gets repainted — one change in a corner plus one in the opposite corner can damage nearly the whole surface.
- Off-screen tiles are commonly not rasterised at all, which is why the first moments of a fast scroll can show empty regions (Scroll and Input Latency).
- Every layer costs at least one bitmap; that is the memory side of the bargain (Compositing Layers).
Which changes actually cost a paint
The useful question about any style change is which pipeline stages it invalidates. Some changes are purely a repaint of an existing box; some force geometry to be recomputed first; a few can be handled by the compositor without repainting anything at all. The honest answer for many of them is "it depends what else is on the page", which is what maybe is for.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| `color` on a text node | yes | no | yes | yes | Geometry is unchanged, but the glyphs must be re-rasterised in the new colour over the damaged text region. |
| `background-color` on a card | yes | no | yes | yes | One fill command over the card's rectangle — about as cheap as a repaint gets, and the cost is the area. |
| `box-shadow` blur radius | yes | no | yes | yes | A convolution over a region larger than the element. Cost scales with blur radius and device pixel ratio, not with element size alone. |
| `border-radius` on a scrolling container | yes | no | yes | maybe | A non-rectangular clip applied to everything inside; it can force the contents onto a separate surface so the clip can be applied at composite time. |
| `width` on a flex item | yes | yes | yes | yes | Geometry changes for the item and typically its siblings, so paint follows layout across the whole flex line (Layout Thrashing). |
| `transform: translate` on a promoted layer | yes | no | no | yes | The bitmap already exists; the compositor draws it at a different offset. Only true while the element genuinely has its own layer (Cheap and Expensive Animation). |
| `opacity` on non-promoted content | yes | no | maybe | yes | Without a layer this is a blend performed during raster, and it forbids skipping whatever is underneath. |
| `visibility: hidden` | yes | no | yes | yes | The box keeps its geometry, so no layout — but the region it occupied must be repainted with whatever is behind it. |
| `filter: blur()` on a hovered image | yes | no | yes | maybe | Filters often promote the element, which moves the cost from repaint-per-frame to memory-plus-one-raster — a trade, not a win. |
caveat Every maybe here is real. Promotion, containment, stacking context and what overlaps the element all change the answer, and the only way to know for your page is to look at the layers and the paint flashing (The Cost of a Change).
Shrink the damage, then shrink the command
filter, will-change or a rounded clip triggers layer promotion is an engine heuristic, not a specification: Blink documents its compositing triggers and changes them between releases, WebKit promotes more conservatively on memory-constrained iOS devices, and Gecko's WebRender path reaches the same visual result with a different internal split — so verify the layer count in each engine you support rather than assuming the Chrome answer.Most paint problems are area problems wearing a command costume. Before reaching for a cheaper effect, find out how large a region the change is damaging — the answer is often "far more than the element", because of a shadow margin, a parent that had to be repainted, or an invalidation declared on an ancestor.
The comparison below is the pattern that shows up most: a hover effect written the obvious way re-blurs a shadow on every pointer move, and written the second way blends an already-rasterised shadow. The pixels are indistinguishable; the per-frame work is not.
.card {
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
transition: box-shadow 200ms;
}
.card:hover {
box-shadow: 0 12px 32px rgb(0 0 0 / 0.35);
}.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 32px rgb(0 0 0 / 0.35);
opacity: 0;
transition: opacity 200ms;
}
.card:hover::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.card::after { transition-duration: 1ms; }
}Interpolating a blur radius means re-running the convolution for every intermediate value, over a region larger than the card. Interpolating opacity on a pre-painted shadow means the expensive raster happens once and every subsequent frame is a blend — and if the pseudo-element gets its own layer, the compositor can do it without touching the main thread at all.
1/* A live-updating widget in a long page. Without containment, its2 invalidation can escape into ancestors that never changed. */3.ticker {4 contain: content; /* layout + paint containment */5 content-visibility: auto;6 contain-intrinsic-size: auto 240px; /* so scrollbars stay honest */7}Containment is a promise you are making to the browser: nothing inside this box affects anything outside it. If that is untrue — an absolutely positioned tooltip escaping the box, say — you will get clipping rather than a warning (CSS Containment).
How to build it
Most important first.
- Reduce the damaged area before reducing the command cost. Painting a small region expensively usually beats painting a large region cheaply, and containment lets you say so explicitly (CSS Containment).
- Prefer commands with fast paths: solid fills, rectangular clips, simple borders. Reserve blur,
filterand non-rectangular clipping for surfaces that do not change often. - If a decorative effect is static, let it be static. A shadow that never animates is painted once and reused; a shadow whose blur radius is interpolated is re-blurred every frame (Cheap and Expensive Animation).
- Fake expensive effects where you honestly can: a pre-rendered shadow image, or an opaque layer faded with
opacityinstead of a re-blurred shadow, moves the cost from every frame to one. - Keep large scrolling surfaces free of translucency stacks. Opaque backgrounds let the rasteriser skip everything underneath; a chain of semi-transparent panels forbids that.
- Measure the paint region rather than reasoning about it. Paint flashing highlights what actually repainted, and it routinely disagrees with the mental model (Debugging Rendering and Jank).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Paint cost is felt hardest by the users with the least headroom. A repaint-heavy hover effect that is imperceptible on a desktop GPU makes a low-end device drop frames, and a dropped-frame interface is measurably harder to use for anyone with a motor or attention impairment.
- Focus indicators are painted like anything else. Removing an
outlinebecause it "costs a paint" removes the only signal a keyboard user has about where they are — the correct move is to paint a cheaper indicator, never none (Keyboard Operability). - Never express state through paint alone. A colour-only hover or error state is invisible to a screen-reader user and unreliable for users with low vision or colour-vision deficiency; the state must exist in the semantics too (Contrast, Colour and Motion).
- Heavy decorative painting frequently arrives with heavy decorative motion. Anything that animates a painted property must still respect
prefers-reduced-motion, because the cheapest paint is the one a user has explicitly asked you not to perform.
What can go wrong
- An invalidation that is technically correct but far too broad — a class toggled on
<body>to theme one widget — repaints everything and shows up as a paint spike with no obvious cause. - A
box-shadowon a list item that changes on hover: the blur repaints the item plus its shadow margin, and on a long list under a fast pointer this fires continuously. - The mitigation failing: promoting the element to its own layer to isolate the repaint works, and then the layer's memory cost or the extra compositing pass costs more than the repaint did (Layer Explosion).
- Raster falling behind the main thread. The display list is recorded on time, the bitmaps are not ready, and the compositor shows the previous tile — visible as a blank or stale region while scrolling fast.
- Image decode on the critical path: the display list references an image that is not decoded yet, so the frame either waits or draws without it (Images and Fonts).
- Raster of a damaged tile can complete after the frame that needed it, so the compositor shows the previous tile — a stale or blank region that resolves a frame later.
- Image decode races the display list that references the image: the frame either waits or draws without it, which is why a late-decoding hero image can appear one frame after the layout that reserved space for it.
- A font arriving mid-paint changes glyph metrics, so text already painted is repainted and re-laid-out — the ordering between the font load and the first paint decides whether the user sees the shift (Images and Fonts).
- The browser will not let you read back what it painted across an origin boundary. Rendering a cross-origin image or a cross-origin iframe and then trying to sample the result taints the canvas or is simply not exposed — this is deliberate, and it is what stops pixel-reading from becoming a cross-origin data leak.
- Paint timing has historically been an attack surface: differences in how long a page takes to render a link, a filter, or a blend mode have been used to infer cross-origin state. Engines mitigate this by restricting timing granularity and by not exposing per-element paint timing.
- Nothing about paint authorises anything. Content that is painted off-screen, clipped away, or drawn underneath an opaque layer is still fully present in the DOM and fully readable — hiding by painting is not hiding (Authorization-Aware UI).
- User-controlled CSS is a real injection surface: a value that reaches
filter,background-imageorclip-pathcan trigger network requests or expensive rendering, so treat style strings as untrusted input like any other (Sanitization and Trusted HTML).
- "Paint means pixels are on screen." Paint records commands; raster produces pixels; composite puts them on screen. Confusing the three sends you optimising the wrong stage.
- "Painting a smaller element is cheaper." Only if the damaged region is smaller. A tiny element with a wide shadow damages a large rectangle.
- "Repaints are always bad." They are the normal cost of a visible change. The problem is repainting more than changed, or repainting every frame.
- "Opacity is free because it does not repaint." It is cheap on a promoted layer being blended by the compositor. On non-promoted content it is a paint-time blend, and a stack of them is overdraw (Cheap and Expensive Animation).
- "The GPU does the painting, so it is fast." The GPU commonly rasterises, but the display list is recorded on the main thread, and pushing more surfaces at the GPU has its own memory and upload cost (CPU or GPU: Two Bets About What Work Looks Like).
Measuring it, and what changes in the field
- Paint flashing in the rendering tools: it highlights the region actually repainted, which is the single fastest way to discover that a small change is damaging a large area (Debugging Rendering and Jank).
- The Performance panel's Paint and Rasterize entries under a frame, plus the paint profiler where available, which shows the recorded display list command by command.
- Layer borders and the layers view, to see how many layers exist and which one carries the repaint (Compositing Layers).
- For real users, none of the above exists — you get frame-level and interaction-level signals only, so paint cost surfaces as poor interaction latency in the field rather than as a paint metric (Vitals in the Field).
- General profiling method applies unchanged: reproduce, measure, change one thing, measure again (Measure Before Optimising).
- On a low-end device with an integrated GPU and shared memory bandwidth, blur and filter cost scale badly, and the raster pool is smaller — the same page paints comfortably on a laptop and drops frames on a mid-range phone (When the Memory Bus Is the Bottleneck).
- On a high-density display, every painted region covers more physical pixels. A 3x device paints roughly nine times the pixels of a 1x device for the same CSS rectangle (The Viewport and Device Pixels).
- On a large viewport, full-width surfaces are enormous. The same header costs far more on a desktop monitor than on a phone.
- With a large dataset, paint cost is bounded by what is on screen, not by row count — which is why virtualisation helps paint even though the underlying array is unchanged (List Virtualization).
- Cheaper paint often means flatter design. Removing blurs, translucency and soft shadows is a real visual cost, and "make it cheaper to paint" is a design negotiation, not a purely technical one.
- Pre-rendering an effect as an image trades main-thread paint work for network bytes, memory, and an asset that no longer adapts to theme or size changes.
- Containment and layer promotion buy isolation and spend memory plus compositing work; both can invert on memory-constrained devices (CSS Containment, Layer Explosion).
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.
- GENERALThe separation of paint (record a display list) from raster (execute it into bitmaps) from composite (assemble the frame) is common to Blink, Gecko and WebKit; all three record before they rasterise and all three rasterise off the main thread in the common case.
- ENGINE-SPECIFICThe names, granularity and tooling differ sharply: Blink records a display list and rasterises in tiles via a raster worker pool with Skia, Gecko uses WebRender to push most drawing to the GPU as batched primitives, and Safari exposes far less paint-level detail in its timeline — so a Chrome paint profile does not transfer to Safari as a diagnosis, only as a hypothesis.
- DEVICE-SPECIFICBlur, filter and large-surface fill cost scale with device pixels and available memory bandwidth, so the same effect that is free on a discrete GPU is a frame-dropper on an integrated one at 3x density.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Computer Graphics — how a 2D rasteriser actually executes fills, strokes, gradients and convolutions, and why a separable blur is two passes rather than one.