Lazy Loading
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.
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.
| 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
| 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 |
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.
| 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 | } |
| 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
| 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 | } |
React provides several patterns for lazy loading components, from simple React.lazy to custom hooks that combine Intersection Observer with dynamic imports.
| 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 | } |
| 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
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.
| 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> |
| 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 | } |
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.
| 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 | } |
| 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 | } |
Video elements can be expensive to load. Use Intersection Observer to defer video loading and only preload metadata until the user interacts.
| 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 | } |
Before Intersection Observer, lazy loading was implemented with scroll event listeners. This approach is problematic because scroll events fire at 60+ times per second, causing layout thrashing and poor performance. Always prefer Intersection Observer.
| 1 | // BAD: Scroll event listener (layout thrashing, performance hit) |
| 2 | function lazyLoadScroll() { |
| 3 | const images = document.querySelectorAll("img[data-src]"); |
| 4 | |
| 5 | function onScroll() { |
| 6 | images.forEach((img) => { |
| 7 | const rect = img.getBoundingClientRect(); // Forces layout calculation |
| 8 | if (rect.top < window.innerHeight + 200) { |
| 9 | const image = img as HTMLImageElement; |
| 10 | image.src = image.dataset.src || ""; |
| 11 | image.classList.add("loaded"); |
| 12 | } |
| 13 | }); |
| 14 | } |
| 15 | |
| 16 | // Even with passive: true and debounce, this is suboptimal |
| 17 | window.addEventListener("scroll", onScroll, { passive: true }); |
| 18 | onScroll(); // Initial check |
| 19 | } |
| 20 | |
| 21 | // GOOD: Intersection Observer (no layout thrashing, off-main-thread) |
| 22 | function lazyLoadObserver() { |
| 23 | const observer = new IntersectionObserver( |
| 24 | (entries) => { |
| 25 | entries.forEach((entry) => { |
| 26 | if (entry.isIntersecting) { |
| 27 | const img = entry.target as HTMLImageElement; |
| 28 | img.src = img.dataset.src || ""; |
| 29 | img.classList.add("loaded"); |
| 30 | observer.unobserve(img); |
| 31 | } |
| 32 | }); |
| 33 | }, |
| 34 | { rootMargin: "200px" } |
| 35 | ); |
| 36 | |
| 37 | document.querySelectorAll("img[data-src]").forEach((img) => { |
| 38 | observer.observe(img); |
| 39 | }); |
| 40 | } |
| 41 | |
| 42 | // Comparison: |
| 43 | // Scroll listener: |
| 44 | // - Runs on main thread |
| 45 | // - Forces layout on every getBoundingClientRect call |
| 46 | // - Must manually debounce/throttle |
| 47 | // - ~60 calls/second = ~60 layout calculations/second |
| 48 | // |
| 49 | // Intersection Observer: |
| 50 | // - Runs off main thread (browser internal) |
| 51 | // - No layout thrashing |
| 52 | // - No throttling needed |
| 53 | // - Browser optimizes internally |
| 54 | // - ~1 call when element enters viewport |
danger
The impact of lazy loading depends on the number and size of deferred resources. Here are typical improvements seen across real-world implementations.
| Scenario | Before Lazy Loading | After Lazy Loading | Improvement |
|---|---|---|---|
| E-commerce product page (20 images) | 4.2 MB initial | 1.1 MB initial | 74% reduction |
| Blog post with embeds (5 iframes) | 3.8 MB initial | 0.8 MB initial | 79% reduction |
| Dashboard (charts + widgets) | 2.1 MB initial | 0.5 MB initial | 76% reduction |
| Social media feed (infinite scroll) | 6.5 MB initial | 0.3 MB initial | 95% reduction |
| 1 | // Measuring lazy loading impact in production |
| 2 | function measureLazyLoadingImpact() { |
| 3 | const resources = performance.getEntriesByType("resource"); |
| 4 | const images = resources.filter((r) => r.initiatorType === "img"); |
| 5 | |
| 6 | const totalTransferSize = images.reduce( |
| 7 | (sum, img) => sum + (img as PerformanceResourceTiming).transferSize, |
| 8 | 0 |
| 9 | ); |
| 10 | |
| 11 | const lazyImages = document.querySelectorAll("img[loading='lazy']").length; |
| 12 | const eagerImages = document.querySelectorAll("img[loading='eager'], img:not([loading])").length; |
| 13 | |
| 14 | console.table({ |
| 15 | "Total images": images.length, |
| 16 | "Lazy images": lazyImages, |
| 17 | "Eager images": eagerImages, |
| 18 | "Total transfer size": (totalTransferSize / 1024 / 1024).toFixed(2) + " MB", |
| 19 | "Avg image size": (totalTransferSize / images.length / 1024).toFixed(1) + " KB", |
| 20 | }); |
| 21 | |
| 22 | // Track resource loading waterfall |
| 23 | const sortedByStartTime = [...images].sort( |
| 24 | (a, b) => a.startTime - b.startTime |
| 25 | ); |
| 26 | |
| 27 | const firstBatch = sortedByStartTime.filter((r) => r.startTime < 1000); |
| 28 | const deferredBatch = sortedByStartTime.filter((r) => r.startTime >= 1000); |
| 29 | |
| 30 | console.log("First batch (eager):", firstBatch.length, "images"); |
| 31 | console.log("Deferred batch:", deferredBatch.length, "images"); |
| 32 | } |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.