$ cat docs/lazy-loading.md
updated Recently · 35 min read · published
Lazy Loading ◆ Performance◆ Images◆ Components◆ Intersection Observer◆ Intermediate🎯 Free Tools Introduction
Lazy loading is the technique of deferring the loading of non-critical resources until they are actually needed. Instead of downloading everything upfront, the browser only fetches images, components, and iframes as the user scrolls or interacts with them.
This technique can reduce initial page weight by 50-80%, dramatically improving load times and reducing bandwidth consumption. It is one of the highest-impact, lowest-effort performance optimizations available. Every major website — from YouTube to Instagram — uses lazy loading extensively.
Native Lazy Loading (loading attribute)
The browser-native loading attribute provides zero-JavaScript lazy loading for images and iframes. It is supported in all modern browsers and is the simplest way to implement lazy loading.
native-lazy-loading.html wrap HTML
Copy 1 <!-- Native lazy loading for images --> 2 <img src="photo.webp" loading="lazy" alt="A beautiful landscape" width="800" height="600" /> 3 4 <!-- Native lazy loading for iframes --> 5 <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" 6 loading="lazy" 7 title="Video player" 8 width="560" 9 height="315" 10 allow="accelerometer; autoplay; clipboard-write; encrypted-media" 11 allowfullscreen> 12 </iframe> 13 14 <!-- Eager loading for above-the-fold content (default behavior) --> 15 <img src="hero.webp" loading="eager" alt="Hero image" width="1200" height="600" /> 16 17 <!-- Auto lets the browser decide (default) --> 18 <img src="thumbnail.webp" loading="auto" alt="Thumbnail" width="400" height="300" /> 19 20 <!-- Combined with fetchpriority for critical images --> 21 <img src="hero.webp" loading="eager" fetchpriority="high" alt="Hero" /> 22 23 <!-- Lazy load below-the-fold images with low fetchpriority --> 24 <img src="sidebar-ad.webp" loading="lazy" fetchpriority="low" alt="Ad" />
ℹ info
Always set explicit width and height attributes on lazily-loaded images. Without dimensions, the browser cannot reserve space, causing layout shift (CLS) when the image loads.
Value Behavior Browser Fetch Distance Use Case lazy Deferred until near viewport ~1250px from viewport Below-the-fold images, iframes eager Loaded immediately Immediate Above-the-fold, LCP candidates auto Browser decides Browser default When uncertain, non-critical
Intersection Observer API
The Intersection Observer API provides a performant way to detect when elements enter or leave the viewport without layout thrashing. It runs off the main thread, making it ideal for lazy loading — far superior to scroll event listeners.
intersection-lazy.js wrap TypeScript
Copy 1 // Core Intersection Observer for lazy loading images 2 function initLazyImages() { 3 const imageObserver = new IntersectionObserver( 4 (entries, observer) => { 5 entries.forEach((entry) => { 6 if (entry.isIntersecting) { 7 const img = entry.target as HTMLImageElement; 8 // Replace placeholder with real source 9 if (img.dataset.src) { 10 img.src = img.dataset.src; 11 } 12 if (img.dataset.srcset) { 13 img.srcset = img.dataset.srcset; 14 } 15 img.classList.add("loaded"); 16 observer.unobserve(img); 17 } 18 }); 19 }, 20 { 21 root: null, // viewport 22 rootMargin: "200px", // start loading 200px before visible 23 threshold: 0, // trigger as soon as 1px is visible 24 } 25 ); 26 27 // Observe all lazy images 28 document.querySelectorAll("img[data-src]").forEach((img) => { 29 imageObserver.observe(img); 30 }); 31 } 32 33 // Initialize on DOM ready 34 if (document.readyState === "loading") { 35 document.addEventListener("DOMContentLoaded", initLazyImages); 36 } else { 37 initLazyImages(); 38 }
lazy-image-html.html wrap HTML
Copy 1 <!-- HTML pattern for Intersection Observer lazy loading --> 2 <img class="lazy-image" 3 src="placeholder-blur.webp" 4 data-src="real-photo.webp" 5 data-srcset="real-photo-400w.webp 400w, real-photo-800w.webp 800w" 6 alt="Product photo" 7 width="800" 8 height="600" 9 loading="lazy" /> 10 11 <style> 12 .lazy-image { 13 transition: opacity 0.3s ease; 14 filter: blur(10px); 15 } 16 .lazy-image.loaded { 17 opacity: 1; 18 filter: none; 19 } 20 </style>
🔥 pro tip
Use rootMargin: "200px" to start loading images before they enter the viewport. This creates a buffer zone so images appear to load instantly as the user scrolls. On fast connections, use 400px; on slow connections, use 600px or more.
lazy-loader-advanced.ts wrap TypeScript
Copy 1 // Advanced Intersection Observer with multiple thresholds 2 function createLazyLoader(options?: { 3 rootMargin?: string; 4 thresholds?: number[]; 5 onLoad?: (el: Element) => void; 6 onError?: (el: Element, error: Error) => void; 7 }) { 8 const { 9 rootMargin = "200px 0px", 10 thresholds = [0], 11 onLoad, 12 onError, 13 } = options || {}; 14 15 const observer = new IntersectionObserver( 16 (entries) => { 17 entries.forEach((entry) => { 18 if (!entry.isIntersecting) return; 19 20 const el = entry.target; 21 22 if (el instanceof HTMLImageElement) { 23 loadImage(el) 24 .then(() => onLoad?.(el)) 25 .catch((err) => onError?.(el, err)); 26 } else if (el instanceof HTMLIFrameElement) { 27 loadIframe(el) 28 .then(() => onLoad?.(el)) 29 .catch((err) => onError?.(el, err)); 30 } else if (el instanceof HTMLVideoElement) { 31 loadVideo(el) 32 .then(() => onLoad?.(el)) 33 .catch((err) => onError?.(el, err)); 34 } 35 36 observer.unobserve(el); 37 }); 38 }, 39 { rootMargin, threshold: thresholds } 40 ); 41 42 return { 43 observe: (selector: string) => { 44 document.querySelectorAll(selector).forEach((el) => observer.observe(el)); 45 }, 46 disconnect: () => observer.disconnect(), 47 }; 48 } 49 50 function loadImage(img: HTMLImageElement): Promise<void> { 51 return new Promise((resolve, reject) => { 52 img.onload = () => resolve(); 53 img.onerror = () => reject(new Error(`Failed to load: ${img.dataset.src}`)); 54 if (img.dataset.src) img.src = img.dataset.src; 55 if (img.dataset.srcset) img.srcset = img.dataset.srcset; 56 img.classList.add("loaded"); 57 }); 58 } 59 60 function loadIframe(iframe: HTMLIFrameElement): Promise<void> { 61 return new Promise((resolve, reject) => { 62 iframe.onload = () => resolve(); 63 iframe.onerror = () => reject(new Error("iframe load failed")); 64 if (iframe.dataset.src) iframe.src = iframe.dataset.src; 65 }); 66 } 67 68 function loadVideo(video: HTMLVideoElement): Promise<void> { 69 return new Promise((resolve, reject) => { 70 video.oncanplay = () => resolve(); 71 video.onerror = () => reject(new Error("video load failed")); 72 if (video.dataset.src) video.src = video.dataset.src; 73 video.preload = "metadata"; 74 video.load(); 75 }); 76 }
Lazy Loading in React
React provides several patterns for lazy loading components, from simple React.lazy to custom hooks that combine Intersection Observer with dynamic imports.
react-lazy-patterns.tsx wrap TypeScript
Copy 1 "use client"; 2 3 import { useState, useEffect, useRef, Suspense, lazy } from "react"; 4 5 // Pattern 1: React.lazy for route-level lazy loading 6 const Dashboard = lazy(() => import("@/pages/Dashboard")); 7 const Settings = lazy(() => import("@/pages/Settings")); 8 const Analytics = lazy(() => import("@/pages/Analytics")); 9 10 function AppRoutes() { 11 return ( 12 <Suspense fallback={<PageSkeleton />}> 13 <Routes> 14 <Route path="/dashboard" element={<Dashboard />} /> 15 <Route path="/settings" element={<Settings />} /> 16 <Route path="/analytics" element={<Analytics />} /> 17 </Routes> 18 </Suspense> 19 ); 20 } 21 22 // Pattern 2: Intersection Observer + lazy import hook 23 function useLazyComponent<T>( 24 importFn: () => Promise<{ default: React.ComponentType<T> }>, 25 options?: { rootMargin?: string } 26 ) { 27 const [Component, setComponent] = useState<React.ComponentType<T> | null>(null); 28 const ref = useRef<HTMLDivElement>(null); 29 30 useEffect(() => { 31 if (!ref.current) return; 32 33 const observer = new IntersectionObserver( 34 ([entry]) => { 35 if (entry.isIntersecting) { 36 importFn().then((mod) => setComponent(() => mod.default)); 37 observer.disconnect(); 38 } 39 }, 40 { rootMargin: options?.rootMargin || "200px" } 41 ); 42 43 observer.observe(ref.current); 44 return () => observer.disconnect(); 45 }, [importFn, options?.rootMargin]); 46 47 return { ref, Component }; 48 } 49 50 // Usage of the hook 51 function ProductPage() { 52 const { ref: reviewsRef, Component: Reviews } = useLazyComponent( 53 () => import("./ProductReviews") 54 ); 55 const { ref: relatedRef, Component: Related } = useLazyComponent( 56 () => import("./RelatedProducts") 57 ); 58 59 return ( 60 <div> 61 <ProductHero /> 62 <div ref={reviewsRef}> 63 {Reviews ? <Reviews productId="123" /> : <ReviewsSkeleton />} 64 </div> 65 <div ref={relatedRef}> 66 {Related ? <Related /> : <RelatedSkeleton />} 67 </div> 68 </div> 69 ); 70 }
react-lazy-components.tsx wrap TypeScript
Copy 1 "use client"; 2 3 import { useState, useEffect, useCallback, useRef } from "react"; 4 5 // Pattern 3: Generic lazy load wrapper component 6 function LazyLoad({ 7 children, 8 fallback, 9 rootMargin = "200px", 10 once = true, 11 }: { 12 children: React.ReactNode; 13 fallback?: React.ReactNode; 14 rootMargin?: string; 15 once?: boolean; 16 }) { 17 const [isVisible, setIsVisible] = useState(false); 18 const ref = useRef<HTMLDivElement>(null); 19 20 useEffect(() => { 21 const element = ref.current; 22 if (!element) return; 23 24 const observer = new IntersectionObserver( 25 ([entry]) => { 26 if (entry.isIntersecting) { 27 setIsVisible(true); 28 if (once) observer.disconnect(); 29 } else if (!once) { 30 setIsVisible(false); 31 } 32 }, 33 { rootMargin } 34 ); 35 36 observer.observe(element); 37 return () => observer.disconnect(); 38 }, [rootMargin, once]); 39 40 return ( 41 <div ref={ref}> 42 {isVisible ? children : (fallback || null)} 43 </div> 44 ); 45 } 46 47 // Usage — lazy load heavy sections 48 function LongPage() { 49 return ( 50 <div> 51 <header> 52 <h1>Welcome</h1> 53 <p>Above the fold content loads immediately</p> 54 </header> 55 56 <LazyLoad fallback={<SectionSkeleton />}> 57 <HeavyChart /> 58 </LazyLoad> 59 60 <LazyLoad fallback={<SectionSkeleton />} rootMargin="400px"> 61 <CommentSection /> 62 </LazyLoad> 63 64 <LazyLoad> 65 <VideoPlayer src="/intro.mp4" /> 66 </LazyLoad> 67 68 <LazyLoad fallback={<TableSkeleton />} rootMargin="600px"> 69 <DataTable query="/api/transactions" /> 70 </LazyLoad> 71 </div> 72 ); 73 } 74 75 // Pattern 4: Lazy load with preload on interaction 76 function LazyWithPreload({ 77 loadFn, 78 children, 79 preloadOn = "hover", 80 }: { 81 loadFn: () => Promise<{ default: React.ComponentType }>; 82 children: (Component: React.ComponentType) => React.ReactNode; 83 preloadOn?: "hover" | "focus" | "visible"; 84 }) { 85 const [Component, setComponent] = useState<React.ComponentType | null>(null); 86 const [loading, setLoading] = useState(false); 87 const promiseRef = useRef<Promise<{ default: React.ComponentType }> | null>(null); 88 89 const load = useCallback(() => { 90 if (promiseRef.current || Component) return; 91 setLoading(true); 92 promiseRef.current = loadFn(); 93 promiseRef.current.then((mod) => { 94 setComponent(() => mod.default); 95 setLoading(false); 96 }); 97 }, [loadFn, Component]); 98 99 useEffect(() => { 100 if (preloadOn === "visible" && !Component) { 101 const observer = new IntersectionObserver( 102 ([entry]) => { if (entry.isIntersecting) { load(); observer.disconnect(); } }, 103 { rootMargin: "200px" } 104 ); 105 const el = document.getElementById("lazy-preload-trigger"); 106 if (el) observer.observe(el); 107 return () => observer.disconnect(); 108 } 109 }, [preloadOn, load, Component]); 110 111 const handlers = preloadOn === "hover" 112 ? { onMouseEnter: load } 113 : preloadOn === "focus" 114 ? { onFocus: load } 115 : {}; 116 117 return ( 118 <div {...handlers}> 119 {Component ? children(Component) : loading ? <span>Loading...</span> : <div id="lazy-preload-trigger" />} 120 </div> 121 ); 122 }
✓ best practice
Combine Intersection Observer visibility detection with React.lazy for the best of both worlds: components only load their JavaScript bundle when they are about to enter the viewport, not just when the route changes.
Lazy Loading Iframes
Embedded iframes (YouTube videos, maps, social media posts) are among the heaviest resources on a page. A single YouTube embed can load 1MB+ of JavaScript. Lazy loading iframes with the native loading="lazy" attribute or Intersection Observer can save hundreds of milliseconds and megabytes.
Copy 1 <!-- Native lazy loading for iframes --> 2 <iframe 3 src="https://www.youtube.com/embed/dQw4w9WgXcQ" 4 loading="lazy" 5 title="Video embed" 6 width="560" 7 height="315" 8 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 9 allowfullscreen> 10 </iframe> 11 12 <!-- Google Maps embed with lazy loading --> 13 <iframe 14 src="https://www.google.com/maps/embed?pb=..." 15 loading="lazy" 16 title="Map location" 17 width="600" 18 height="450" 19 style="border: 0" 20 allowfullscreen 21 referrerpolicy="no-referrer-when-downgrade"> 22 </iframe> 23 24 <!-- Fbunde-style iframe — placeholder with click-to-load --> 25 <div class="video-embed" data-src="https://www.youtube.com/embed/VIDEO_ID"> 26 <img 27 src="https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg" 28 alt="Video thumbnail" 29 width="560" 30 height="315" 31 loading="lazy" 32 /> 33 <button class="play-button" aria-label="Load video">▶</button> 34 </div> 35 36 <style> 37 .video-embed { 38 position: relative; 39 cursor: pointer; 40 aspect-ratio: 16/9; 41 } 42 .video-embed img { 43 width: 100%; 44 height: 100%; 45 object-fit: cover; 46 } 47 .play-button { 48 position: absolute; 49 top: 50%; 50 left: 50%; 51 transform: translate(-50%, -50%); 52 width: 68px; 53 height: 48px; 54 background: rgba(0, 0, 0, 0.7); 55 color: white; 56 border: none; 57 border-radius: 12px; 58 font-size: 24px; 59 cursor: pointer; 60 } 61 </style>
click-to-load-iframe.tsx wrap TypeScript
Copy 1 // Click-to-load iframe pattern (no JavaScript until clicked) 2 function ClickToLoadIframe({ 3 src, 4 thumbnail, 5 title, 6 }: { 7 src: string; 8 thumbnail: string; 9 title: string; 10 }) { 11 const [loaded, setLoaded] = useState(false); 12 13 if (loaded) { 14 return ( 15 <iframe 16 src={src} 17 title={title} 18 width="560" 19 height="315" 20 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 21 allowFullScreen 22 className="w-full aspect-video" 23 /> 24 ); 25 } 26 27 return ( 28 <div 29 className="relative cursor-pointer group aspect-video" 30 onClick={() => setLoaded(true)} 31 role="button" 32 tabIndex={0} 33 onKeyDown={(e) => e.key === "Enter" && setLoaded(true)} 34 aria-label={"Load " + title} 35 > 36 <img 37 src={thumbnail} 38 alt={title} 39 className="w-full h-full object-cover" 40 loading="lazy" 41 /> 42 <div className="absolute inset-0 flex items-center justify-center"> 43 <div className="w-16 h-12 bg-black/70 rounded-xl flex items-center justify-center group-hover:bg-black/90 transition-colors"> 44 <svg className="w-6 h-6 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24"> 45 <path d="M8 5v14l11-7z" /> 46 </svg> 47 </div> 48 </div> 49 </div> 50 ); 51 }
Advanced Image Lazy Loading
Beyond basic lazy loading, advanced techniques include progressive image loading, blur-up placeholders, and responsive lazy loading with srcset. These create a polished loading experience.
lazy-image-component.tsx wrap TypeScript
Copy 1 "use client"; 2 3 import { useState, useEffect, useRef } from "react"; 4 5 // Blur-up lazy loading component 6 function LazyImage({ 7 src, 8 srcSet, 9 alt, 10 width, 11 height, 12 className = "", 13 }: { 14 src: string; 15 srcSet?: string; 16 alt: string; 17 width: number; 18 height: number; 19 className?: string; 20 }) { 21 const [isLoaded, setIsLoaded] = useState(false); 22 const [isInView, setIsInView] = useState(false); 23 const imgRef = useRef<HTMLDivElement>(null); 24 25 useEffect(() => { 26 if (!imgRef.current) return; 27 28 const observer = new IntersectionObserver( 29 ([entry]) => { 30 if (entry.isIntersecting) { 31 setIsInView(true); 32 observer.disconnect(); 33 } 34 }, 35 { rootMargin: "200px" } 36 ); 37 38 observer.observe(imgRef.current); 39 return () => observer.disconnect(); 40 }, []); 41 42 return ( 43 <div 44 ref={imgRef} 45 className={`relative overflow-hidden ${className}`} 46 style={{ aspectRatio: `${width}/${height}` }} 47 > 48 {/* Blur placeholder */} 49 <div 50 className={`absolute inset-0 bg-gray-200 transition-opacity duration-500 ${ 51 isLoaded ? "opacity-0" : "opacity-100" 52 }`} 53 /> 54 55 {/* Actual image */} 56 {isInView && ( 57 <img 58 src={src} 59 srcSet={srcSet} 60 alt={alt} 61 width={width} 62 height={height} 63 loading="lazy" 64 onLoad={() => setIsLoaded(true)} 65 className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-500 ${ 66 isLoaded ? "opacity-100" : "opacity-0" 67 }`} 68 /> 69 )} 70 </div> 71 ); 72 }
progressive-image.tsx wrap TypeScript
Copy 1 // Progressive image loading with low-quality placeholder (LQP) 2 // Generate a tiny placeholder at build time 3 4 // In your build pipeline (e.g., next.config.js): 5 // Use sharp or imagify to generate 20px-wide placeholders 6 7 // Component with LQP support 8 function ProgressiveImage({ 9 src, 10 placeholder, 11 alt, 12 width, 13 height, 14 }: { 15 src: string; 16 placeholder: string; // Tiny base64-encoded placeholder 17 alt: string; 18 width: number; 19 height: number; 20 }) { 21 const [currentSrc, setCurrentSrc] = useState(placeholder); 22 const [isLoaded, setIsLoaded] = useState(false); 23 const imgRef = useRef<HTMLImageElement>(null); 24 25 useEffect(() => { 26 if (!imgRef.current) return; 27 28 const observer = new IntersectionObserver( 29 ([entry]) => { 30 if (entry.isIntersecting) { 31 // Start loading the real image 32 const img = new Image(); 33 img.onload = () => { 34 setCurrentSrc(src); 35 setIsLoaded(true); 36 }; 37 img.src = src; 38 observer.disconnect(); 39 } 40 }, 41 { rootMargin: "200px" } 42 ); 43 44 observer.observe(imgRef.current); 45 return () => observer.disconnect(); 46 }, [src]); 47 48 return ( 49 <img 50 ref={imgRef} 51 src={currentSrc} 52 alt={alt} 53 width={width} 54 height={height} 55 className={`transition-all duration-500 ${ 56 isLoaded ? "blur-0 scale-100" : "blur-sm scale-105" 57 }`} 58 style={{ aspectRatio: `${width}/${height}` }} 59 /> 60 ); 61 } 62 63 // Usage in a page 64 function Gallery() { 65 return ( 66 <div className="grid grid-cols-3 gap-4"> 67 {photos.map((photo) => ( 68 <ProgressiveImage 69 key={photo.id} 70 src={photo.url} 71 placeholder={photo.placeholder} // data:image/jpeg;base64,/9j/4AAQ... 72 alt={photo.alt} 73 width={400} 74 height={300} 75 /> 76 ))} 77 </div> 78 ); 79 }
Lazy Loading Videos
Video elements can be expensive to load. Use Intersection Observer to defer video loading and only preload metadata until the user interacts.
lazy-video.tsx wrap TypeScript
Copy 1 "use client"; 2 3 import { useEffect, useRef, useState } from "react"; 4 5 function LazyVideo({ 6 src, 7 poster, 8 className = "", 9 }: { 10 src: string; 11 poster: string; 12 className?: string; 13 }) { 14 const videoRef = useRef<HTMLVideoElement>(null); 15 const [shouldLoad, setShouldLoad] = useState(false); 16 17 useEffect(() => { 18 const video = videoRef.current; 19 if (!video || shouldLoad) return; 20 21 // Start with preload="none", only load metadata when near viewport 22 const observer = new IntersectionObserver( 23 ([entry]) => { 24 if (entry.isIntersecting) { 25 video.preload = "metadata"; 26 video.load(); 27 setShouldLoad(true); 28 observer.disconnect(); 29 } 30 }, 31 { rootMargin: "300px" } 32 ); 33 34 observer.observe(video); 35 return () => observer.disconnect(); 36 }, [shouldLoad]); 37 38 return ( 39 <video 40 ref={videoRef} 41 src={shouldLoad ? src : undefined} 42 poster={poster} 43 controls 44 preload="none" 45 className={className} 46 playsInline 47 > 48 <track kind="captions" src="/captions.vtt" label="English" /> 49 </video> 50 ); 51 } 52 53 // Autoplay video that only plays when visible 54 function AutoPlayVideo({ src, poster }: { src: string; poster: string }) { 55 const videoRef = useRef<HTMLVideoElement>(null); 56 57 useEffect(() => { 58 const video = videoRef.current; 59 if (!video) return; 60 61 const observer = new IntersectionObserver( 62 ([entry]) => { 63 if (entry.isIntersecting) { 64 video.play().catch(() => {}); // Autoplay may be blocked 65 } else { 66 video.pause(); 67 } 68 }, 69 { threshold: 0.5 } // 50% visible to play 70 ); 71 72 observer.observe(video); 73 return () => observer.disconnect(); 74 }, []); 75 76 return ( 77 <video 78 ref={videoRef} 79 src={src} 80 poster={poster} 81 muted 82 loop 83 playsInline 84 preload="none" 85 className="w-full" 86 /> 87 ); 88 }
Best Practices
◆ Use native loading="lazy" for simple image and iframe lazy loading — zero JavaScript needed
◆ Use Intersection Observer for complex lazy loading (components, sections, custom logic)
◆ Always set width and height on lazy images to prevent layout shift
◆ Set rootMargin to 200-400px to preload resources before they enter the viewport
◆ Never use scroll event listeners for lazy loading — always prefer Intersection Observer
◆ For videos, use preload="none" and only load metadata when near viewport
◆ Consider click-to-load for heavy embeds (YouTube, maps, social media posts)
◆ Combine lazy loading with responsive images (srcset) for maximum benefit
◆ Always provide a loading placeholder (blur-up, solid color, or skeleton)
◆ Monitor lazy loading metrics in production to verify the impact
$ Blueprint — Engineering Documentation · Section ID: PERF-LL-01 · Revision: 1.0