Vikram D.

The Critical Rendering Path: How Browsers Render Web Pages

2026-01-0315 min read
The Critical Rendering Path: How Browsers Render Web Pages

Ever wondered what happens when you hit Enter on a URL in your browser? This article will walk you through the Critical Rendering Path, how HTML is rendered into pixels, and how to optimize each step. It is very important to understand this process as it is the foundation of web performance optimization.

What is the Critical Rendering Path?

In simple terms, the Critical Rendering Path is the process behind the scenes that browsers use to render web pages. It is a series of steps that the browser follows to display a web page on the screen.

The faster each step completes, the sooner your users see something on screen. Let's walk through each step.

Step 1: Parsing HTML → Building the DOM

When HTML bytes arrive, the browser doesn't see <div> and <p>—it sees a stream of characters. The parser converts this stream into tokens, then builds a tree structure called the Document Object Model (DOM).

<!DOCTYPE html>
<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    <header>
      <h1>Hello World</h1>
    </header>
    <main>
      <p>Welcome to my site.</p>
    </main>
  </body>
</html>

This becomes a DOM tree:

Parser-Blocking Resources

When the parser encounters a <script> tag, it stops parsing and executes the script. This is because JavaScript can modify the DOM using document.write().

<body>
  <h1>Hello</h1>
  <script src="app.js"></script> <!-- Parser stops here! -->
  <p>This waits for the script to finish.</p>
</body>

Optimization: Use async or defer

<!-- async: Download in parallel, execute when ready (may execute before DOM is complete) -->
<script async src="analytics.js"></script>

<!-- defer: Download in parallel, execute after DOM is complete -->
<script defer src="app.js"></script>
AttributeDownloadExecute
NoneBlocks parsingImmediately
asyncParallelWhen downloaded
deferParallelAfter DOM complete

Interview Question: What is the difference between async and defer in script loading?

Answer: Both download scripts in parallel without blocking HTML parsing. The key difference is execution timing: async executes immediately when downloaded (order not guaranteed), while defer waits until the DOM is fully parsed and executes scripts in order. Use defer for scripts that depend on the DOM; use async for independent scripts like analytics.

Step 2: Parsing CSS → Building the CSSOM

While HTML is parsed, the browser also parses CSS to build the CSS Object Model (CSSOM)—another tree that represents styles.

body {
  font-family: sans-serif;
}

h1 {
  color: blue;
  font-size: 2rem;
}

p {
  color: gray;
}

This CSS becomes a CSSOM tree:

The CSSOM must be fully constructed before rendering can proceed. This makes CSS render-blocking by default.

Why CSS is Render-Blocking

Unlike HTML which can be rendered incrementally, the browser must have the complete CSSOM to know how elements should look. Consider:

/* At the start of the stylesheet */
p { color: red; }

/* 1000 lines later... */
p { color: blue; }

Without the full stylesheet, the browser might render paragraphs red, then suddenly flash to blue—a terrible user experience.

Optimization: Critical CSS

Inline the CSS needed for above-the-fold content directly in <style> tags:

<head>
  <style>
    /* Critical CSS for initial viewport */
    body { margin: 0; font-family: sans-serif; }
    header { background: #333; color: white; padding: 1rem; }
    h1 { margin: 0; }
  </style>
  
  <!-- Load the rest asynchronously -->
  <link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">
</head>

Interview Question: Why is CSS called "render-blocking"?

Answer: CSS is render-blocking because the browser cannot render any content until the CSSOM is fully constructed. Unlike HTML which can be parsed incrementally, CSS rules can override each other (cascade), so the browser must wait for the complete stylesheet before it knows the final styles to apply.

Step 3: Building the Render Tree

Once both DOM and CSSOM are ready, the browser combines them into a Render Tree. This tree contains only the nodes that will actually be rendered.

Key point: Elements with display: none are not included in the render tree.

<body>
  <h1>Visible</h1>
  <p style="display: none;">Not in render tree</p>
  <p style="visibility: hidden;">In render tree, but invisible</p>
</body>
PropertyIn Render Tree?Takes Space?Visible?
NormalYesYesYes
visibility: hiddenYesYesNo
display: noneNoNoNo

Interview Question: What is the difference between visibility: hidden and display: none?

Answer: visibility: hidden keeps the element in the render tree—it still takes up space in the layout, but is invisible. display: none removes the element from the render tree entirely—it takes no space and doesn't participate in layout calculations.

Step 4: Layout (Reflow)

With the render tree built, the browser calculates the exact position and size of each element. This is called Layout or Reflow.

The browser starts from the root and works down, calculating:

  • Width and height of each box
  • Position relative to the viewport
  • Margins, padding, borders

Layout is expensive because changing one element can affect many others.

What Triggers Layout?

  • Reading these properties: offsetWidth, clientHeight, getBoundingClientRect()
  • Changing these properties: width, height, top, left, margin, padding, font-size
  • Adding/removing DOM elements
  • Resizing the window

Optimization: Batch DOM Reads and Writes

// ❌ Bad: Forces multiple layouts
elements.forEach(el => {
  const height = el.offsetHeight; // Read (triggers layout)
  el.style.height = height * 2 + 'px'; // Write (invalidates layout)
});

// ✅ Good: Batch reads, then writes
const heights = elements.map(el => el.offsetHeight); // All reads first

elements.forEach((el, i) => {
  el.style.height = heights[i] * 2 + 'px'; // All writes together
});

Interview Question: What is layout thrashing and how do you prevent it?

Answer: Layout thrashing occurs when JavaScript repeatedly reads layout properties (like offsetWidth) and then writes style changes in the same loop. Each read forces the browser to recalculate layout, causing performance issues. Prevent it by batching all reads first, then doing all writes together.

Step 5: Paint

Now the browser knows where everything goes—it's time to fill in the pixels. Paint creates layers of visual content: backgrounds, borders, shadows, text, images.

Painting is done in layers, which brings us to...

Step 6: Composite

Modern browsers split the page into layers and paint them separately. The Compositor then combines these layers in the correct order.

Why layers? Because if an element changes, the browser only needs to repaint that layer, not the entire page.

Properties that create their own layer (are "cheap" to animate):

  • transform
  • opacity
  • filter

Interview Question: Why is animating transform better than animating left or top?

Answer: Animating transform and opacity only triggers the composite step, which is handled by the GPU and very fast. Animating left, top, width, or height triggers layout recalculation on every frame, which is CPU-intensive and causes janky animations.

Optimization: Animate Transform, Not Layout Properties

/* ❌ Bad: Animating `left` triggers layout on every frame */
.box {
  position: absolute;
  left: 0;
  transition: left 0.3s;
}
.box:hover {
  left: 100px;
}

/* ✅ Good: Animating `transform` only composites */
.box {
  transform: translateX(0);
  transition: transform 0.3s;
}
.box:hover {
  transform: translateX(100px);
}

Putting It All Together: CRP Optimization Strategies

1. Minimize Critical Resources

Critical resources are those that block initial render:

  • CSS files (always render-blocking)
  • JavaScript in <head> without async/defer
<!-- Before: 3 render-blocking resources -->
<link rel="stylesheet" href="reset.css">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="theme.css">

<!-- After: 1 critical resource -->
<style>/* Inlined critical CSS */</style>
<link rel="preload" href="all-styles.css" as="style" onload="this.rel='stylesheet'">

2. Minimize Critical Path Length

The critical path length is the number of round trips needed to fetch critical resources.

Use <link rel="preload"> to start fetching resources early:

<head>
  <link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/hero-image.webp" as="image">
</head>

3. Minimize Bytes

Smaller files = faster downloads.

  • Minify CSS, JS, and HTML
  • Compress with Gzip or Brotli
  • Use modern image formats (WebP, AVIF)
  • Tree-shake unused JavaScript
  • Lazy-load images and components
<!-- Lazy load images below the fold -->
<img src="hero.webp" alt="Hero"> <!-- Loads immediately -->
<img src="photo.webp" alt="Photo" loading="lazy"> <!-- Loads when near viewport -->

Measuring CRP Performance

Core Web Vitals

  • LCP (Largest Contentful Paint): Time until the largest element is visible. Target: < 2.5s
  • FID (First Input Delay): Time until the page responds to user input. Target: < 100ms
  • CLS (Cumulative Layout Shift): Visual stability. Target: < 0.1

Tools

  1. Chrome DevTools Performance Panel: Record and analyze rendering
  2. Lighthouse: Automated performance audits
  3. WebPageTest: Detailed waterfall analysis
  4. Chrome DevTools Coverage Tab: Find unused CSS/JS
// Measure paint timing programmatically
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.startTime}ms`);
  }
});
observer.observe({ entryTypes: ['paint'] });

Real-World Example: Optimizing a Slow Page

Before Optimization

<head>
  <link rel="stylesheet" href="bootstrap.css"> <!-- 150KB -->
  <link rel="stylesheet" href="custom.css"> <!-- 50KB -->
  <script src="jquery.js"></script> <!-- 90KB, blocks parsing -->
  <script src="app.js"></script> <!-- 200KB, blocks parsing -->
</head>

Problems:

  • 2 render-blocking CSS files (200KB)
  • 2 parser-blocking JS files (290KB)
  • Total blocking resources: 490KB

After Optimization

<head>
  <style>
    /* Inlined critical CSS: only what's needed for first paint */
    body { margin: 0; font-family: system-ui; }
    .header { background: #333; color: white; padding: 1rem; }
    .hero { min-height: 60vh; display: flex; align-items: center; }
  </style>
  
  <link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="styles.css"></noscript>
  
  <script defer src="app.bundle.js"></script> <!-- Tree-shaken, no jQuery -->
</head>

Improvements:

  • 0KB render-blocking CSS (critical CSS inlined)
  • 0KB parser-blocking JS (using defer)
  • First paint now happens immediately after HTML parsing

Summary

The Critical Rendering Path consists of:

  1. Parse HTML → Build DOM
  2. Parse CSS → Build CSSOM
  3. Combine → Render Tree
  4. Layout → Calculate positions and sizes
  5. Paint → Fill in pixels
  6. Composite → Combine layers

To optimize:

StrategyAction
Reduce render-blocking CSSInline critical CSS, async load the rest
Reduce parser-blocking JSUse defer or async
Shorten critical pathPreload key resources
Reduce bytesMinify, compress, use modern formats
Optimize layoutBatch DOM reads/writes
Optimize paintAnimate transform/opacity, not layout properties

Understanding the CRP transforms you from someone who "makes pages" to someone who engineers fast experiences. Your users will thank you.

Happy optimizing! 🚀

Loading comments...