Front-end Engineering Lab

CSS Splitting

Extract and inline critical CSS, lazy load the rest

CSS Splitting

CSS can be just as heavy as JavaScript. Extract critical CSS, inline it, and lazy load the rest for optimal First Contentful Paint (FCP).

🎯 The Problem

Without CSS Splitting:
main.css: 500 KB
└─ Blocks render until fully downloaded
└─ FCP: 3.2s ❌

With CSS Splitting:
critical.css: 15 KB (inlined in HTML)
├─ Instant render
├─ FCP: 0.6s ✅
route-dashboard.css: 50 KB (lazy loaded)
route-settings.css: 30 KB (lazy loaded)
vendors.css: 200 KB (lazy loaded)

📊 Impact on Core Web Vitals

MetricWithout CSS SplittingWith CSS Splitting
FCP3.2s0.6s (81% faster)
LCP4.1s1.2s (71% faster)
CLS0.250.05 (80% better)

🔧 Critical CSS Extraction

npm install --save-dev critters
// next.config.js
const CrittersWebpackPlugin = require('critters-webpack-plugin');

module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.plugins.push(
        new CrittersWebpackPlugin({
          preload: 'swap', // Preload remaining CSS
          pruneSource: true, // Remove inlined CSS from external files
        })
      );
    }
    return config;
  }
};

Manual Critical CSS

// lib/critical-css.ts
export function extractCriticalCSS(html: string): string {
  // Extract CSS for above-the-fold content
  const criticalSelectors = [
    'header',
    'nav',
    '.hero',
    '.above-fold',
    '.main-content'
  ];
  
  // Generate CSS for critical selectors only
  return criticalSelectors
    .map(selector => extractStylesForSelector(selector))
    .join('\n');
}
// pages/_document.tsx (Next.js)
import Document, { Html, Head, Main, NextScript } from 'next/document';

class MyDocument extends Document {
  render() {
    const criticalCSS = extractCriticalCSS(this.props.__NEXT_DATA__.page);
    
    return (
      <Html>
        <Head>
          {/* Inline critical CSS */}
          <style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
          
          {/* Preload full CSS (non-blocking) */}
          <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>
    );
  }
}

📦 CSS Code Splitting by Route

Vite (Automatic)

// vite.config.js
export default {
  build: {
    cssCodeSplit: true, // Split CSS by routes automatically
  }
};

// Results in:
// index.css (home page)
// dashboard.css (dashboard page)
// settings.css (settings page)

Webpack

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
      chunkFilename: '[id].[contenthash].css',
    })
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  }
};

🎨 CSS-in-JS Code Splitting

Styled Components

import { lazy, Suspense } from 'react';

// Heavy component with lots of styles
const Dashboard = lazy(() => import('./Dashboard'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Dashboard />
    </Suspense>
  );
}

// Dashboard.tsx
import styled from 'styled-components';

// Styles only load when Dashboard loads!
const Container = styled.div`
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 2rem;
  // ... 100+ lines of styles
`;

export default function Dashboard() {
  return <Container>...</Container>;
}

Emotion

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import { lazy } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

// Styles in HeavyComponent only load when component loads

🚀 Lazy Load CSS

Dynamic CSS Import

function Dashboard() {
  useEffect(() => {
    // Lazy load dashboard-specific CSS
    import('./Dashboard.css');
  }, []);
  
  return <div className="dashboard">...</div>;
}

Load CSS on Interaction

function BlogPost() {
  const [showComments, setShowComments] = useState(false);
  
  const handleShowComments = () => {
    // Load comments CSS only when showing comments
    import('./Comments.css').then(() => {
      setShowComments(true);
    });
  };
  
  return (
    <div>
      <article>Blog content</article>
      
      <button onClick={handleShowComments}>
        Show Comments
      </button>
      
      {showComments && <Comments />}
    </div>
  );
}

📐 CSS Modules (Automatic Splitting)

// Dashboard.module.css
.container {
  display: grid;
  /* ... */
}

// Dashboard.tsx
import styles from './Dashboard.module.css';

function Dashboard() {
  return <div className={styles.container}>...</div>;
}

// CSS only loads when Dashboard component loads!

🎯 Media Query Splitting

Load CSS Based on Screen Size

function App() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  
  useEffect(() => {
    if (isMobile) {
      import('./styles/mobile.css');
    } else {
      import('./styles/desktop.css');
    }
  }, [isMobile]);
  
  return <div>...</div>;
}

Load Print CSS on Demand

function PrintButton() {
  const handlePrint = () => {
    // Load print styles only when printing
    import('./print.css').then(() => {
      window.print();
    });
  };
  
  return <button onClick={handlePrint}>Print</button>;
}

🎨 Tailwind CSS Optimization

Remove Unused Classes

// tailwind.config.js
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  // PurgeCSS removes unused classes automatically
};

// Before: 3 MB of Tailwind
// After: 15 KB (only classes you use)

Critical Tailwind

<!-- Inline critical Tailwind classes -->
<style>
  /* Only utility classes used above the fold */
  .flex { display: flex; }
  .grid { display: grid; }
  .p-4 { padding: 1rem; }
  .text-lg { font-size: 1.125rem; }
  /* ... */
</style>

<!-- Load full Tailwind later -->
<link rel="preload" href="/styles/tailwind.css" as="style" 
      onload="this.rel='stylesheet'">

🔧 Font Loading Strategy

Inline Critical Fonts

<head>
  <!-- Inline critical font (woff2, base64) -->
  <style>
    @font-face {
      font-family: 'Inter';
      font-style: normal;
      font-weight: 400;
      font-display: swap;
      src: url(data:font/woff2;base64,...) format('woff2');
      unicode-range: U+0000-00FF; /* Latin only */
    }
  </style>
  
  <!-- Load full font set later -->
  <link rel="preload" href="/fonts/inter-full.woff2" as="font" 
        type="font/woff2" crossorigin>
</head>

Subset Fonts

# Use only characters you need
npx glyphhanger --subset=*.woff2 --formats=woff2 \
  --whitelist="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

# Before: 500 KB
# After: 50 KB (90% smaller!)

📊 Measuring CSS Performance

// Measure CSS load time
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType === 'css' || entry.initiatorType === 'link') {
      console.log(`CSS loaded: ${entry.name} in ${entry.duration}ms`);
      
      // Send to analytics
      analytics.track('css_loaded', {
        file: entry.name,
        duration: entry.duration,
        size: entry.transferSize
      });
    }
  }
});

observer.observe({ entryTypes: ['resource'] });

🎯 Decision Tree

Is CSS > 50 KB?
├─ Yes → Split it
└─ No → Keep inline

Is it critical (above fold)?
├─ Yes → Inline it
└─ No → Lazy load

Is it route-specific?
├─ Yes → Split by route
└─ No → Load globally

Is it component-specific?
├─ Yes → CSS Modules / CSS-in-JS
└─ No → Global stylesheet

📚 Best Practices

1. Inline Critical CSS (< 15 KB)

<!-- Above fold only -->
<style>
  header { /* ... */ }
  .hero { /* ... */ }
  nav { /* ... */ }
</style>

2. Preload Non-Critical

<link rel="preload" href="/styles/main.css" as="style"
      onload="this.onload=null;this.rel='stylesheet'">

3. Lazy Load Route CSS

// Each route loads its own CSS
const Dashboard = lazy(() => import('./Dashboard')); // Includes Dashboard.css

4. Remove Unused CSS

# Use PurgeCSS
npx purgecss --css main.css --content index.html --output dist/

🏢 Real-World Examples

Google

<!-- Inline critical CSS (5 KB) -->
<style>/* above-fold styles */</style>

<!-- Async load rest -->
<link rel="stylesheet" href="/main.css" media="print"
      onload="this.media='all'">

GitHub

<!-- Split by route -->
<link rel="stylesheet" href="/css/frameworks.css"> <!-- 50 KB -->
<link rel="stylesheet" href="/css/profile.css">    <!-- 20 KB, route-specific -->

📚 Key Takeaways

  1. Inline critical CSS - < 15 KB, above the fold only
  2. Split by route - Each route gets its own CSS
  3. Lazy load non-critical - Below fold, interactions
  4. Remove unused - PurgeCSS, tree-shaking
  5. CSS Modules - Automatic splitting per component
  6. Preload fonts - Critical fonts only
  7. Monitor size - Target < 50 KB per chunk

CSS splitting typically improves FCP by 70-80%. Inline critical, lazy load the rest! 🚀

On this page