Paint vs Layout vs Composite: What Actually Happens When the Browser Renders a Frame
Part 1 of the Senior Frontend Interview Deep-Dive Series
If you've ever been told to "avoid layout thrashing" or "prefer transform over top/left for animations" without anyone explaining why, this article is for you. Paint, Layout, and Composite are the three most misunderstood stages of browser rendering - misunderstood not because they're complicated, but because most explanations stop at the vocabulary instead of the mechanics.
By the end of this article you should be able to explain, from memory, what happens between a JavaScript state change and pixels appearing on screen - and why that path can cost you 2ms or 200ms depending on what you touched.
1. Intuition / Mental Model
Think of rendering a web page like producing a stage play, frame by frame:
- Layout is the stage crew deciding where every actor stands and how big the set pieces are. If an actor's position depends on another actor's size, the crew has to work that out for the whole stage before anyone can move.
- Paint is filling in the actors' costumes and makeup - recording the actual visual detail (colors, shadows, text, borders) once positions are fixed.
- Composite is the camera operator layering pre-shot footage together - taking already-painted layers (some of which were prepared in advance) and assembling the final image the audience sees, often just shifting or fading layers rather than re-shooting anything.
The critical insight: these three stages have wildly different costs. Layout is expensive because it can cascade through the whole document. Paint is expensive because it touches every pixel in a region. Composite is cheap because it's mostly done on the GPU, operating on already-rendered bitmaps.
The entire discipline of browser performance engineering is about pushing work down this pipeline - turning Layout-triggering changes into Paint-only changes, and Paint-only changes into Composite-only changes.
2. What Problem Does It Solve?
The browser needs to convert a tree of HTML/CSS/JS state into a 2D grid of pixels, 60 (or 120) times a second, while that state is actively changing under user interaction, animation, and network updates. Doing this naively - recomputing everything, from geometry to pixels, on every single frame - is computationally impossible at scale for anything beyond a trivial page.
Splitting rendering into distinct stages solves this by allowing partial invalidation: when something changes, the browser only needs to redo the stages actually affected by that specific change, and can often skip Layout and Paint entirely by working at the Composite stage.
3. Why Was It Introduced?
Early browsers (think Netscape/early IE era) had much simpler, largely synchronous rendering models: change something, recalculate the whole render tree, repaint the affected area, done. This was tractable when pages were static documents with occasional DHTML.
As the web moved toward highly animated, JavaScript-driven interfaces (parallax scrolling, drag-and-drop, CSS transitions, SPAs with constant DOM churn), browser vendors needed a way to keep interactions smooth without recomputing geometry for every pixel-level visual change. The introduction of a dedicated compositor thread (Chrome's compositor architecture, shipped progressively through the 2010s, WebKit's tiled compositing, and Firefox's WebRender) was a direct response to this: separate "what things look like" from "where things go" so that opacity fades and transforms could run at 60fps even while the main thread was busy running JavaScript.
4. What Breaks Without It?
Without this separation:
- Every animation would require full layout recalculation per frame. A simple fade-in would force the browser to re-measure the entire affected subtree (and potentially ancestors/siblings) on every tick.
- Scrolling would be janky by default, because scroll position changes would trigger the same expensive pipeline as a DOM mutation.
- The main thread and rendering would be inseparable. Any JavaScript execution (a synchronous event handler, a heavy computation) would directly stall visual updates, because there'd be no independent thread capable of producing a frame from already-prepared layers.
- GPU acceleration would be meaningless, since there'd be no persistent, GPU-resident representation of content to reuse across frames - everything would be re-rasterized from scratch.
In short: the modern "buttery smooth 60fps" web experience - CSS transitions, position: sticky, momentum scrolling, drag interactions - depends entirely on being able to skip Layout and Paint and go straight to Composite.
5. How It Works Internally
Let's walk through the browser's rendering pipeline stage by stage. (This is the Chromium model; other engines differ in implementation but converge on the same conceptual pipeline.)
Stage 1: Style Calculation
The browser resolves CSS rules against the DOM tree to determine the computed style of every element. Output: a render tree (or "computed style tree").
Stage 2: Layout (a.k.a. Reflow)
The browser walks the render tree and computes the exact geometry - position and size in pixels - of every box. This is where width, height, top, left, margin, flex-basis, text wrapping, and so on are resolved into concrete numbers.
Layout is inherently relational: an element's size can depend on its children (intrinsic sizing), its parent (percentage sizing), and its siblings (flex/grid distribution). This is why layout can't always be scoped to a single element - a change can force recomputation of an entire subtree, and in pathological cases, the whole document.
Output: a layout tree (boxes with concrete x/y/width/height).
Stage 3: Paint (a.k.a. Rasterization prep)
Once geometry is known, the browser records the visual instructions for each box: fill colors, borders, shadows, text glyphs, background images, gradients. This step doesn't produce pixels directly - it produces an ordered list of paint records (essentially a display list, similar to a vector drawing's instruction set).
Paint happens per layer, not per element. Elements are grouped into paint layers based on stacking context and paint order.
Stage 4: Layerization / Compositing Layer Assignment
The browser decides which parts of the paint tree get promoted to their own compositing layer - an independently rasterized, GPU-uploadable texture. Triggers for layer promotion include: transform, opacity with animation, will-change, position: fixed in some engines, video/canvas elements, and elements with 3D transforms.
Stage 5: Rasterization
The paint records for each layer are converted into actual pixel bitmaps. In modern engines this happens on a separate raster thread pool, often using the GPU (Skia's GPU backend in Chromium, or WebRender's fully GPU-driven pipeline in Firefox), and layers are typically split into tiles so only visible/near-visible tiles need to be rasterized.
Stage 6: Composite
The compositor thread takes the rasterized layer textures and combines them into the final frame - applying transforms, opacity, clipping, and stacking order. Crucially, this step can run independently of the main thread. If you're only animating transform or opacity, the compositor can produce new frames using the already rasterized texture, just moved/faded, without touching Style, Layout, Paint, or even Rasterization again.
This is the mechanical reason "compositor-only" properties are fast: the expensive stages (1-5) happened once, and subsequent frames are cheap GPU matrix math.
6. Step-by-Step Flow
Here's the concrete pipeline for two contrasting changes:
Case A: element.style.left = '100px'
- Style recalculation (this property changed)
- Layout - full reflow of the element (and potentially affected siblings/ancestors, since
leftaffects box position which can ripple through the flow) - Paint - the element and possibly overlapping elements are repainted because their pixel content moved
- Layerization - no change unless already promoted
- Rasterization - new bitmap generated
- Composite - final frame assembled
Every single stage runs. On a complex page, this is the slowest possible path - often called "layout thrashing" when done repeatedly in a loop.
Case B: element.style.transform = 'translateX(100px)'
- Style recalculation (transform is a compositor-only property)
- Layout - skipped (transform doesn't affect document flow geometry)
- Paint - skipped (the element's visual content, i.e., its pixels, hasn't changed - only its position on screen)
- Layerization - element is (or becomes) its own compositing layer
- Rasterization - skipped on subsequent frames (the bitmap is reused)
- Composite - the compositor thread just repositions the existing texture
This is why senior engineers reach for transform/opacity for animation: you collapse a 6-stage pipeline into effectively one cheap step, run on a thread that isn't blocked by JavaScript.
Here's the same idea in motion - watch how the left-driven box stutters while the transform-driven box glides, even though they're covering the same distance in the same time:
(The stutter isn't exaggerated for effect - it's modeling exactly what happens when the main thread has to re-run Layout and Paint on every frame while also handling other JS work. The transform-driven box never has to wait on the main thread at all once it's promoted to its own layer.)
7. Code / Concrete Example
/* Expensive: triggers Layout -> Paint -> Composite every frame */
.slide-in-bad {
position: absolute;
left: 0;
transition: left 300ms ease-out;
}
.slide-in-bad.active {
left: 300px;
}
/* Cheap: triggers Composite only */
.slide-in-good {
position: absolute;
transform: translateX(0);
transition: transform 300ms ease-out;
will-change: transform; /* hints the browser to pre-promote this to its own layer */
}
.slide-in-good.active {
transform: translateX(300px);
}
You can verify this yourself in Chrome DevTools:
- Open the Performance panel, hit record, trigger the animation, stop recording.
- In the flame chart, look for purple bars (Layout), green bars (Paint), and the teal Composite Layers entries.
- The
left-based animation will show repeated Layout + Paint work on the main thread for every frame. Thetransform-based one will show almost nothing on the main thread after the first frame - the work moves to the Compositor thread/summary track.
You can also toggle "Paint flashing" in DevTools' Rendering tab to visually see which regions repaint on every interaction - a genuinely eye-opening debugging tool for spotting unnecessary paints.
8. Browser / React Internals
Browser side: Chromium's rendering pipeline is split across processes/threads: the main thread (runs JS, Style, Layout, Paint-record-generation), a pool of raster threads (rasterize tiles, often GPU-accelerated via Skia), and the compositor thread (assembles frames from layer textures, independent of main-thread availability). This is often called the Threaded Rendering Architecture. It's the reason a busy main thread (e.g., a heavy synchronous JS computation) doesn't necessarily freeze a transform-based CSS animation - the compositor keeps producing frames from cached layer content.
React's relevance: React itself doesn't touch Paint/Composite directly - it only produces DOM mutations. But the pattern of how React updates state has direct consequences here. A setState that changes inline styles tied to top/left/width triggers full Layout on every re-render; the same animation driven by a CSS class toggling transform (or a library like Framer Motion, which defaults to transform/opacity-based animation) stays compositor-only. This is why performance-conscious React codebases avoid animating layout-affecting CSS properties via state and instead toggle classes or use libraries that default to compositor-safe properties.
React 18 concurrent rendering nuance: Concurrent features (like useTransition) affect when React commits DOM mutations relative to the browser's rendering opportunities, but they don't change which CSS properties are cheap to animate - Layout vs Paint vs Composite cost is a browser-level fact, orthogonal to how React schedules its own work.
9. Real-World Use Cases
-
Sticky headers and parallax effects: implemented with
transformto stay off the Layout/Paint path during scroll. -
Modal/drawer open animations: sliding panels use
transform: translateX/Yinstead of animatingleft/top/margin. -
Skeleton loading shimmer effects: implemented via
background-positionanimation or, better, atransform-based gradient sweep withopacityto stay compositor-only. -
Drag-and-drop libraries (e.g., dnd-kit, react-beautiful-dnd internals): use
transform: translate()to move the dragged element visually without triggering Layout on every pointer-move event, which can fire dozens of times per second. - Video/streaming platforms: video elements and canvas overlays are almost always their own compositing layer so scrubbing controls, subtitles, and overlays can animate without re-rasterizing the video frame itself.
10. Performance Implications
- Layout cost scales with subtree size and relational complexity. A single style change on a flex/grid container can force layout recomputation of every child. This is why deeply nested flex layouts with dynamic content are a common source of jank.
-
Forced synchronous layout ("layout thrashing") happens when JS reads a layout-dependent property (
offsetHeight,getBoundingClientRect(),getComputedStyle()) immediately after writing a style change, forcing the browser to run Layout synchronously mid-script instead of batching it before the next frame. Doing this in a loop (read-write-read-write) is one of the most common real-world performance bugs in dashboards and data grids. - Paint cost scales with the area and visual complexity of what's repainting - box shadows, blur filters, gradients, and text are more expensive to paint than flat fills.
- Composite cost is roughly constant and GPU-bound, largely independent of DOM complexity, which is exactly why it's the target stage for smooth animation.
-
Layer explosion is a real anti-pattern: over-using
will-changeortransformon too many elements creates excessive compositing layers, each consuming GPU memory and increasing composite time - you can trade one performance problem for another.
11. Tradeoffs
| Approach | Pros | Cons |
|---|---|---|
Animate top/left/width
|
Simple, intuitive, no layer management | Triggers full pipeline every frame; janky on complex pages |
Animate transform/opacity
|
Compositor-only, smooth, off main thread | Requires the element to already have appropriate positioning (e.g., position: absolute/fixed) to make sense; can't animate "auto" layout values like height: auto directly |
will-change hinting |
Pre-promotes layers, avoids first-frame jank | Memory cost per promoted layer; overuse degrades composite performance |
CSS Containment (contain: layout paint) |
Scopes layout/paint invalidation to a subtree, protecting siblings | Requires careful application; wrong containment can hide legitimate overflow/rendering |
12. Edge Cases
-
Animating
height: autoto a concrete value cannot be done viatransform(there's no "scale to content size" primitive that preserves reflow of children correctly) - this is a genuine case where Layout-triggering animation may be unavoidable, often solved instead withmax-heighttricks,grid-template-rows: 0fr -> 1fr, or JS-measured height +transform: scaleYapproximations for simple cases. - Text rendering inside a transformed layer can sometimes look blurrier at non-integer pixel offsets due to how rasterized layers are composited - this is the "subpixel rendering" issue, closely related to this topic.
-
position: fixedelements may or may not get their own layer depending on the engine and other properties present - this affects whether scrolling causes repaint of fixed headers. -
Filters and backdrop-filter (blur, drop-shadow) are notoriously expensive at the Paint/Raster stage and can force full-layer repaints even when only
opacitychanges on a sibling, because filter effects often require repainting the whole filtered region. - Nested compositing layers with overlapping transparency can force "layer squashing," where the browser merges multiple layers back into one for correctness, silently undoing your optimization.
13. Common Misconceptions
- "Composite-only properties are always fast." They're fast per-frame, but the initial promotion to a layer (rasterization) still costs Paint. If you promote a large, complex element to its own layer right before animating it, you can still see first-frame jank.
-
"
will-changeis a free performance boost." It reserves GPU memory and can create layers even for elements that never animate, which is wasted cost. It should be applied just before an animation starts and removed after, not left on permanently. - "Layout and Reflow are different things." They're the same stage under different names (Reflow is the legacy/marketing term, Layout is the spec-accurate term used in DevTools and browser source).
-
"Paint only happens on visual changes I make directly." Paint can be triggered by ancestor or sibling changes too - e.g., changing a parent's
background-colorcan force repaint of overlapping children depending on stacking/paint order.
14. Senior-Level Insights
At the senior/staff level, the interesting discussion isn't "transform is faster than left" - that's table stakes. The deeper conversation is about invalidation boundaries: how do you architect a component tree so that a high-frequency update (drag position, scroll offset, live data ticking) is contained to the smallest possible subtree, ideally one that's already its own compositing layer?
This is where contain: layout style paint (CSS Containment) becomes relevant - it's a direct tool for telling the browser "this subtree's layout/paint cannot affect anything outside it," which lets the browser skip invalidating ancestors/siblings even for Layout-triggering changes. Combined with content-visibility: auto, you get a mechanism for effectively "virtualizing" rendering cost for off-screen content without a JS virtualization library.
Another senior-level thread: understanding that Paint and Layout cost are why virtualized lists exist. A naive long list re-renders (and potentially re-lays-out) thousands of DOM nodes; virtualization isn't just about reducing DOM node count for memory reasons - it's fundamentally about keeping the Layout/Paint invalidation surface small.
15. System Design Scenario
Scenario: You're building a real-time collaborative dashboard (think Figma-lite or a live trading dashboard) with 200+ draggable/resizable widgets, live-updating charts, and multiple users' cursors rendered simultaneously.
Discuss:
-
Cursor rendering: other users' live cursor positions update dozens of times per second over a WebSocket. If each cursor is a DOM element positioned via
top/left, you force Layout on every incoming message - with 10 concurrent users, that's potentially hundreds of forced layouts per second. Solution: render cursors astransform-positioned elements (or a single<canvas>overlay) so updates are Composite-only or fully off the DOM. -
Widget drag: dragging a widget should update its position via
transformduring the drag gesture, and only commit the "real" layout position (e.g., updating a CSS grid or absoluteleft/topin application state) on drag-end - this avoids triggering Layout on everypointermove. - Chart updates: if charts re-render via SVG, frequent data updates can cause Paint-heavy work (redrawing paths); a canvas or WebGL-based chart library sidesteps DOM-driven Paint entirely, trading it for direct raster/GPU drawing.
-
Layer budget: with 200+ widgets, indiscriminately setting
will-change: transformon all of them would create 200+ compositing layers, exhausting GPU memory and actually slowing composite. Discuss selectively promoting only the currently interacted-with widget, demoting it after the interaction ends. - Failure mode to flag: if drag performance degrades specifically on lower-end GPUs, that's a signal of composite-stage bottleneck (too many/too large layers), not a Layout/Paint problem - different profiling and different fix.
16. Interview Questions
Mid-level:
- What's the difference between Layout, Paint, and Composite?
- Why is animating
transformconsidered more performant than animatingleft?
Senior:
- Explain what "layout thrashing" is and how you'd detect and fix it in a real codebase.
- What does
will-changeactually do under the hood, and what are the risks of overusing it? - Walk me through what happens, stage by stage, when a user hovers over a button with a
box-shadowtransition.
Staff:
- Design a rendering strategy for a component that updates 60 times a second (e.g., an audio waveform visualizer) without degrading the rest of the page's frame budget.
- How would you use CSS Containment and
content-visibilityto reduce invalidation scope in a large, deeply nested dashboard, and what are the correctness risks of doing so? - A junior engineer says "I made everything
will-change: transformand now scrolling is slower." Diagnose this.
17. Connection to Other Topics
- Browser Compositing Layers - the direct mechanism underlying the Composite stage described here.
- GPU Acceleration in CSS - explains how rasterized layers get uploaded to and manipulated on the GPU.
- CSS Containment - a tool for scoping Layout/Paint invalidation, directly reducing the cost discussed in section 10.
- Subpixel Rendering - a visual side-effect of compositing transformed layers.
- Layout Thrashing / Critical Rendering Path - the forced-synchronous-layout failure mode is a direct extension of this topic.
- React Rendering & Reconciliation - explains how React's commit phase produces the DOM mutations that feed into this pipeline, but doesn't control the pipeline itself.
18. Notes Template (for personal GitHub notes)
# Paint vs Layout vs Composite
## One-line summary
## Pipeline stages (in order)
1.
2.
3.
## What triggers each stage from CSS/JS
## Compositor-only properties
## Diagnostic tools (DevTools panels/flags used)
## Real bug I've seen / could see in production
## System design scenario I'd bring up in an interview
## Open questions to revisit






Top comments (0)