CSS Lazy Loading — Loading Stylesheets on Demand
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
- 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.
- 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.
- Missing noscript fallbacks. If JavaScript is disabled, async CSS loading fails. Always provide
<noscript><link rel="stylesheet" href="..."></noscript>as a fallback. - 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. - 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
- What is critical CSS and how do you identify above-fold styles?
- How does the preload + onload pattern let CSS load asynchronously?
- When would you use media-query-based CSS loading?
- Why should you delay non-critical CSS loading until after the initial render?
- 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
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