TechLead
Lesson 4 of 22
5 min read
Performance Engineering

Critical Rendering Path

Optimize the browser's rendering pipeline from HTML parsing to pixels on screen for faster page loads

Understanding the Critical Rendering Path

The Critical Rendering Path (CRP) is the sequence of steps the browser takes to convert HTML, CSS, and JavaScript into rendered pixels. Optimizing the CRP means minimizing the time between receiving the first byte of HTML and rendering the first meaningful paint on screen.

The Six Steps of the CRP

  • 1. Construct DOM: Parse HTML into a tree of DOM nodes
  • 2. Construct CSSOM: Parse CSS into a tree of style rules
  • 3. Execute JavaScript: Run scripts that may modify DOM or CSSOM
  • 4. Build Render Tree: Combine DOM and CSSOM, excluding invisible elements
  • 5. Layout: Calculate the exact position and size of each element
  • 6. Paint & Composite: Convert render tree to pixels on the screen

Render-Blocking Resources

CSS is render-blocking by default — the browser will not render any content until the CSSOM is constructed. JavaScript is parser-blocking by default — when the HTML parser encounters a script tag, it must stop, download, and execute the script before it can continue parsing. These two behaviors are the main bottlenecks in the CRP.

// Analyzing render-blocking resources
function findRenderBlockingResources(): void {
  const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];

  const blocking = resources.filter((r) => {
    // CSS files loaded in <head> without media queries are render-blocking
    if (r.initiatorType === 'link' && r.name.endsWith('.css')) {
      return true;
    }
    // Synchronous scripts in <head> are parser-blocking
    if (r.initiatorType === 'script' && !r.name.includes('async') && !r.name.includes('defer')) {
      return true;
    }
    return false;
  });

  console.log('Potentially render-blocking resources:');
  blocking.forEach((r) => {
    const duration = r.responseEnd - r.startTime;
    console.log(`  ${r.name}: ${duration.toFixed(0)}ms`);
  });
}

Inline Critical CSS

Critical CSS is the minimum CSS required to render the above-the-fold content. By inlining it directly in the HTML, you eliminate the render-blocking CSS request for the initial paint. The remaining CSS can be loaded asynchronously.

// Using the 'critical' npm package to extract critical CSS
// build-critical-css.ts
import critical from 'critical';
import * as fs from 'fs';
import * as path from 'path';

interface CriticalResult {
  css: string;
  html: string;
  uncritical: string;
}

async function extractCriticalCSS(htmlFile: string): Promise<CriticalResult> {
  const result = await critical.generate({
    src: htmlFile,
    width: 1300,
    height: 900,
    inline: false,     // Return CSS separately
    minify: true,
    extract: true,     // Remove critical CSS from original stylesheet
    penthouse: {
      blockJSRequests: false,
      timeout: 30000,
    },
  });

  return result;
}

// Next.js approach: Use next/head with inline styles
// This is handled automatically by Next.js CSS optimization,
// but you can further optimize with a custom Document:

// pages/_document.tsx (Pages Router approach)
import Document, { Html, Head, Main, NextScript } from 'next/document';

class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head>
          {/* Inline critical CSS */}
          <style dangerouslySetInnerHTML={{
            __html: `
              /* Critical above-the-fold styles */
              body { margin: 0; font-family: system-ui, sans-serif; }
              .hero { min-height: 100vh; display: flex; align-items: center; }
              .nav { position: sticky; top: 0; z-index: 50; }
            `
          }} />

          {/* Load full CSS asynchronously */}
          <link
            rel="preload"
            href="/styles/main.css"
            as="style"
            onLoad="this.onload=null;this.rel='stylesheet'"
          />
          <noscript>
            <link rel="stylesheet" href="/styles/main.css" />
          </noscript>
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Optimizing Script Loading

The way you load JavaScript has a dramatic impact on the CRP. The three main strategies are: normal (parser-blocking), async (downloads in parallel, executes immediately), and defer (downloads in parallel, executes after DOM is parsed).

// Script loading strategies comparison

// BLOCKING: Parser stops, downloads, executes, then continues parsing
// <script src="app.js"></script>

// ASYNC: Downloads in parallel with parsing, executes immediately when downloaded
// Good for independent scripts (analytics, ads)
// <script async src="analytics.js"></script>

// DEFER: Downloads in parallel, executes after DOM is fully parsed
// Good for scripts that need the DOM or depend on each other
// <script defer src="app.js"></script>

// MODULE: Deferred by default, supports import/export
// <script type="module" src="app.mjs"></script>

// Next.js Script component for optimal loading
import Script from 'next/script';

function OptimizedScripts() {
  return (
    <>
      {/* beforeInteractive: loaded before page hydration (critical scripts) */}
      <Script src="/polyfills.js" strategy="beforeInteractive" />

      {/* afterInteractive (default): loaded after hydration */}
      <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />

      {/* lazyOnload: loaded during idle time */}
      <Script src="https://chat-widget.example.com/widget.js" strategy="lazyOnload" />

      {/* worker: offloaded to a web worker via Partytown */}
      <Script
        src="https://heavy-analytics.example.com/tracker.js"
        strategy="worker"
      />
    </>
  );
}

Measuring CRP Performance

// Comprehensive CRP analysis
function analyzeCRP(): void {
  const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;

  const crpMetrics = {
    // DNS + Connection
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    tls: nav.requestStart - nav.secureConnectionStart,

    // Server response
    ttfb: nav.responseStart - nav.requestStart,
    download: nav.responseEnd - nav.responseStart,

    // Parsing and rendering
    domParsing: nav.domInteractive - nav.responseEnd,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.domContentLoadedEventStart,
    domComplete: nav.domComplete - nav.domInteractive,

    // Total
    totalPageLoad: nav.loadEventEnd - nav.navigationStart,
  };

  console.table(crpMetrics);

  // Count critical resources
  const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
  const criticalResources = resources.filter(
    (r) => r.renderBlockingStatus === 'blocking'
  );

  console.log(`Critical (render-blocking) resources: ${criticalResources.length}`);
  criticalResources.forEach((r) => {
    console.log(`  ${r.name}: ${(r.responseEnd - r.startTime).toFixed(0)}ms`);
  });
}

Key Takeaways

  • Inline critical CSS: Eliminate the render-blocking request for above-the-fold styles
  • Defer non-critical CSS: Load remaining stylesheets asynchronously
  • Use defer/async: Never block HTML parsing with synchronous scripts
  • Minimize critical resources: Fewer resources in the critical path means faster first paint
  • Reduce critical path length: Minimize round trips needed before first render

Continue Learning