Skip to content

CSS Lazy Loading — Loading Stylesheets on Demand

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about CSS Lazy Loading. We cover key concepts, practical examples, and best practices to help you master this topic.

CSS lazy loading defers non-critical stylesheets until needed, reducing render-blocking requests and improving initial page load performance.

What You'll Learn

By the end of this tutorial, you'll understand how CSS blocks rendering, how to split CSS into critical and non-critical bundles, how to load non-critical CSS asynchronously, and how to use media queries to conditionally load stylesheets.

Why It Matters

Every CSS file referenced in the <head> is render-blocking by default. The browser must download and parse the entire stylesheet before rendering any content. For large applications, this can delay the first paint by 1-3 seconds. Lazy loading non-critical CSS — styles for modals, accordions, below-fold content — removes these files from the critical rendering path.

Real-World Use

An e-commerce site splits CSS into critical (above-fold layout, colors, typography) and non-critical (product description styles, review section, footer). Critical CSS is inlined. Non-critical CSS loads asynchronously after the initial render. The First Contentful Paint drops from 2.1s to 0.8s, and the fully-loaded page weight decreases by 85KB.

CSS Loading Strategies

graph LR
    A[CSS Loading Strategies] --> B[Inline Critical
In head, blocks render] A --> C[Async non-critical
loadCSS technique] A --> D[Media-query-based
Conditional loading] A --> E[On-demand
JavaScript-driven] B --> F[First Paint
~0.5s] C --> G[Non-critical CSS
loads after paint] D --> H[Print, tablet,
dark mode styles] E --> I[Modal, accordion,
component styles] style B fill:#e74c3c,color:#fff style C fill:#4a90d9,color:#fff style D fill:#27ae60,color:#fff style E fill:#f39c12,color:#fff

Critical CSS Inlining

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Critical CSS Inlining</title>

    <!-- Critical CSS inlined directly in head -->
    <style>
        /* Above-fold styles — layout, colors, typography */
        * { box-sizing: border-box; margin: 0; padding: 0; }

        body {
            font-family: system-ui, -apple-system, sans-serif;
            line-height: 1.6;
            color: #1a1a2e;
            background: #ffffff;
        }

        header {
            position: fixed;
            top: 0;
            width: 100%;
            height: 64px;
            background: #1a1a2e;
            color: #ffffff;
            display: flex;
            align-items: center;
            padding: 0 24px;
            z-index: 100;
        }

        .hero {
            margin-top: 64px;
            padding: 80px 24px;
            text-align: center;
            background: linear-gradient(135deg, #667eea, #764ba2);
            color: #ffffff;
        }

        .hero-title {
            font-size: 2.5rem;
            margin-bottom: 16px;
        }

        .hero-subtitle {
            font-size: 1.25rem;
            opacity: 0.9;
        }
        /* End critical CSS — only what's visible above the fold */
    </style>

    <!-- Non-critical CSS loaded asynchronously -->
    <link rel="preload" href="/styles/full.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
    <noscript><link rel="stylesheet" href="/styles/full.css"></noscript>
</head>
<body>
    <header>
        <span class="logo">SiteName</span>
    </header>
    <section class="hero">
        <h1 class="hero-title">Welcome</h1>
        <p class="hero-subtitle">This content renders immediately with inlined CSS.</p>
    </section>
    <!-- Below-fold content will use asynchronously loaded full.css -->
</body>
</html>

Async CSS Loading with loadCSS

// utils/css-loader.js — Async CSS loading utility
class CSSLoader {
    constructor() {
        this.loaded = new Set();
    }

    // Load CSS with the loadCSS pattern
    loadCSS(href, options = {}) {
        if (this.loaded.has(href)) return Promise.resolve();
        this.loaded.add(href);

        return new Promise((resolve, reject) => {
            // Create link element
            const link = document.createElement('link');
            link.rel = 'stylesheet';
            link.href = href;

            if (options.media) {
                link.media = options.media;
            }

            link.onload = () => {
                // Restore media to 'all' if we used a temporary media
                if (options.media) {
                    link.media = 'all';
                }
                console.log(`CSS loaded: ${href}`);
                resolve(link);
            };

            link.onerror = () => {
                console.error(`CSS failed to load: ${href}`);
                reject(new Error(`CSS load error: ${href}`));
            };

            // Insert after the last existing stylesheet
            const lastStyle = document.querySelector('link[rel="stylesheet"], style');
            if (lastStyle && lastStyle.parentNode) {
                lastStyle.parentNode.insertBefore(link, lastStyle.nextSibling);
            } else {
                document.head.appendChild(link);
            }
        });
    }

    // Load CSS with preload polyfill pattern
    loadCSSWithPreload(href) {
        if (this.loaded.has(href)) return;
        this.loaded.add(href);

        const link = document.createElement('link');
        link.rel = 'preload';
        link.as = 'style';
        link.href = href;

        link.onload = () => {
            link.rel = 'stylesheet';
        };

        document.head.appendChild(link);
    }

    // Load CSS when element enters viewport
    loadCSSOnIntersection(href, element, rootMargin = '200px') {
        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    this.loadCSS(href);
                    observer.disconnect();
                }
            });
        }, { rootMargin });

        observer.observe(element);
        return observer;
    }

    // Load CSS on user interaction
    loadCSSOnInteraction(href, element, event = 'click') {
        element.addEventListener(event, () => {
            this.loadCSS(href);
        }, { once: true });
    }

    // Unload a CSS file (remove from DOM)
    unloadCSS(href) {
        const links = document.querySelectorAll(`link[href="${href}"]`);
        links.forEach(link => {
            link.remove();
            console.log(`CSS unloaded: ${href}`);
        });
        this.loaded.delete(href);
    }
}

const cssLoader = new CSSLoader();

// Load below-fold styles when page finishes initial render
document.addEventListener('DOMContentLoaded', () => {
    // Delay non-critical CSS to prioritize critical rendering
    requestAnimationFrame(() => {
        cssLoader.loadCSS('/styles/content.css');
        cssLoader.loadCSS('/styles/footer.css');
    });
});

// Load component CSS on demand
const modalTrigger = document.getElementById('open-modal');
cssLoader.loadCSSOnInteraction('/styles/modal.css', modalTrigger, 'click');

// Load print styles only when printing
if (window.matchMedia) {
    const printMedia = window.matchMedia('print');
    printMedia.addListener((mql) => {
        if (mql.matches) {
            cssLoader.loadCSS('/styles/print.css');
        }
    });
}

Media-Query-Based CSS Loading

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Media Query CSS Loading</title>

    <!-- Only loads on screens wider than 768px -->
    <link rel="stylesheet" href="/styles/desktop.css" media="(min-width: 768px)">

    <!-- Only loads on screens narrower than 768px -->
    <link rel="stylesheet" href="/styles/mobile.css" media="(max-width: 767px)">

    <!-- Print styles — only loads when printing -->
    <link rel="stylesheet" href="/styles/print.css" media="print">

    <!-- Dark mode — conditional via prefers-color-scheme -->
    <link rel="stylesheet" href="/styles/dark.css" media="(prefers-color-scheme: dark)">

    <!-- High-resolution screens -->
    <link rel="stylesheet" href="/styles/retina.css" media="(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)">

    <!-- Orientation-based loading -->
    <link rel="stylesheet" href="/styles/landscape.css" media="(orientation: landscape)">

    <!-- Reduced motion — accessibility -->
    <link rel="stylesheet" href="/styles/reduced-motion.css" media="(prefers-reduced-motion: reduce)">
</head>
<body>
    <p>This page loads CSS conditionally based on device capabilities.</p>
</body>
</html>

Component-Level CSS Loading

// components/accordion.js — Self-loading CSS component
class Accordion {
    constructor(container) {
        this.container = container;
        this.cssLoaded = false;
    }

    async init() {
        // Load component CSS before rendering
        await this.loadCSS();
        this.render();
        this.bindEvents();
    }

    async loadCSS() {
        if (this.cssLoaded) return;
        this.cssLoaded = true;

        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = '/styles/accordion.css';

        return new Promise((resolve) => {
            link.onload = resolve;
            document.head.appendChild(link);
        });
    }

    render() {
        this.container.innerHTML = `
            <div class="accordion">
                <div class="accordion-item">
                    <button class="accordion-trigger" aria-expanded="false">
                        Section 1
                        <span class="accordion-icon" aria-hidden="true">+</span>
                    </button>
                    <div class="accordion-content" hidden>
                        <p>Content for section 1</p>
                    </div>
                </div>
                <div class="accordion-item">
                    <button class="accordion-trigger" aria-expanded="false">
                        Section 2
                        <span class="accordion-icon" aria-hidden="true">+</span>
                    </button>
                    <div class="accordion-content" hidden>
                        <p>Content for section 2</p>
                    </div>
                </div>
            </div>
        `;
    }

    bindEvents() {
        this.container.querySelectorAll('.accordion-trigger').forEach(trigger => {
            trigger.addEventListener('click', () => {
                const expanded = trigger.getAttribute('aria-expanded') === 'true';
                trigger.setAttribute('aria-expanded', !expanded);
                trigger.nextElementSibling.hidden = expanded;
                trigger.querySelector('.accordion-icon').textContent = expanded ? '+' : '-';
            });
        });
    }
}

// Usage: only loads accordion CSS when accordion component is used
document.querySelectorAll('.accordion-container').forEach(el => {
    const accordion = new Accordion(el);
    accordion.init();
});

Performance Measurement

// Measure CSS loading impact
function measureCSSPerformance() {
    const resources = performance.getEntriesByType('resource')
        .filter(r => r.initiatorType === 'link' || r.name.includes('.css'));

    const cssMetrics = resources.map(r => ({
        name: r.name.split('/').pop(),
        size: `${r.transferSize || 0} bytes`,
        loadTime: `${r.duration.toFixed(0)}ms`,
        renderBlocking: r.duration > 100 ? 'Likely blocking' : 'Non-blocking'
    }));

    console.table(cssMetrics);

    // Check if CSS blocks First Paint
    const paintEntries = performance.getEntriesByType('paint');
    const firstPaint = paintEntries.find(p => p.name === 'first-contentful-paint');

    return {
        cssFiles: cssMetrics,
        totalCSSSize: resources.reduce((s, r) => s + (r.transferSize || 0), 0),
        firstPaint: firstPaint ? `${firstPaint.startTime.toFixed(0)}ms` : 'N/A',
        recommendation: resources.length > 3
            ? 'Consider inlining critical CSS and lazy loading non-critical stylesheets'
            : 'CSS loading is well optimized'
    };
}

// Usage
console.log(measureCSSPerformance());

Common Mistakes

  1. Not inlining critical CSS. Even with async loading, the browser must make a round trip for external CSS. Inline the styles required for above-fold content to eliminate this request entirely.
  2. Loading all CSS asynchronously. If all CSS loads asynchronously, the page renders without styles (FOUC). Always inline critical CSS and async-load only non-critical styles.
  3. Missing noscript fallbacks. If JavaScript is disabled, async CSS loading fails. Always provide <noscript><link rel="stylesheet" href="..."></noscript> as a fallback.
  4. Media query CSS still blocks rendering. Media-query-based stylesheets block rendering only when the media condition matches. Use media="print" for stylesheets you never want to block rendering on screen.
  5. Not removing unused CSS. Lazy loading helps, but if a 200KB CSS file is loaded for a single component, it's still wasteful. Use tools like PurgeCSS to remove unused rules from each bundle.

Practice Questions

  1. What is critical CSS and how do you identify above-fold styles?
  2. How does the preload + onload pattern let CSS load asynchronously?
  3. When would you use media-query-based CSS loading?
  4. Why should you delay non-critical CSS loading until after the initial render?
  5. How do print stylesheets affect page load performance?

Challenge: Build a page that inlines critical CSS for above-fold content, async-loads a large CSS file (100KB+) for below-fold sections, lazy loads component CSS on interaction, uses media queries for responsive and print styles, and measures the performance difference compared to loading all CSS synchronously.

FAQ

Does async CSS loading cause a flash of unstyled content?

Only if critical CSS isn't inlined. With inlined above-fold styles and async below-fold styles, content renders correctly immediately. The async styles apply later without visual disruption.

What's the best way to generate critical CSS?

Use tools like Critical (npm), Penthouse, or PurgeCSS to extract above-fold styles from your full CSS. Integrate them into your build pipeline (Webpack, Vite) for automatic extraction.

Can I lazy load third-party CSS (like Google Fonts CSS)?

Yes, but be careful. Third-party CSS often hides content until loaded. For Google Fonts, use the &display=swap parameter and async load the stylesheet. The text will render with fallback fonts immediately.

How do I know which CSS is critical?

Analyze the above-fold viewport (usually 100vh x 100vw) and identify all elements visible without scrolling. The styles applied to those elements are critical. DevTools coverage tab helps identify used vs unused rules.

Does HTTP/2 multiplexing make CSS lazy loading unnecessary?

No. HTTP/2 multiplexing reduces overhead but doesn't eliminate render blocking. The browser still waits for CSS to parse before rendering, regardless of how efficiently it's delivered.

Mini Project

Build a CSS loading analyzer: create a page with 10+ CSS files of varying sizes, implement critical CSS inlining, async loading with loadCSS, media-query-based loading, and component-level on-demand loading. Measure render-blocking time, total CSS weight, and First Contentful Paint for each Strategy. Visualize the results as a comparison chart.

What's Next

You've mastered CSS lazy loading. Next, learn about JavaScript Lazy Loading to defer non-critical script execution and optimize initial page load time.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro