Lazy Loading in JavaScript: Complete Tutorial
A complete tutorial on lazy loading in JavaScript. Covers IntersectionObserver for images, native lazy loading, component lazy loading, module lazy loading, progressive data loading, placeholder strategies, and building a full lazy loading framework.
Lazy loading defers the loading of resources until they are needed, reducing initial page weight and improving load times. This tutorial covers every lazy loading pattern from images to components to data.
For image-specific patterns and component lazy loading, see How to Lazy Load Images and Components in JS.
IntersectionObserver for Lazy Loading
class LazyLoader {
constructor(options = {}) {
this.rootMargin = options.rootMargin || "200px 0px";
this.threshold = options.threshold || 0;
this.loaded = new Set();
this.observer = new IntersectionObserver(
(entries) => this.handleEntries(entries),
{
rootMargin: this.rootMargin,
threshold: this.threshold,
}
);
}
observe(elements) {
if (typeof elements === "string") {
elements = document.querySelectorAll(elements);
}
elements.forEach((el) => {
if (!this.loaded.has(el)) {
this.observer.observe(el);
}
});
}
handleEntries(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadElement(entry.target);
this.observer.unobserve(entry.target);
this.loaded.add(entry.target);
}
});
}
loadElement(el) {
const tagName = el.tagName.toLowerCase();
switch (tagName) {
case "img":
this.loadImage(el);
break;
case "video":
this.loadVideo(el);
break;
case "iframe":
this.loadIframe(el);
break;
default:
this.loadBackground(el);
}
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
if (srcset) img.srcset = srcset;
if (src) {
img.src = src;
img.addEventListener("load", () => img.classList.add("loaded"));
img.addEventListener("error", () => img.classList.add("error"));
}
}
loadVideo(video) {
const sources = video.querySelectorAll("source[data-src]");
sources.forEach((source) => {
source.src = source.dataset.src;
});
video.load();
}
loadIframe(iframe) {
iframe.src = iframe.dataset.src;
}
loadBackground(el) {
const bg = el.dataset.bg;
if (bg) {
el.style.backgroundImage = `url('${bg}')`;
el.classList.add("loaded");
}
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const loader = new LazyLoader({ rootMargin: "300px 0px" });
loader.observe("[data-src]");Native Lazy Loading
// HTML native lazy loading (images and iframes)
// <img src="photo.jpg" loading="lazy" alt="Photo">
// <iframe src="video.html" loading="lazy"></iframe>
// Progressive enhancement: use native when available, polyfill otherwise
function setupLazyImages() {
const images = document.querySelectorAll("img[data-src]");
if ("loading" in HTMLImageElement.prototype) {
// Browser supports native lazy loading
images.forEach((img) => {
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.loading = "lazy";
});
console.log("Using native lazy loading");
} else {
// Fallback to IntersectionObserver
const loader = new LazyLoader();
loader.observe(images);
console.log("Using IntersectionObserver lazy loading");
}
}
// Eager vs lazy loading decision
function optimizeImageLoading() {
const images = document.querySelectorAll("img[data-src]");
images.forEach((img) => {
const rect = img.getBoundingClientRect();
const isAboveFold = rect.top < window.innerHeight;
img.src = img.dataset.src;
// Above fold: load eagerly for better LCP
// Below fold: load lazily to save bandwidth
img.loading = isAboveFold ? "eager" : "lazy";
// Optionally add fetchpriority for hero images
if (img.dataset.hero) {
img.fetchPriority = "high";
}
});
}Component Lazy Loading
class LazyComponent {
constructor(container, loader, options = {}) {
this.container = typeof container === "string"
? document.querySelector(container)
: container;
this.loader = loader;
this.placeholder = options.placeholder || this.defaultPlaceholder();
this.loadOnVisible = options.loadOnVisible !== false;
this.module = null;
this.observer = null;
}
defaultPlaceholder() {
const el = document.createElement("div");
el.className = "lazy-placeholder";
el.style.cssText = "min-height:200px;background:#f0f0f0;border-radius:8px;";
return el;
}
init() {
this.container.appendChild(this.placeholder);
if (this.loadOnVisible) {
this.observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
this.load();
this.observer.disconnect();
}
},
{ rootMargin: "100px" }
);
this.observer.observe(this.container);
} else {
this.load();
}
return this;
}
async load() {
try {
this.module = await this.loader();
const Component = this.module.default || this.module;
this.container.innerHTML = "";
if (typeof Component === "function") {
const rendered = Component(this.container);
if (rendered instanceof HTMLElement) {
this.container.appendChild(rendered);
}
}
this.container.classList.add("lazy-loaded");
} catch (error) {
this.container.innerHTML = `
<div class="lazy-error">
<p>Failed to load component</p>
<button class="retry-btn">Retry</button>
</div>
`;
this.container.querySelector(".retry-btn").addEventListener(
"click",
() => this.load()
);
}
}
}
// Usage
new LazyComponent("#chart-area", () => import("./components/Chart")).init();
new LazyComponent("#comments", () => import("./components/Comments"), {
loadOnVisible: true,
}).init();Progressive Data Loading
class ProgressiveLoader {
constructor(options = {}) {
this.batchSize = options.batchSize || 20;
this.threshold = options.threshold || 0.8;
this.loading = false;
this.hasMore = true;
this.offset = 0;
this.items = [];
}
async fetchBatch(url) {
if (this.loading || !this.hasMore) return [];
this.loading = true;
try {
const separator = url.includes("?") ? "&" : "?";
const response = await fetch(
`${url}${separator}offset=${this.offset}&limit=${this.batchSize}`
);
const data = await response.json();
if (data.items.length < this.batchSize) {
this.hasMore = false;
}
this.offset += data.items.length;
this.items.push(...data.items);
return data.items;
} finally {
this.loading = false;
}
}
observeScrollEnd(container, url, renderItem) {
const sentinel = document.createElement("div");
sentinel.className = "scroll-sentinel";
container.appendChild(sentinel);
const observer = new IntersectionObserver(
async ([entry]) => {
if (entry.isIntersecting && this.hasMore) {
const newItems = await this.fetchBatch(url);
newItems.forEach((item) => {
const el = renderItem(item);
container.insertBefore(el, sentinel);
});
if (!this.hasMore) {
observer.disconnect();
sentinel.remove();
}
}
},
{ rootMargin: "400px" }
);
observer.observe(sentinel);
return observer;
}
}
// Usage
const loader = new ProgressiveLoader({ batchSize: 25 });
const container = document.getElementById("feed");
loader.observeScrollEnd(container, "/api/posts", (post) => {
const el = document.createElement("article");
el.innerHTML = `<h3>${post.title}</h3><p>${post.excerpt}</p>`;
return el;
});Script Lazy Loading
class ScriptLoader {
constructor() {
this.loaded = new Map();
this.loading = new Map();
}
load(src, options = {}) {
if (this.loaded.has(src)) {
return Promise.resolve(this.loaded.get(src));
}
if (this.loading.has(src)) {
return this.loading.get(src);
}
const promise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = options.async !== false;
if (options.integrity) {
script.integrity = options.integrity;
script.crossOrigin = "anonymous";
}
script.onload = () => {
this.loaded.set(src, true);
this.loading.delete(src);
resolve(true);
};
script.onerror = () => {
this.loading.delete(src);
reject(new Error(`Failed to load script: ${src}`));
};
document.head.appendChild(script);
});
this.loading.set(src, promise);
return promise;
}
async loadWithDeps(scripts) {
// Load scripts in dependency order
for (const script of scripts) {
await this.load(script.src, script.options);
}
}
async loadParallel(sources) {
return Promise.all(sources.map((src) => this.load(src)));
}
}
// Usage
const scripts = new ScriptLoader();
// Load third-party libraries on demand
async function initMap() {
await scripts.load("https://maps.googleapis.com/maps/api/js?key=KEY");
const map = new google.maps.Map(document.getElementById("map"), {
center: { lat: 40.7, lng: -74.0 },
zoom: 12,
});
}
document.getElementById("show-map").addEventListener("click", initMap, {
once: true,
});| Pattern | Trigger | Best For | Savings |
|---|---|---|---|
| Image lazy loading | Viewport proximity | Media-heavy pages | 40-70% bandwidth |
| Component lazy loading | Visibility or interaction | Complex widgets | 30-60% JS size |
| Route lazy loading | Navigation | SPAs | 60-80% initial bundle |
| Data lazy loading | Scroll position | Long lists/feeds | Faster initial render |
| Script lazy loading | User action | Third-party libs | Removes blocking scripts |
Rune AI
Key Insights
- IntersectionObserver is the most flexible lazy loading API: It supports custom root margins, thresholds, and works with any DOM element including images, iframes, and component containers
- Native loading="lazy" requires zero JavaScript: Use it for images and iframes as the default, with IntersectionObserver only for custom behaviors
- Never lazy load above-the-fold content: Hero images and critical content should load eagerly with fetchpriority="high" to optimize Largest Contentful Paint
- Progressive data loading reduces initial render time: Load only the first batch of data and fetch more as the user scrolls, using a sentinel element observed by IntersectionObserver
- Component lazy loading with error boundaries provides resilience: Wrap lazy component loading in try/catch with retry buttons so users can recover from network failures
Frequently Asked Questions
Should I use native loading="lazy" or IntersectionObserver?
What rootMargin should I use for IntersectionObserver lazy loading?
How do I handle lazy loading with SEO?
Can lazy loading hurt Largest Contentful Paint (LCP)?
How do I lazy load CSS along with components?
Conclusion
Lazy loading reduces initial page weight by deferring non-critical resources. IntersectionObserver provides precise viewport-based triggers, while native loading="lazy" handles images with zero JavaScript. Component and script lazy loading eliminate heavy framework and library code from the initial bundle. For image-specific optimizations, see How to Lazy Load Images and Components in JS. For infinite scrolling built on lazy data loading, see Implementing Infinite Scroll with JS Observers.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.