|$ curl https://forge-ai.dev/api/markdown?path=docs/performance/lazy-loading
$cat docs/lazy-loading.md
updated Recently·35 min read·published

Lazy Loading

PerformanceImagesComponentsIntersection ObserverIntermediate🎯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
HTML
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.
ValueBehaviorBrowser Fetch DistanceUse Case
lazyDeferred until near viewport~1250px from viewportBelow-the-fold images, iframes
eagerLoaded immediatelyImmediateAbove-the-fold, LCP candidates
autoBrowser decidesBrowser defaultWhen 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
TypeScript
1// Core Intersection Observer for lazy loading images
2function 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
34if (document.readyState === "loading") {
35 document.addEventListener("DOMContentLoaded", initLazyImages);
36} else {
37 initLazyImages();
38}
lazy-image-html.html
HTML
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
TypeScript
1// Advanced Intersection Observer with multiple thresholds
2function 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
50function 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
60function 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
68function 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
TypeScript
1"use client";
2
3import { useState, useEffect, useRef, Suspense, lazy } from "react";
4
5// Pattern 1: React.lazy for route-level lazy loading
6const Dashboard = lazy(() => import("@/pages/Dashboard"));
7const Settings = lazy(() => import("@/pages/Settings"));
8const Analytics = lazy(() => import("@/pages/Analytics"));
9
10function 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
23function 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
51function 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
TypeScript
1"use client";
2
3import { useState, useEffect, useCallback, useRef } from "react";
4
5// Pattern 3: Generic lazy load wrapper component
6function 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
48function 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
76function 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.

iframe-lazy.html
HTML
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
TypeScript
1// Click-to-load iframe pattern (no JavaScript until clicked)
2function 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
TypeScript
1"use client";
2
3import { useState, useEffect, useRef } from "react";
4
5// Blur-up lazy loading component
6function 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
TypeScript
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
8function 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
64function 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
TypeScript
1"use client";
2
3import { useEffect, useRef, useState } from "react";
4
5function 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
54function 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}
Scroll Events vs Intersection Observer

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.

scroll-vs-observer.js
TypeScript
1// BAD: Scroll event listener (layout thrashing, performance hit)
2function 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)
22function 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

Never use scroll event listeners for lazy loading in production. They cause layout thrashing and degrade performance. Intersection Observer is faster, cleaner, and supported in all modern browsers (97%+ global coverage).
Performance Impact

The impact of lazy loading depends on the number and size of deferred resources. Here are typical improvements seen across real-world implementations.

ScenarioBefore Lazy LoadingAfter Lazy LoadingImprovement
E-commerce product page (20 images)4.2 MB initial1.1 MB initial74% reduction
Blog post with embeds (5 iframes)3.8 MB initial0.8 MB initial79% reduction
Dashboard (charts + widgets)2.1 MB initial0.5 MB initial76% reduction
Social media feed (infinite scroll)6.5 MB initial0.3 MB initial95% reduction
lazy-loading-metrics.ts
TypeScript
1// Measuring lazy loading impact in production
2function 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}
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