Search IOCombats

Search challenges, guides, questions and articles

How Chrome Actually Renders a Frame: Inside the RenderingNG Pipeline
browser internalsrendering performanceRenderingNGcompositor threadCSS containmentinterview prep

How Chrome Actually Renders a Frame: Inside the RenderingNG Pipeline

By Ghazi Khan | Aug 31, 2026 - 8 min read

If you have ever been asked "what happens between changing a CSS property and seeing it on screen" in an interview, you have probably answered with the old three-word answer: layout, paint, composite. That answer is not wrong, it is just twenty years out of date. Chrome rewrote its entire rendering engine around an architecture called RenderingNG, and the real pipeline has eight distinct stages split across two threads that barely talk to each other by design.

Understanding this pipeline is not trivia. It is the difference between knowing that transform: translateX() is "faster" than changing left, and knowing exactly why, down to which thread does the work and which stages get skipped entirely. It is also the difference between guessing at a jank problem in DevTools and reading the Performance panel like a map.

The eight stages, and the thread each one runs on

RenderingNG splits work into a main thread pipeline and a compositor thread pipeline. The main thread owns the DOM and JavaScript, so it does anything that needs to know what an element actually is. The compositor thread owns pixels once they exist, so it does anything that just needs to move rectangles around.

Diagram
100%
flowchart TD subgraph MT["Main Thread"] A["Style<br/>compute styles for every DOM node"] --> B["Layout<br/>compute size and position, build fragment tree"] B --> C["Pre-paint<br/>build property trees, invalidate stale tiles"] C --> D["Paint<br/>record a display list of drawing instructions"] end subgraph CT["Compositor / GPU Thread"] E["Raster<br/>turn display list into GPU texture tiles"] --> F["Activate<br/>build a compositor frame from tiles + effects"] F --> G["Aggregate<br/>merge frames from all layers/iframes"] G --> H["Draw<br/>submit the frame to the GPU, pixels appear"] end D --> E
visualized byIOCombats

Each stage does exactly one job:

Style. The browser has already parsed HTML into the DOM and CSS into the CSSOM. Style resolves every selector against every node and produces a computed style for each one, cascade, inheritance, and specificity all collapse into a single set of resolved values per element.

Layout. Using those computed styles, the browser walks the tree and calculates the actual geometry, width, height, x/y position, for every box. The output is an immutable fragment tree (the modern replacement for the old mutable "layout tree"). Immutability here matters for correctness: once a fragment is produced it never changes, so partial re-layouts cannot leave the tree in an inconsistent state.

Pre-paint. This stage computes property trees (transform, clip, and effect trees) and figures out which parts of the previous frame are now invalid, so the browser does not have to repaint pixels that did not change.

Paint. This does not draw actual pixels yet. It produces a display list, an ordered set of drawing instructions like "draw this rectangle with this fill" or "draw this text run at this baseline." Think of it as a recipe, not the finished dish.

Raster. This is where the display list finally becomes pixels, specifically GPU texture tiles. Chrome tiles the page into fixed-size chunks (typically 256x256 or 512x512) so it only has to re-rasterize the tiles that actually changed, not the whole page.

Activate. The raw tiles get assembled into a compositor frame, a data structure describing exactly how to position and blend those tiles with visual effects like opacity or transforms applied.

Aggregate. A page is rarely one layer. Iframes, scrollable regions, and elements with will-change each produce their own compositor frame. Aggregate merges all of them into one frame for the whole viewport.

Draw. The aggregated frame is handed to the GPU, which executes it and produces the pixels you actually see.

Why this split makes some animations "free"

The reason this architecture exists is isolation. If every animation frame had to re-run Style through Paint on the main thread, a single expensive JavaScript function would stall your scroll and your animations at the same time, because they would all be competing for the same thread.

RenderingNG avoids this by giving the compositor thread the ability to redraw certain properties, transform and opacity, without going back to the main thread at all. If an element is on its own compositor layer, animating its transform only requires re-running Activate, Aggregate, and Draw. Style, Layout, Paint, and even Raster can be skipped for that frame.

Diagram
100%
sequenceDiagram participant JS as Main Thread (JS/Style/Layout/Paint) participant CT as Compositor Thread participant GPU as GPU Note over JS,CT: Animating `transform` on a layer-promoted element JS->>CT: Initial paint + raster (once) loop Every frame (60fps) CT->>CT: Update transform matrix only CT->>GPU: Activate, Aggregate, Draw end Note over JS,CT: Animating `top`/`left` (triggers layout) loop Every frame (60fps) JS->>JS: Style, Layout, Pre-paint, Paint JS->>CT: New display list, Raster CT->>GPU: Activate, Aggregate, Draw end
visualized byIOCombats

That second loop is the one that drops frames under load, because it forces the full main-thread pipeline to run 60 times a second, competing with every other script, timer, and event handler on that same thread.

Layout thrashing: the main-thread bug hiding in plain sight

The pipeline explains a classic performance bug that shows up constantly in interviews and in real codebases: layout thrashing. It happens when you interleave reads and writes to layout-dependent properties inside a loop.

// Bad: forces a synchronous layout on every iteration
function resizeCards(cards) {
  cards.forEach((card) => {
    const width = card.offsetWidth; // READ: forces layout to flush
    card.style.width = `${width * 1.1}px`; // WRITE: invalidates layout
  });
}

Reading offsetWidth requires an up-to-date fragment tree, so if a previous write already invalidated layout, the browser cannot answer from cache. It has to run Layout synchronously, right there in your loop, before returning the value. Do that N times and you get N forced synchronous layouts instead of one.

The fix is to separate all reads from all writes so layout only has to run once:

// Good: batch all reads, then all writes
function resizeCards(cards) {
  const widths = cards.map((card) => card.offsetWidth); // all reads first
  cards.forEach((card, i) => {
    card.style.width = `${widths[i] * 1.1}px`; // all writes after
  });
}

This is the same principle behind libraries like FastDOM, and it is the single most common root cause you will find when a "why is this app janky" bug turns out to be self-inflicted.

Getting onto the compositor's fast path

Not every element gets its own compositor layer by default, that would be wasteful, since every layer costs GPU memory for its texture tiles. An element gets promoted to its own layer when the browser has a reason to expect it will need one, most commonly:

TriggerWhy it promotes
will-change: transform or opacityExplicit hint that this property will animate
transform: translateZ(0) or translate3d()Forces 3D compositing context (older, still works)
<video>, <canvas>, WebGL contextsContent is already GPU-backed
position: fixed (in many cases)Needs to stay stable during scroll compositing
CSS animations/transitions on transform/opacityBrowser promotes automatically once detected

The practical rule: if you are animating a property, animate transform and opacity, not top, left, width, or height. The former can run entirely on the compositor thread once promoted; the latter force a full main-thread pipeline run every frame.

Do not overuse will-change as a blanket fix, though. Every promoted layer holds GPU texture memory for as long as it exists, and on memory-constrained devices, too many layers causes its own performance regression. Apply it selectively, right before an animation starts, and remove it after.

Skipping work you do not need: content-visibility

RenderingNG also gives you a direct lever to skip stages entirely for off-screen content, the content-visibility property.

.comment-row {
  content-visibility: auto;
  contain-intrinsic-size: auto 120px;
}

content-visibility: auto applies layout, paint, and hit-testing containment to an element. When it is off-screen, the browser skips Layout and Paint for its entire subtree, treating it as an opaque box of the size given by contain-intrinsic-size. That size hint matters: without it, the element collapses to zero height until it scrolls into view, which breaks scrollbar length and causes visible jumps. With it, the browser reserves the right amount of space up front and only does the real work once the content nears the viewport.

On long, repetitive pages (comment threads, product grids, changelogs), this single property has been measured cutting layout-and-paint time from roughly 232ms down to 30ms on a chunked page, a real, verifiable result from Chrome's own web.dev writeup. Apply it to repeating leaf-level containers, not to your whole page shell, or you risk delaying the render of content the user actually needs first.

Diagram
100%
flowchart LR A["Element far off-screen"] -->|"content-visibility: auto"| B["Treated as opaque box<br/>sized by contain-intrinsic-size<br/>Layout + Paint SKIPPED"] B -->|"user scrolls near it"| C["Browser runs real Layout + Paint<br/>for that subtree only"] C -->|"scrolls back away"| B
visualized byIOCombats

Reading the pipeline in DevTools

Once you know the stage names, the Performance panel stops being a wall of colored bars and starts being a diagnosis tool. Purple bars are Layout, green bars are Paint, and if you see either one firing repeatedly during a scroll or animation, that is your signal the work is happening on the main thread instead of the compositor. The Layers panel (under More Tools) shows you exactly which elements got promoted to their own compositor layer and why, which turns "is this animation composited" from a guess into a fact you can check.

Practical takeaway

When you are optimizing an animation, ask which thread each property change belongs to before reaching for a fix. Prefer transform and opacity for anything that moves or fades. Use will-change narrowly and remove it when the animation ends. Batch DOM reads and writes so you never trigger more than one forced layout per frame. For long lists, apply content-visibility: auto with an explicit contain-intrinsic-size. And when an interviewer asks what happens after you change a style, you now have eight real stages to walk through instead of three approximate ones.

Conclusion

The pipeline is not an implementation detail you can safely ignore. It is the actual mechanism deciding whether your UI holds 60fps or stutters, and every major performance technique in frontend engineering, from will-change to content-visibility to batched DOM reads, is really just a way of telling this pipeline to do less work, or to do that work on the thread that will not compete with your JavaScript.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge