Master lazy loading in JavaScript: images, dynamic imports, IntersectionObserver, React components & infinite scroll with code examples and output.
Introduction
Lazy loading is a performance optimization technique where resources (images, scripts, components) are not loaded until the user actually needs them. Instead of loading everything upfront, resources load on demand as the user interacts with the page.
Why Lazy Loading Matters
Modern web applications contain hundreds of images, multiple JavaScript bundles, and heavy components. If everything is loaded at once:
- Initial page load time increases dramatically
- Bandwidth is wasted — the user never even viewed the content that was loaded
- Memory consumption becomes unnecessarily high
- Core Web Vitals (LCP, FID, CLS) are negatively affected
Lazy loading ensures that only the resources in the user's viewport or those immediately needed are loaded. Other resources load when the user scrolls or interacts.
Real-World Impact
Studies show:
- Lazy loading images can reduce initial page weight by 40-60%
- Code splitting with dynamic imports can reduce initial bundle size by 30-50%
- Lazy loading below-the-fold content improves LCP by 20-40%
Text Diagram — Eager Loading vs Lazy Loading
┌─────────────────────────────────────────────────────────────────────┐
│ EAGER LOADING (Traditional) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Page Request ──► Load ALL Resources ──► Render Page │
│ │
│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ Img 1 │ │ Img 2 │ │ Img 3 │ │ Img 4 │ │ Img 5 │ ← ALL loaded │
│ └───────┘ └───────┘ └───────┘ └───────┘ └───────┘ │
│ │
│ Time: ████████████████████████░░░░░░░░░░ (Slow initial load) │
│ Data: ████████████████████████ (100% downloaded upfront) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ LAZY LOADING (Optimized) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Page Request ──► Load VISIBLE Only ──► Render Page ──► Load More │
│ │
│ ┌───────┐ ┌───────┐ │
│ │ Img 1 │ │ Img 2 │ ← Only visible images loaded │
│ └───────┘ └───────┘ │
│ ┌ ─ ─ ─ ┐ ┌ ─ ─ ─ ┐ ┌ ─ ─ ─ ┐ │
│ │ Img 3 │ │ Img 4 │ │ Img 5 │ ← Deferred │
│ └ ─ ─ ─ ┘ └ ─ ─ ─ ┘ └ ─ ─ ─ ┘ │
│ │
│ Time: ████████░░░░░░░░░░░░░░░░░░░░░░░░░ (Fast initial load) │
│ Data: ████████ (Only 40% downloaded initially) │
└─────────────────────────────────────────────────────────────────────┘
How Lazy Loading Works
The core principle of lazy loading is simple: defer resource loading until the resource enters the viewport or is needed by user interaction.
The Mechanism
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Page Loads │────►│ Placeholder Set │────►│ Observer Watches │
└──────────────┘ └─────────────────┘ └──────────────────┘
│
▼
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
│Resource Shown│◄────│ Resource Loaded │◄────│ Element Visible │
└──────────────┘ └─────────────────┘ └──────────────────┘
There are three primary approaches to lazy loading:
- Native Browser Lazy Loading — Using
loading="lazy" attribute (simplest) - IntersectionObserver API — Programmatic approach with fine control
- Dynamic Import (
import()) — For JavaScript modules and components
Lazy Loading Images
Images typically account for 50-70% of a webpage's total weight. Lazy loading images is the most impactful optimization.
Method 1: Native loading="lazy" Attribute
<!-- Native lazy loading - simplest approach -->
<img src="hero-image.jpg" loading="eager" alt="Hero" />
<img src="below-fold.jpg" loading="lazy" alt="Below fold content" />
<img src="gallery-1.jpg" loading="lazy" alt="Gallery image" />
<!-- Also works with iframes -->
<iframe src="https://youtube.com/embed/xyz" loading="lazy"></iframe>
✓ Browser natively handles loading="lazy" images
✓ Images below viewport are NOT fetched until user scrolls near them
✓ No JavaScript required
✓ Supported in Chrome 77+, Firefox 75+, Edge 79+, Safari 15.4+
Method 2: IntersectionObserver for Images
// IntersectionObserver approach - more control
class LazyImageLoader {
constructor(options = {}) {
this.options = {
root: null,
rootMargin: '50px 0px', // Start loading 50px before visible
threshold: 0.01,
...options
};
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
this.options
);
}
observe(images) {
images.forEach(img => this.observer.observe(img));
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
if (src) img.src = src;
if (srcset) img.srcset = srcset;
img.classList.add('loaded');
img.removeAttribute('data-src');
img.removeAttribute('data-srcset');
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const loader = new LazyImageLoader({ rootMargin: '100px 0px' });
const lazyImages = document.querySelectorAll('img[data-src]');
loader.observe(lazyImages);
console.log(`Observing ${lazyImages.length} images for lazy loading`);
Observing 12 images for lazy loading
✓ Images load as user scrolls within 100px of viewport
✓ Observer disconnects from each image after loading
✓ data-src replaced with actual src attribute
Lazy Loading JavaScript Modules
Dynamic import() allows loading JavaScript modules on demand instead of bundling everything together.
// Static import - loaded immediately with bundle
// import { heavyCalculation } from './heavy-module.js';
// Dynamic import - loaded only when needed
async function performCalculation(data) {
console.log('User requested calculation...');
console.log('Loading heavy module on demand...');
const startTime = performance.now();
// Module is fetched and parsed only at this point
const { heavyCalculation } = await import('./heavy-module.js');
const loadTime = performance.now() - startTime;
console.log(`Module loaded in ${loadTime.toFixed(2)}ms`);
const result = heavyCalculation(data);
console.log(`Calculation result: ${result}`);
return result;
}
// Module loads ONLY when user clicks
document.getElementById('calc-btn').addEventListener('click', () => {
performCalculation([1, 2, 3, 4, 5]);
});
console.log('Page loaded - heavy module NOT yet downloaded');
console.log('Module will load only on button click');
Page loaded - heavy module NOT yet downloaded
Module will load only on button click
// After user clicks button:
User requested calculation...
Loading heavy module on demand...
Module loaded in 45.23ms
Calculation result: 15
IntersectionObserver API for Lazy Loading
IntersectionObserver is the modern, performant way to detect when elements enter or leave the viewport.
// Complete IntersectionObserver setup for lazy loading
function createLazyLoader(config = {}) {
const defaultConfig = {
root: null, // viewport as root
rootMargin: '0px', // no margin
threshold: [0, 0.25, 0.5, 0.75, 1.0] // multiple thresholds
};
const settings = { ...defaultConfig, ...config };
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio;
const element = entry.target;
console.log(
`Element: ${element.id} | ` +
`Visible: ${entry.isIntersecting} | ` +
`Ratio: ${(ratio * 100).toFixed(0)}%`
);
if (entry.isIntersecting && ratio >= 0.25) {
// Element is at least 25% visible - trigger load
element.dataset.loaded = 'true';
loadResource(element);
obs.unobserve(element);
}
});
}, settings);
return observer;
}
function loadResource(element) {
const type = element.dataset.type;
switch (type) {
case 'image':
element.src = element.dataset.src;
break;
case 'video':
element.querySelector('source').src = element.dataset.videoSrc;
element.load();
break;
case 'component':
renderComponent(element.dataset.component, element);
break;
}
console.log(`✓ Loaded ${type} resource for #${element.id}`);
}
// Initialize
const observer = createLazyLoader({ rootMargin: '50px 0px' });
document.querySelectorAll('[data-lazy]').forEach(el => {
observer.observe(el);
});
console.log('Lazy loader initialized with IntersectionObserver');
Lazy loader initialized with IntersectionObserver
// As user scrolls:
Element: hero-img | Visible: true | Ratio: 100%
✓ Loaded image resource for #hero-img
Element: product-video | Visible: true | Ratio: 50%
✓ Loaded video resource for #product-video
Element: chart-widget | Visible: true | Ratio: 25%
✓ Loaded component resource for #chart-widget
Code Examples
Example 1: Native Lazy Loading for Images
// Simple native lazy loading implementation
function setupNativeLazyLoading() {
// Check browser support
if ('loading' in HTMLImageElement.prototype) {
console.log('✓ Native lazy loading supported');
const images = document.querySelectorAll('img.lazy');
images.forEach(img => {
// Move data-src to src and let browser handle lazy loading
img.src = img.dataset.src;
img.loading = 'lazy';
img.classList.remove('lazy');
});
console.log(`Set up ${images.length} images with native lazy loading`);
} else {
console.log('✗ Native lazy loading NOT supported - using fallback');
// Fallback to IntersectionObserver
loadIntersectionObserverPolyfill();
}
}
setupNativeLazyLoading();
✓ Native lazy loading supported
Set up 8 images with native lazy loading
Example 2: IntersectionObserver with Fade-In Effect
// Lazy loading with smooth fade-in transition
class FadeInLazyLoader {
constructor() {
this.observer = new IntersectionObserver(
this.onIntersect.bind(this),
{ rootMargin: '75px 0px', threshold: 0.1 }
);
this.loadedCount = 0;
this.totalCount = 0;
}
init(selector = '.lazy-fade') {
const elements = document.querySelectorAll(selector);
this.totalCount = elements.length;
elements.forEach(el => {
el.style.opacity = '0';
el.style.transition = 'opacity 0.5s ease-in';
this.observer.observe(el);
});
console.log(`FadeInLazyLoader: Watching ${this.totalCount} elements`);
}
onIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
// Load the resource
if (el.tagName === 'IMG') {
el.src = el.dataset.src;
el.onload = () => {
el.style.opacity = '1';
this.loadedCount++;
console.log(
`Loaded & faded in: ${el.alt} ` +
`(${this.loadedCount}/${this.totalCount})`
);
};
} else {
el.style.opacity = '1';
this.loadedCount++;
}
this.observer.unobserve(el);
}
});
}
getStats() {
return {
total: this.totalCount,
loaded: this.loadedCount,
pending: this.totalCount - this.loadedCount
};
}
}
const loader = new FadeInLazyLoader();
loader.init('.lazy-fade');
// After some scrolling:
setTimeout(() => {
const stats = loader.getStats();
console.log(`Stats: ${JSON.stringify(stats)}`);
}, 3000);
FadeInLazyLoader: Watching 15 elements
Loaded & faded in: Product Image 1 (1/15)
Loaded & faded in: Product Image 2 (2/15)
Loaded & faded in: Banner (3/15)
Stats: {"total":15,"loaded":3,"pending":12}Example 3: Dynamic Import with Loading States
// Dynamic import with proper loading states and error handling
class ModuleLoader {
constructor() {
this.cache = new Map();
this.loading = new Set();
}
async load(modulePath) {
// Return from cache if already loaded
if (this.cache.has(modulePath)) {
console.log(`[Cache Hit] ${modulePath}`);
return this.cache.get(modulePath);
}
// Prevent duplicate loading
if (this.loading.has(modulePath)) {
console.log(`[Already Loading] ${modulePath} - waiting...`);
// Wait for existing load to complete
await new Promise(resolve => {
const check = setInterval(() => {
if (this.cache.has(modulePath)) {
clearInterval(check);
resolve();
}
}, 50);
});
return this.cache.get(modulePath);
}
this.loading.add(modulePath);
console.log(`[Loading] ${modulePath}...`);
try {
const startTime = performance.now();
const module = await import(modulePath);
const duration = performance.now() - startTime;
this.cache.set(modulePath, module);
this.loading.delete(modulePath);
console.log(`[Loaded] ${modulePath} in ${duration.toFixed(1)}ms`);
console.log(`[Exports] ${Object.keys(module).join(', ')}`);
return module;
} catch (error) {
this.loading.delete(modulePath);
console.error(`[Error] Failed to load ${modulePath}: ${error.message}`);
throw error;
}
}
getStatus() {
return {
cached: this.cache.size,
loading: this.loading.size
};
}
}
// Usage
const moduleLoader = new ModuleLoader();
async function initDashboard() {
console.log('--- Loading Dashboard Modules ---');
// Load chart library only when dashboard is opened
const charts = await moduleLoader.load('./charts.js');
const analytics = await moduleLoader.load('./analytics.js');
// Second call uses cache
const chartsAgain = await moduleLoader.load('./charts.js');
console.log(`Status: ${JSON.stringify(moduleLoader.getStatus())}`);
}
initDashboard();
--- Loading Dashboard Modules ---
[Loading] ./charts.js...
[Loaded] ./charts.js in 120.5ms
[Exports] LineChart, BarChart, PieChart
[Loading] ./analytics.js...
[Loaded] ./analytics.js in 85.3ms
[Exports] trackEvent, getMetrics, generateReport
[Cache Hit] ./charts.js
Status: {"cached":2,"loading":0}Example 4: Lazy Component Loading Pattern
// Lazy component loader for SPA frameworks
class LazyComponentRegistry {
constructor() {
this.components = new Map();
this.loadedComponents = new Map();
}
register(name, importFn) {
this.components.set(name, {
importFn,
status: 'registered',
loadTime: null
});
console.log(`Registered lazy component: ${name}`);
}
async render(name, container, props = {}) {
if (!this.components.has(name)) {
throw new Error(`Component "${name}" not registered`);
}
const componentInfo = this.components.get(name);
// Show placeholder
container.innerHTML = `<div class="skeleton-loader">Loading ${name}...</div>`;
console.log(`[Placeholder] Showing skeleton for "${name}"`);
try {
const startTime = performance.now();
// Dynamically import the component
const module = await componentInfo.importFn();
const Component = module.default || module[name];
const loadTime = performance.now() - startTime;
componentInfo.status = 'loaded';
componentInfo.loadTime = loadTime;
// Render the component
const html = Component.render(props);
container.innerHTML = html;
console.log(`[Rendered] "${name}" in ${loadTime.toFixed(1)}ms`);
console.log(`[Props] ${JSON.stringify(props)}`);
return Component;
} catch (error) {
componentInfo.status = 'error';
container.innerHTML = `<div class="error">Failed to load ${name}</div>`;
console.error(`[Error] "${name}": ${error.message}`);
}
}
getReport() {
const report = [];
this.components.forEach((info, name) => {
report.push(`${name}: ${info.status} ${info.loadTime ? `(${info.loadTime.toFixed(0)}ms)` : ''}`);
});
return report;
}
}
// Register components (not loaded yet!)
const registry = new LazyComponentRegistry();
registry.register('UserProfile', () => import('./components/UserProfile.js'));
registry.register('DataTable', () => import('./components/DataTable.js'));
registry.register('ChartWidget', () => import('./components/ChartWidget.js'));
registry.register('CommentSection', () => import('./components/CommentSection.js'));
console.log('\n--- Rendering on demand ---');
// Components load only when needed
const container = document.getElementById('widget-area');
await registry.render('DataTable', container, { rows: 50, columns: 5 });
await registry.render('ChartWidget', container, { type: 'line', data: [10, 20, 30] });
console.log('\n--- Load Report ---');
registry.getReport().forEach(line => console.log(line));
Registered lazy component: UserProfile
Registered lazy component: DataTable
Registered lazy component: ChartWidget
Registered lazy component: CommentSection
--- Rendering on demand ---
[Placeholder] Showing skeleton for "DataTable"
[Rendered] "DataTable" in 92.4ms
[Props] {"rows":50,"columns":5}
[Placeholder] Showing skeleton for "ChartWidget"
[Rendered] "ChartWidget" in 67.8ms
[Props] {"type":"line","data":[10,20,30]}
--- Load Report ---
UserProfile: registered
DataTable: loaded (92ms)
ChartWidget: loaded (68ms)
CommentSection: registered// Infinite scroll implementation using IntersectionObserver
class InfiniteScroller {
constructor(container, options = {}) {
this.container = container;
this.page = 1;
this.isLoading = false;
this.hasMore = true;
this.itemsPerPage = options.itemsPerPage || 10;
this.totalLoaded = 0;
// Create sentinel element at bottom
this.sentinel = document.createElement('div');
this.sentinel.className = 'scroll-sentinel';
this.container.appendChild(this.sentinel);
// Observe sentinel
this.observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && !this.isLoading && this.hasMore) {
this.loadMore();
}
},
{ rootMargin: '200px 0px' } // Trigger 200px before reaching bottom
);
this.observer.observe(this.sentinel);
console.log('InfiniteScroller initialized');
}
async loadMore() {
this.isLoading = true;
console.log(`\nLoading page ${this.page}...`);
try {
// Simulate API call
const response = await this.fetchItems(this.page);
const { items, hasMore } = response;
// Render items
items.forEach(item => {
const el = document.createElement('div');
el.className = 'item';
el.textContent = item.title;
this.container.insertBefore(el, this.sentinel);
});
this.totalLoaded += items.length;
this.hasMore = hasMore;
this.page++;
console.log(`✓ Loaded ${items.length} items (Total: ${this.totalLoaded})`);
if (!this.hasMore) {
console.log('All items loaded - disconnecting observer');
this.observer.disconnect();
this.sentinel.textContent = 'No more items';
}
} catch (error) {
console.error(`Failed to load page ${this.page}: ${error.message}`);
} finally {
this.isLoading = false;
}
}
async fetchItems(page) {
// Simulated API response
await new Promise(r => setTimeout(r, 300));
const totalItems = 35;
const start = (page - 1) * this.itemsPerPage;
const remaining = totalItems - start;
const count = Math.min(this.itemsPerPage, remaining);
return {
items: Array.from({ length: count }, (_, i) => ({
title: `Item ${start + i + 1}`,
id: start + i + 1
})),
hasMore: start + count < totalItems
};
}
}
// Initialize
const scroller = new InfiniteScroller(
document.getElementById('feed'),
{ itemsPerPage: 10 }
);
// Simulate user scrolling
scroller.loadMore(); // Initial load
InfiniteScroller initialized
Loading page 1...
✓ Loaded 10 items (Total: 10)
Loading page 2...
✓ Loaded 10 items (Total: 20)
Loading page 3...
✓ Loaded 10 items (Total: 30)
Loading page 4...
✓ Loaded 5 items (Total: 35)
All items loaded - disconnecting observer
Example 6: Placeholder Pattern (Skeleton Loading)
// Placeholder/Skeleton pattern for lazy loaded content
class SkeletonLoader {
constructor() {
this.skeletons = new Map();
}
createSkeleton(type, container) {
const templates = {
card: `
<div class="skeleton-card">
<div class="skeleton-img" style="height:200px;background:#e0e0e0;border-radius:8px"></div>
<div class="skeleton-text" style="height:20px;width:80%;background:#e0e0e0;margin-top:12px;border-radius:4px"></div>
<div class="skeleton-text" style="height:16px;width:60%;background:#e0e0e0;margin-top:8px;border-radius:4px"></div>
</div>`,
list: `
<div class="skeleton-list">
${Array(5).fill('<div class="skeleton-row" style="height:48px;background:#e0e0e0;margin:8px 0;border-radius:4px"></div>').join('')}
</div>`,
profile: `
<div class="skeleton-profile">
<div class="skeleton-avatar" style="width:80px;height:80px;border-radius:50%;background:#e0e0e0"></div>
<div class="skeleton-name" style="height:24px;width:150px;background:#e0e0e0;margin-top:12px;border-radius:4px"></div>
</div>`
};
const skeleton = templates[type] || templates.card;
container.innerHTML = skeleton;
container.classList.add('skeleton-active');
this.skeletons.set(container, { type, startTime: Date.now() });
console.log(`[Skeleton] Created "${type}" placeholder`);
return container;
}
async loadContent(container, loadFn) {
const info = this.skeletons.get(container);
if (!info) return;
try {
const content = await loadFn();
const duration = Date.now() - info.startTime;
// Replace skeleton with actual content
container.innerHTML = content;
container.classList.remove('skeleton-active');
container.classList.add('content-loaded');
this.skeletons.delete(container);
console.log(`[Content] Replaced "${info.type}" skeleton after ${duration}ms`);
return content;
} catch (error) {
container.innerHTML = `<div class="error-state">Failed to load content</div>`;
console.error(`[Error] Failed to load content: ${error.message}`);
}
}
getActiveSkeletons() {
return this.skeletons.size;
}
}
// Usage
const skeleton = new SkeletonLoader();
// Show skeletons immediately
const cardContainer = document.getElementById('product-card');
const listContainer = document.getElementById('reviews-list');
const profileContainer = document.getElementById('user-profile');
skeleton.createSkeleton('card', cardContainer);
skeleton.createSkeleton('list', listContainer);
skeleton.createSkeleton('profile', profileContainer);
console.log(`Active skeletons: ${skeleton.getActiveSkeletons()}`);
// Load actual content (simulated API calls)
await skeleton.loadContent(cardContainer, async () => {
await new Promise(r => setTimeout(r, 800));
return '<div class="product">Actual Product Card Content</div>';
});
await skeleton.loadContent(profileContainer, async () => {
await new Promise(r => setTimeout(r, 400));
return '<div class="profile">User Profile Content</div>';
});
console.log(`Active skeletons remaining: ${skeleton.getActiveSkeletons()}`);
[Skeleton] Created "card" placeholder
[Skeleton] Created "list" placeholder
[Skeleton] Created "profile" placeholder
Active skeletons: 3
[Content] Replaced "card" skeleton after 800ms
[Content] Replaced "profile" skeleton after 400ms
Active skeletons remaining: 1
Example 7: Route-Based Code Splitting
// Route-based lazy loading for Single Page Applications
class LazyRouter {
constructor() {
this.routes = new Map();
this.currentRoute = null;
this.loadHistory = [];
}
register(path, importFn, options = {}) {
this.routes.set(path, {
importFn,
preload: options.preload || false,
module: null,
loaded: false
});
console.log(`Route registered: ${path}`);
}
async navigate(path) {
const route = this.routes.get(path);
if (!route) {
console.error(`Route not found: ${path}`);
return;
}
console.log(`\nNavigating to: ${path}`);
// Show loading indicator
this.showLoader();
const startTime = performance.now();
try {
if (!route.loaded) {
// First visit - load the module
route.module = await route.importFn();
route.loaded = true;
const loadTime = performance.now() - startTime;
console.log(`Module loaded in ${loadTime.toFixed(1)}ms`);
this.loadHistory.push({ path, time: loadTime, cached: false });
} else {
// Already loaded - use cached module
const loadTime = performance.now() - startTime;
console.log(`Using cached module (${loadTime.toFixed(2)}ms)`);
this.loadHistory.push({ path, time: loadTime, cached: true });
}
// Render the route component
const Page = route.module.default;
this.currentRoute = path;
this.hideLoader();
console.log(`✓ Rendered: ${path}`);
} catch (error) {
this.hideLoader();
console.error(`Failed to load route: ${error.message}`);
}
}
preloadRoute(path) {
const route = this.routes.get(path);
if (route && !route.loaded) {
console.log(`Preloading: ${path}`);
route.importFn().then(module => {
route.module = module;
route.loaded = true;
console.log(`Preloaded: ${path}`);
});
}
}
showLoader() { console.log('[UI] Loading spinner shown'); }
hideLoader() { console.log('[UI] Loading spinner hidden'); }
printStats() {
console.log('\n--- Navigation Stats ---');
this.loadHistory.forEach(entry => {
console.log(
`${entry.path}: ${entry.time.toFixed(1)}ms ${entry.cached ? '(cached)' : '(fresh)'}`
);
});
}
}
// Setup
const router = new LazyRouter();
router.register('/', () => import('./pages/Home.js'));
router.register('/dashboard', () => import('./pages/Dashboard.js'));
router.register('/settings', () => import('./pages/Settings.js'));
router.register('/analytics', () => import('./pages/Analytics.js'));
// Simulate navigation
await router.navigate('/');
await router.navigate('/dashboard');
await router.navigate('/'); // Should use cache
router.printStats();
Route registered: /
Route registered: /dashboard
Route registered: /settings
Route registered: /analytics
Navigating to: /
[UI] Loading spinner shown
Module loaded in 55.3ms
[UI] Loading spinner hidden
✓ Rendered: /
Navigating to: /dashboard
[UI] Loading spinner shown
Module loaded in 132.7ms
[UI] Loading spinner hidden
✓ Rendered: /dashboard
Navigating to: /
[UI] Loading spinner shown
Using cached module (0.12ms)
[UI] Loading spinner hidden
✓ Rendered: /
--- Navigation Stats ---
/: 55.3ms (fresh)
/dashboard: 132.7ms (fresh)
/: 0.1ms (cached)
Lazy loading provides measurable performance improvements:
| Metric | Without Lazy Loading | With Lazy Loading | Improvement |
|---|
| Initial Page Load | 4.2s | 1.8s | 57% faster |
| First Contentful Paint (FCP) | 2.8s | 1.2s | 57% faster |
| Largest Contentful Paint (LCP) | 4.5s | 2.1s | 53% faster |
| Total Transfer Size | 3.2 MB | 0.9 MB | 72% less |
| Initial JS Bundle | 850 KB | 320 KB | 62% smaller |
| Memory Usage | 180 MB | 95 MB | 47% less |
| Time to Interactive (TTI) | 5.1s | 2.4s | 53% faster |
// Measuring lazy loading performance impact
function measureLazyLoadingImpact() {
const metrics = {
initialResources: 12, // Without lazy loading
lazyLoadedInitial: 4, // With lazy loading
savedRequests: 8,
savedBytes: 2.3 * 1024 * 1024, // 2.3 MB saved initially
ttfbImprovement: '35%',
lcpImprovement: '53%',
clsRisk: 'Managed with placeholders'
};
console.log('=== Lazy Loading Performance Impact ===');
console.log(`Initial HTTP Requests: ${metrics.initialResources} → ${metrics.lazyLoadedInitial}`);
console.log(`Saved requests: ${metrics.savedRequests}`);
console.log(`Saved initial transfer: ${(metrics.savedBytes / 1024 / 1024).toFixed(1)} MB`);
console.log(`TTFB improvement: ${metrics.ttfbImprovement}`);
console.log(`LCP improvement: ${metrics.lcpImprovement}`);
console.log(`CLS handling: ${metrics.clsRisk}`);
return metrics;
}
measureLazyLoadingImpact();
=== Lazy Loading Performance Impact ===
Initial HTTP Requests: 12 → 4
Saved requests: 8
Saved initial transfer: 2.3 MB
TTFB improvement: 35%
LCP improvement: 53%
CLS handling: Managed with placeholders
🚫 Common Mistakes
Mistake 1: Lazy Loading Above-the-Fold Images
// ❌ WRONG - Lazy loading hero/above-fold images hurts LCP
<img src="hero-banner.jpg" loading="lazy" alt="Hero Banner" />
// ✅ CORRECT - Above-fold images should load eagerly
<img src="hero-banner.jpg" loading="eager" alt="Hero Banner" />
// OR simply don't add loading attribute (defaults to eager)
<img src="hero-banner.jpg" alt="Hero Banner" />
❌ Lazy loading above-fold images DELAYS LCP because the image
won't start loading until layout is calculated
✅ Above-fold images should ALWAYS be eager-loaded for best LCP
Mistake 2: Not Setting Dimensions on Lazy Images (Causes CLS)
// ❌ WRONG - No dimensions causes layout shift when image loads
<img data-src="product.jpg" class="lazy" alt="Product" />
// ✅ CORRECT - Always set width/height or aspect-ratio
<img
data-src="product.jpg"
class="lazy"
alt="Product"
width="400"
height="300"
style="aspect-ratio: 4/3"
/>
// ✅ ALSO CORRECT - Use CSS aspect-ratio container
<div style="aspect-ratio: 16/9; background: #f0f0f0;">
<img data-src="banner.jpg" class="lazy" alt="Banner" style="width:100%;height:100%;object-fit:cover" />
</div>
❌ Without dimensions: CLS score increases by 0.1-0.25 (poor score)
Content jumps when image loads and takes space
✅ With dimensions: CLS stays at 0 — space is pre-allocated
Browser reserves exact space before image downloads
Mistake 3: Forgetting to Unobserve After Loading
// ❌ WRONG - Never unobserves, keeps firing callbacks
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src;
// Missing: observer.unobserve(entry.target)
// The callback keeps firing every time element is scrolled in/out!
}
});
});
// ✅ CORRECT - Unobserve after loading
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src;
obs.unobserve(entry.target); // ← Stop watching this element
}
});
});
❌ Without unobserve: Observer keeps firing callbacks on every scroll
Memory leak — observer holds references to all elements forever
Performance degrades as page grows
✅ With unobserve: Observer stops watching loaded elements
Memory is freed, callbacks reduce over time
Clean and performant
Mistake 4: No Error Handling for Dynamic Imports
// ❌ WRONG - No error handling, app crashes if module fails to load
const module = await import('./heavy-module.js');
module.doSomething();
// ✅ CORRECT - Proper error handling with fallback
async function loadModule(path) {
try {
const module = await import(path);
return module;
} catch (error) {
console.error(`Failed to load module: ${path}`, error);
// Show user-friendly error
showErrorUI(`This feature is temporarily unavailable. Please refresh.`);
// Retry logic
if (error.message.includes('network')) {
console.log('Network error - will retry in 3 seconds');
await new Promise(r => setTimeout(r, 3000));
return import(path); // Retry once
}
return null;
}
}
const module = await loadModule('./heavy-module.js');
if (module) {
module.doSomething();
} else {
console.log('Using fallback behavior');
}
❌ Without error handling: Uncaught TypeError if network fails
User sees blank screen or broken UI
No way to recover
✅ With error handling: Graceful degradation
User sees friendly error message
Retry logic for network issues
Fallback behavior keeps app usable
Mistake 5: Lazy Loading Everything (Over-Optimization)
// ❌ WRONG - Lazy loading critical CSS, fonts, and above-fold content
const criticalCSS = await import('./critical-styles.css'); // Blocks render!
const font = await import('./main-font.woff2'); // Text invisible!
const header = await import('./Header.js'); // No header initially!
// ✅ CORRECT - Only lazy load non-critical, below-fold resources
// Critical resources - load immediately
import './critical-styles.css';
import Header from './Header.js';
// Non-critical - lazy load
const ChartWidget = lazy(() => import('./ChartWidget.js'));
const CommentSection = lazy(() => import('./CommentSection.js'));
const FooterLinks = lazy(() => import('./FooterLinks.js'));
console.log('Rule of thumb:');
console.log('- EAGER: Above fold, critical path, header, nav, hero');
console.log('- LAZY: Below fold, modals, tabs, heavy widgets, images offscreen');
❌ Lazy loading everything:
- Flash of unstyled content (FOUC)
- Invisible text flash (FOIT)
- Layout shifts everywhere
- User sees empty page first
✅ Strategic lazy loading:
- Critical path loads immediately
- Above-fold content renders instantly
- Below-fold deferred intelligently
- Smooth progressive experience
Mistake 6: Not Providing Fallback for Older Browsers
// ❌ WRONG - Assumes IntersectionObserver exists everywhere
const observer = new IntersectionObserver(callback); // Crashes in old browsers!
// ✅ CORRECT - Feature detection with fallback
function initLazyLoading(elements) {
if ('IntersectionObserver' in window) {
// Modern browsers - use IntersectionObserver
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target);
obs.unobserve(entry.target);
}
});
});
elements.forEach(el => observer.observe(el));
console.log('Using IntersectionObserver');
} else {
// Fallback - load all images immediately
elements.forEach(el => loadImage(el));
console.log('Fallback: loaded all images immediately');
}
}
function loadImage(img) {
img.src = img.dataset.src;
}
const images = document.querySelectorAll('img[data-src]');
initLazyLoading(images);
✓ Modern browsers: Uses IntersectionObserver efficiently
✓ Older browsers: Falls back to loading all images
✓ No crashes, no broken images
✓ Progressive enhancement approach
🎯 Key Takeaways
- Lazy loading defers non-critical resource loading until the resource is actually needed by the user, dramatically reducing initial page load time.
- Use
loading="lazy" for simple image lazy loading — it's native, requires no JavaScript, and has excellent browser support (95%+ coverage in 2026).
- IntersectionObserver is the recommended API for custom lazy loading logic — it's performant, non-blocking, and gives fine-grained control over when resources load.
- Dynamic
import() enables code splitting — load JavaScript modules on demand to reduce initial bundle size by 30-60%.
- Never lazy load above-the-fold content — hero images, header components, and critical CSS must load eagerly for optimal LCP scores.
- Always set dimensions on lazy-loaded images to prevent Cumulative Layout Shift (CLS) — use
width/height attributes or CSS aspect-ratio.
- Implement error handling for dynamic imports — network failures happen, and your app should degrade gracefully with retry logic and fallback UI.
- Use the placeholder/skeleton pattern to provide immediate visual feedback while content loads — prevents perceived slowness.
- Unobserve elements after loading to prevent memory leaks and unnecessary callback executions in IntersectionObserver.
- Measure the impact — use Lighthouse, Web Vitals, and performance APIs to quantify improvements before and after implementing lazy loading.
❓ Interview Questions
Answer: Lazy loading is a design pattern that defers the initialization or loading of resources (images, scripts, components) until they are actually needed. It's important because:
- Reduces initial page load time by loading only critical resources first
- Saves bandwidth for users who don't scroll through entire page
- Improves Core Web Vitals (LCP, FID/INP, TTI)
- Reduces memory consumption since not all resources are in DOM simultaneously
- Improves server load by reducing concurrent requests
Q2: Explain the difference between loading="lazy" and IntersectionObserver approach.
Answer:
loading="lazy": Native browser attribute, zero JavaScript required, browser decides exact trigger distance, works only on <img> and <iframe>, less control over timing/thresholds.- IntersectionObserver: JavaScript API, works on any DOM element, configurable
rootMargin and threshold, can add animations/transitions, enables complex patterns (infinite scroll, analytics), requires more code but offers maximum flexibility.
Use native loading="lazy" for simple image cases; use IntersectionObserver when you need custom behavior, animations, or non-image lazy loading.
Q3: How does dynamic import() differ from static import?
Answer:
// Static import - evaluated at parse time, synchronous, hoisted
import { utils } from './utils.js'; // Always loaded with bundle
// Dynamic import - returns Promise, loaded at runtime, on-demand
const { utils } = await import('./utils.js'); // Loaded when this line executes
Key differences:
- Static imports are resolved at compile/parse time; dynamic imports at runtime
- Dynamic import returns a Promise (async)
- Dynamic imports enable code splitting (separate chunks)
- Dynamic imports can use variables:
import(\./locale/${lang}.js\) - Static imports are hoisted; dynamic imports execute in order
Q4: What is CLS and how does lazy loading cause it? How do you prevent it?
Answer: CLS (Cumulative Layout Shift) measures visual stability. Lazy loading causes CLS when:
- Images load without reserved space, pushing content down
- Components render and change layout after initial paint
Prevention:
<!-- Always set explicit dimensions -->
<img data-src="photo.jpg" width="800" height="600" loading="lazy" />
<!-- Or use aspect-ratio -->
<div style="aspect-ratio: 16/9;">
<img data-src="video-thumb.jpg" loading="lazy" style="width:100%;height:100%;object-fit:cover" />
</div>
Q5: When should you NOT use lazy loading?
Answer: Do NOT lazy load:
- Above-the-fold images (hero banners, header logos) — hurts LCP
- Critical CSS and fonts — causes FOUC/FOIT
- Primary navigation components — user sees empty header
- Small images (< 5KB) — overhead of lazy loading exceeds savings
- Images that are critical for SEO (unless proper
noscript fallback exists) - Content user will definitely see immediately on page load
Q6: How would you implement lazy loading in a React application?
Answer:
import React, { Suspense, lazy } from 'react';
// Lazy load components
const Dashboard = lazy(() => import('./Dashboard'));
const Analytics = lazy(() => import('./Analytics'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} /> {/* Eager */}
<Route path="/dashboard" element={<Dashboard />} /> {/* Lazy */}
<Route path="/analytics" element={<Analytics />} /> {/* Lazy */}
</Routes>
</Suspense>
);
}
React.lazy() with Suspense enables component-level code splitting. Each lazy route creates a separate chunk that downloads only when user navigates to it.
Answer:
// Using Performance Observer API
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});
});
observer.observe({ entryTypes: ['resource', 'largest-contentful-paint'] });
// Using Web Vitals library
import { getLCP, getCLS, getFID } from 'web-vitals';
getLCP(metric => console.log('LCP:', metric.value));
getCLS(metric => console.log('CLS:', metric.value));
// Before/after comparison with Lighthouse
// Run lighthouse --only-categories=performance before and after implementation
Tools: Lighthouse, Chrome DevTools Performance tab, WebPageTest, Core Web Vitals report in Google Search Console.
📝 FAQ
Q: Does loading="lazy" work on background images in CSS?
A: No. The loading="lazy" attribute only works on <img> and <iframe> HTML elements. For CSS background images, you need to use IntersectionObserver to add/remove a class that triggers the background-image property. Example:
observer.observe(section);
// When visible: section.classList.add('bg-loaded');
// CSS: .bg-loaded { background-image: url('bg.jpg'); }
Q: Will lazy loading hurt my SEO?
A: Generally no, if implemented correctly. Googlebot renders pages with JavaScript and can trigger lazy loading. However:
- Use native
loading="lazy" which Googlebot respects - Provide proper
alt text on all images - Use
<noscript> fallbacks for critical images - Don't lazy load content that's essential for SEO indexing
- Avoid lazy loading above-the-fold content that Googlebot needs to see immediately
Q: What's the ideal rootMargin value for IntersectionObserver?
A: It depends on network speed and image size:
- Fast connections (4G/WiFi):
50px - 100px rootMargin is sufficient - Slow connections (3G):
200px - 300px gives images more time to download - Large images: Use larger margin (200px+) so they start loading earlier
- Adaptive approach: Use Network Information API to set margin dynamically:
const connection = navigator.connection;
const margin = connection?.effectiveType === '4g' ? '100px' : '300px';
Q: Can I lazy load scripts (not modules)?
A: Yes, you can dynamically inject script tags:
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}
// Load analytics only after user interaction
document.addEventListener('click', () => loadScript('analytics.js'), { once: true });
Also, use <script defer> and <script async> for different loading strategies. defer maintains order and executes after parsing; async downloads in parallel and executes immediately.
Q: How do I handle lazy loading for users with JavaScript disabled?
A: Use the <noscript> pattern:
<img data-src="photo.jpg" class="lazy" alt="Photo" />
<noscript>
<img src="photo.jpg" alt="Photo" />
</noscript>
For native loading="lazy", no fallback is needed since it's an HTML attribute that works without JavaScript. The browser handles it natively even when JS is disabled.