Front-end Engineering Lab

Critical CSS Inlining

Inline essential CSS for First Meaningful Paint and defer the rest to eliminate render-blocking resources.

Critical CSS Inlining

Problem

External CSS files block rendering. Users see a blank white screen until all CSS loads. For large applications with 100+ KB of CSS, this can take seconds on slow connections.

Solution

Identify and inline critical CSS (styles needed for above-the-fold content) directly in the <head>. Load the rest asynchronously after the page renders.

/**
 * Extract critical CSS from full stylesheet
 */
interface CriticalCSSOptions {
  html: string;
  css: string;
  width: number;
  height: number;
}

async function extractCriticalCSS(options: CriticalCSSOptions): Promise<string> {
  const { html, css, width, height } = options;

  // Using critical package (production solution)
  // This is a simplified example
  const critical = await import('critical');
  
  const result = await critical.generate({
    inline: false,
    base: '',
    html,
    css: [css],
    width,
    height,
    penthouse: {
      timeout: 30000,
    },
  });

  return result.css;
}

/**
 * Inline critical CSS in HTML
 */
function inlineCriticalCSS(html: string, criticalCSS: string): string {
  const styleTag = `<style id="critical-css">${criticalCSS}</style>`;
  return html.replace('</head>', `${styleTag}</head>`);
}

/**
 * Load non-critical CSS asynchronously
 */
function createAsyncCSSLoader(href: string): HTMLLinkElement {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = href;
  link.media = 'print'; // Load with low priority
  
  // Switch to all media once loaded
  link.onload = () => {
    link.media = 'all';
  };

  return link;
}

/**
 * Preload CSS with low priority
 */
function preloadCSS(href: string): void {
  const link = document.createElement('link');
  link.rel = 'preload';
  link.as = 'style';
  link.href = href;
  link.onload = function() {
    const stylesheet = document.createElement('link');
    stylesheet.rel = 'stylesheet';
    stylesheet.href = href;
    document.head.appendChild(stylesheet);
  };
  document.head.appendChild(link);
}

/**
 * Load CSS based on media query
 */
function loadConditionalCSS(href: string, mediaQuery: string): void {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = href;
  link.media = mediaQuery;
  document.head.appendChild(link);
}

// Practical implementation

/**
 * CSS loading strategy manager
 */
class CSSLoadingStrategy {
  private loadedStylesheets = new Set<string>();

  /**
   * Load critical CSS inline (server-side)
   */
  public static generateCriticalCSS(html: string, fullCSS: string): Promise<string> {
    return extractCriticalCSS({
      html,
      css: fullCSS,
      width: 1300,
      height: 900,
    });
  }

  /**
   * Load remaining CSS after critical
   */
  public loadDeferredCSS(href: string): void {
    if (this.loadedStylesheets.has(href)) {
      return;
    }

    const link = createAsyncCSSLoader(href);
    document.head.appendChild(link);
    this.loadedStylesheets.add(href);
  }

  /**
   * Load CSS when user interacts
   */
  public loadOnInteraction(href: string, eventType: keyof WindowEventMap = 'scroll'): void {
    const loadCSS = () => {
      this.loadDeferredCSS(href);
      window.removeEventListener(eventType, loadCSS);
    };

    window.addEventListener(eventType, loadCSS, { once: true, passive: true });
  }

  /**
   * Load CSS when element enters viewport
   */
  public loadOnVisible(href: string, targetSelector: string): void {
    const target = document.querySelector(targetSelector);
    
    if (!target) {
      console.warn(`Target ${targetSelector} not found`);
      return;
    }

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            this.loadDeferredCSS(href);
            observer.disconnect();
          }
        });
      },
      { rootMargin: '50px' }
    );

    observer.observe(target);
  }

  /**
   * Load print styles only when needed
   */
  public loadPrintStyles(href: string): void {
    loadConditionalCSS(href, 'print');
  }
}

// Build-time critical CSS extraction (Node.js)
const buildTimeCritical = `
import * as fs from 'fs';
import { generate } from 'critical';

async function generateCriticalCSS() {
  const result = await generate({
    inline: true,
    base: 'dist/',
    src: 'index.html',
    target: {
      html: 'index.html',
    },
    width: 1300,
    height: 900,
    dimensions: [
      { width: 375, height: 667 },   // Mobile
      { width: 1300, height: 900 },  // Desktop
    ],
  });

  fs.writeFileSync('dist/index.html', result.html);
}

generateCriticalCSS();
`;

// Usage in HTML
const htmlExample = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>My App</title>
  
  <!-- Critical CSS inlined -->
  <style id="critical-css">
    /* Above-the-fold styles */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 60px; background: #333; }
    .hero { height: 400px; background: #f0f0f0; }
    /* Only essential styles here */
  </style>
  
  <!-- Preload main stylesheet (but don't block render) -->
  <link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  
  <!-- Fallback for browsers without JS -->
  <noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
<body>
  <header class="header">Header</header>
  <main class="hero">Hero Section</main>
  
  <script>
    // Load deferred CSS after page load
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = '/styles/deferred.css';
        document.head.appendChild(link);
      });
    } else {
      window.addEventListener('load', () => {
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = '/styles/deferred.css';
        document.head.appendChild(link);
      });
    }
  </script>
</body>
</html>
`;

Client-side Usage

// Initialize CSS loading strategy
const cssLoader = new CSSLoadingStrategy();

// Load main CSS after critical
document.addEventListener('DOMContentLoaded', () => {
  cssLoader.loadDeferredCSS('/styles/main.css');
});

// Load component CSS when user scrolls
cssLoader.loadOnInteraction('/styles/footer.css', 'scroll');

// Load modal CSS when modal element is visible
cssLoader.loadOnVisible('/styles/modal.css', '#modal-trigger');

// Load print styles
cssLoader.loadPrintStyles('/styles/print.css');

Webpack Plugin Configuration

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlCriticalWebpackPlugin = require('html-critical-webpack-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html',
    }),
    new HtmlCriticalWebpackPlugin({
      base: 'dist/',
      src: 'index.html',
      dest: 'index.html',
      inline: true,
      minify: true,
      extract: true,
      width: 1300,
      height: 900,
      penthouse: {
        blockJSRequests: false,
      },
    }),
  ],
};

Impact on Core Web Vitals

Before Critical CSS

  • LCP: 3.5s (waiting for full CSS to load)
  • FID: 250ms (render blocked)
  • CLS: 0.15 (styles load late, layout shifts)

After Critical CSS

  • LCP: 1.2s (content renders immediately)
  • FID: 50ms (no render blocking)
  • CLS: 0.02 (critical styles prevent layout shift)

Performance gain: 65% faster First Meaningful Paint

Best Practices

  1. Keep critical CSS small: Under 14KB (fits in first TCP packet)
  2. Update regularly: Critical CSS should match current layout
  3. Test on real devices: Use Lighthouse with throttling
  4. Inline only critical: Everything else loads async
  5. Use CSS variables: Reduce duplication in critical CSS

Tools

  • critical: Node.js library for critical CSS extraction
  • critters: Webpack plugin (used by Next.js)
  • penthouse: Headless browser critical CSS generator
  • PurgeCSS: Remove unused CSS before extraction

Automation

// GitHub Actions workflow
const githubAction = `
name: Generate Critical CSS
on: [push]
jobs:
  critical-css:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Generate Critical CSS
        run: |
          npm install
          npm run build
          npm run critical-css
`;

Critical CSS inlining is essential for apps serving millions of users on varying network conditions. It's the difference between a 1-second load and a 5-second load on 3G.

On this page