DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Foundations & Tooling/Browser Internals & Critical Rendering Path
intermediate25 min read·Updated Dec 2024Expert reviewed

Browser Internals & Critical Rendering Path

The browser turns bytes into pixels through a precise pipeline: parse HTML → build DOM → parse CSS → build CSSOM → combine into render tree → layout → paint → composite. Knowing where this pipeline stalls is the foundation of every performance optimization.

browserrenderingDOMCSSOMlayoutpaintcompositeperformance

Knowledge Check

4 questions · pass at 70%

0/4
mediummcq

1. A developer changes an element's `left` property via JS inside a `requestAnimationFrame` callback. What rendering steps does this trigger?


Interview Questions

1 questions

Cheatsheet

Download-ready reference
Cheatsheet
Rendering Pipeline:
  Bytes → DOM
  CSS Bytes → CSSOM
  DOM + CSSOM → Render Tree (display:none excluded)
  Render Tree → Layout (position/size)
  Layout → Paint (draw calls, per-layer)
  Paint → Composite (GPU, off main thread)

CSS Properties by rendering cost:
  Layout    width, height, top, left, margin, padding, font-size
  Paint     color, background, border, box-shadow, outline
  Composite transform, opacity   ← prefer these for animation

Layout thrashing fix:
  BAD:  read → write → read → write (interleaved)
  GOOD: read all → write all (batched)

Key APIs:
  requestAnimationFrame()    run before next paint (16.6ms budget at 60fps)
  ResizeObserver             observe element size changes
  IntersectionObserver       observe viewport entry/exit (no layout cost)
  getBoundingClientRect()    forces layout flush — batch these reads

GPU layer triggers:
  will-change: transform|opacity
  transform: translateZ(0)  (hack, use will-change instead)
  position: fixed/sticky
  video, canvas elements
  CSS filters

Process model:
  Renderer process   JS engine (V8) + layout + paint — sandboxed
  GPU process        compositing
  Network process    all I/O
  Browser process    UI coordination

Related topics

Request Lifecycle (browser → server)Core Web Vitals (LCP, CLS, INP)HTTP & Networking (HTTP/1.1 vs 2 vs 3)ProRendering OptimizationPro

On this page

25m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

Modern browsers are extraordinarily complex. Chrome has ~35 million lines of code. But the part that frontend engineers need to own deeply is the rendering pipeline — the sequence of steps that converts HTML bytes into visible pixels. Every Core Web Vitals metric maps directly to a stage in this pipeline.

Browser process architecture#

Chrome (and most modern browsers) use a multi-process architecture for security and stability:

  • Browser process — UI (tabs, address bar), coordinates everything
  • Renderer process — one per tab (isolated). Runs the HTML parser, JS engine, layout engine, painter. This is where your code runs.
  • GPU process — handles compositing and drawing to screen
  • Network process — handles all network I/O (separate from renderer for security)
  • Plugin/extension processes — isolated to prevent crashes from taking down tabs

The renderer process runs in a sandbox — it can't access the filesystem or OS directly. This is why a compromised JavaScript context can't exfiltrate files.

The critical rendering path#

Bytes → Characters → Tokens → Nodes → DOM
                                        ↘
CSS bytes → CSSOM ──────────────────── Render Tree → Layout → Paint → Composite
                                        ↗
                              [JavaScript can block both]

Step 1: Parse HTML → DOM

The HTML parser is not a simple left-to-right reader. Key behaviors:

  • Incremental: parsing starts as soon as the first bytes arrive, before the full document is downloaded
  • Recoverable: HTML parsers handle malformed markup via error recovery rules (unlike XML)
  • Blocked by scripts: a classic <script> tag without async/defer pauses the parser, because scripts can call document.write() which injects more HTML

While the main parser is blocked, a background preload scanner speculatively fetches resources it finds in the unparsed HTML (<img>, <link rel="stylesheet">, <script>). This is why even render-blocking resources start downloading early.

Step 2: Parse CSS → CSSOM

CSS Object Model is built from all stylesheets (external + <style> + inline). Unlike HTML, CSS parsing is not incremental — the browser won't render anything until all CSS is downloaded and parsed, because a later rule can override an earlier one.

The CSSOM is the reason:

  • <link rel="stylesheet"> in <head> blocks rendering
  • @import inside CSS is especially bad — it's discovered late and creates additional blocking loads
  • media attributes can make stylesheets non-render-blocking: <link media="print"> doesn't block

Step 3: Render tree

The render tree is the DOM + CSSOM merged, with non-visual nodes removed:

  • display: none elements → removed from render tree (but still in DOM)
  • visibility: hidden elements → kept in render tree (they still occupy space)
  • <head>, <script>, <meta> → not in render tree

Each render tree node is a layout object (a.k.a. "box" in the CSS spec).

Step 4: Layout (Reflow)

Layout calculates the position and size of every element on the page. It starts at the root and recurses.

Layout is expensive — a change to one element can invalidate the layout of many others (e.g., changing a parent's width reflowing all its children). This is called forced synchronous layout or "layout thrashing" when triggered repeatedly in JavaScript.

// Layout thrashing — reads and writes interleaved, forces layout on every iteration
for (const el of elements) {
  const height = el.offsetHeight;   // READ — forces layout to flush
  el.style.height = height + 10 + "px"; // WRITE — invalidates layout
}
 
// Fix: batch reads first, then writes
const heights = elements.map(el => el.offsetHeight); // all reads
heights.forEach

Step 5: Paint

Paint converts the render tree into draw calls — the instructions for filling in pixels. These are grouped into paint layers. Elements that are independently animated (using transform, opacity) get their own compositor layer, which lets the GPU update them without repainting other layers.

What triggers paint: Any visual change that doesn't qualify for composite-only — background color changes, border changes, box-shadow changes, etc.

Step 6: Composite

The compositor takes all the painted layers and combines ("composites") them into the final screen image, on the GPU. Compositing is done off the main thread — this is what makes transform and opacity animations buttery smooth even when the main thread is busy.

Properties that are compositor-only (don't trigger layout or paint):

  • transform (translate, rotate, scale)
  • opacity
  • filter (on GPU-promoted layers)
  • will-change: transform (promotes element to its own layer)
/* Prefer this — compositor only, no layout/paint */
.slide-in {
  animation: slideIn 0.3s ease;
}
@keyframes slideIn {
  from { transform: translateX(-100%); }
  to   { transform: translateX(0); }
}
 
/* Avoid this — triggers layout on every frame */
@keyframes badSlide {


JavaScript and the main thread#

JavaScript runs on the main thread — the same thread that handles HTML parsing, layout, and painting. A long JS task (> 50ms) blocks all of these. This is why:

  • Long JS tasks increase INP (Interaction to Next Paint)
  • requestAnimationFrame schedules work just before paint
  • Web Workers move computation off the main thread (but can't touch the DOM)
  • scheduler.postTask() (and React's startTransition) defer non-urgent work

Common Mistakes#

1. Using left/top for animations#

/* Triggers layout + paint on every frame — janky */
.bad { transition: left 0.3s; }
 
/* Compositor only — smooth 60fps even under load */
.good { transition: transform 0.3s; }

2. Reading layout properties inside loops#

offsetWidth, offsetHeight, clientWidth, getBoundingClientRect(), scrollTop — all of these force a synchronous layout flush if called after a DOM write. Interleaving reads and writes in a loop is called "layout thrashing."

3. Overusing will-change#

will-change: transform creates a new GPU layer for that element. Layers have memory cost. Promoting everything to its own layer consumes VRAM and can actually slow down compositing.

/* Only add during animation, not permanently */
.animated { will-change: transform; }
/* Remove after animation ends with JavaScript */

4. CSS @import in stylesheets#

An @import inside a CSS file is only discovered after the parent stylesheet downloads. It creates a waterfall of blocking requests. Always use <link> tags for each stylesheet, never @import.

5. Large DOM trees#

A DOM with >1,500 nodes increases layout time, paint time, and memory. Virtualize long lists (react-virtual, TanStack Virtual) instead of rendering all rows.


Best Practices#

  • Animate only transform and opacity for 60fps animations.
  • Use content-visibility: auto on off-screen sections — tells the browser to skip layout/paint until they're near the viewport.
  • Defer non-critical CSS — use <link media="print" onload="this.media='all'"> trick or load asynchronously.
  • Minimize DOM depth and size. More nodes = more work on every layout pass.
  • Use requestAnimationFrame for JS-driven visual updates — it runs just before the next frame, never mid-render.
  • Measure layout cost with the Chrome DevTools Performance panel — look for purple "Layout" bars in the flame chart.

Performance Tips#

  • The browser targets 60fps — each frame has ~16.6ms budget. Layout + paint + script must all fit in that budget.
  • Layer promotion has cost. Each GPU layer uses VRAM. Don't promote elements that don't need it.
  • contain: layout isolates layout — changes inside the element don't trigger layout recalculation outside it. Huge win for widget-heavy pages.
  • Chrome's "Layers" panel (DevTools → More tools → Layers) shows you exactly how many compositor layers you have and why each was created.
  • ResizeObserver is cheaper than window.resize — it only fires when a specific element's size changes, not on every window resize event.

((
h
,
i
)
=>
{ elements[i].style.height
=
h
+
10
+
"px"
; });
// all writes
from { left: -100px; }
to { left: 0; }
}