Lazy Loading
Ship Only What the User Is About to See
LinkedIn Hook
Your landing page is 2.4MB of JavaScript. The user lands, bounces after 3 seconds, and 2.3MB of that bundle was never executed.
You paid for the download. The user paid for the download. Nobody benefited.
Lazy loading flips that math: load what's visible right now, defer everything else until the user actually asks for it. Images load as they scroll into view. Route bundles download when the user clicks the link. Heavy components appear with a Suspense fallback instead of blocking the paint.
In this lesson: native
loading="lazy", a from-scratch IntersectionObserver image loader, dynamicimport()for routes, React.lazy + Suspense, hover-based preloading — and the one mistake that makes lazy loading hurt instead of help (lazy loading above-the-fold content).If your Largest Contentful Paint is over 2.5s — this lesson is the fix.
Read the full lesson -> [link]
#JavaScript #WebPerformance #LazyLoading #IntersectionObserver #CodeSplitting #CoreWebVitals #Frontend
What You'll Learn
- How to lazy load images with the native
loading="lazy"attribute and with IntersectionObserver from scratch - How dynamic
import()enables route-based code splitting and React.lazy + Suspense - How to preload the next likely chunk on hover/focus — and when lazy loading actually hurts performance
The Library Analogy
Imagine a library with a million books. You don't bring all million books to the reading desk when you walk in — you only fetch the one you need, when you need it. Lazy loading applies the same principle to web resources: don't load what the user hasn't asked for yet.
Native Lazy Loading for Images
// HTML attribute — simplest approach (Chrome 76+, widely supported)
// <img src="photo.jpg" loading="lazy" alt="Description">
// <iframe src="embed.html" loading="lazy"></iframe>
// Three values:
// loading="lazy" — defer until near viewport
// loading="eager" — load immediately (default)
// loading="auto" — browser decides
// IMPORTANT: Always set width and height to prevent layout shift
// <img src="photo.jpg" loading="lazy" width="800" height="600" alt="Photo">
Lazy Loading Images with IntersectionObserver (From Scratch)
class LazyImageLoader {
constructor(options = {}) {
this.options = {
rootMargin: options.rootMargin || "200px 0px", // load 200px before visible
threshold: options.threshold || 0,
placeholder: options.placeholder || "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
};
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: this.options.rootMargin,
threshold: this.options.threshold
}
);
this.loadedCount = 0;
this.totalCount = 0;
}
handleIntersection(entries) {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
this.loadImage(img);
this.observer.unobserve(img); // stop watching after load
});
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
if (!src) return;
// Create a temporary image to preload
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
if (srcset) img.srcset = srcset;
img.classList.remove("lazy");
img.classList.add("loaded");
this.loadedCount++;
img.dispatchEvent(new CustomEvent("lazyloaded"));
};
tempImg.onerror = () => {
img.classList.add("lazy-error");
console.warn(`Failed to lazy load: ${src}`);
};
tempImg.src = src; // triggers the actual download
}
observe(selector = "img[data-src]") {
const images = document.querySelectorAll(selector);
this.totalCount = images.length;
images.forEach(img => this.observer.observe(img));
return this; // chainable
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const loader = new LazyImageLoader({ rootMargin: "300px 0px" });
loader.observe();
// HTML structure:
// <img data-src="real-photo.jpg"
// data-srcset="photo-400.jpg 400w, photo-800.jpg 800w"
// src="placeholder.jpg"
// class="lazy"
// alt="Description">
Route-Based Lazy Loading (Dynamic Import)
// STATIC import — bundled into main chunk, loaded upfront
import { Dashboard } from "./pages/Dashboard";
import { Settings } from "./pages/Settings";
import { Analytics } from "./pages/Analytics";
// DYNAMIC import — separate chunks, loaded on demand
async function loadPage(pageName) {
switch (pageName) {
case "dashboard":
const { Dashboard } = await import("./pages/Dashboard");
return Dashboard;
case "settings":
const { Settings } = await import("./pages/Settings");
return Settings;
case "analytics":
const { Analytics } = await import("./pages/Analytics");
return Analytics;
}
}
// Real-world router example
class Router {
constructor() {
this.routes = {
"/": () => import("./pages/Home"),
"/dashboard": () => import("./pages/Dashboard"),
"/settings": () => import("./pages/Settings"),
"/analytics": () => import("./pages/Analytics")
};
}
async navigate(path) {
const loader = this.routes[path];
if (!loader) {
const { NotFound } = await import("./pages/NotFound");
return NotFound;
}
const module = await loader();
return module.default; // the page component
}
}
Component Lazy Loading (React Pattern)
// React.lazy + Suspense
import React, { lazy, Suspense } from "react";
// Instead of: import HeavyChart from "./HeavyChart";
const HeavyChart = lazy(() => import("./HeavyChart"));
const DataTable = lazy(() => import("./DataTable"));
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart data={chartData} />
</Suspense>
<Suspense fallback={<Skeleton rows={10} />}>
<DataTable />
</Suspense>
</div>
);
}
// Named export lazy loading (React.lazy requires default export)
const MyComponent = lazy(() =>
import("./MyComponent").then(module => ({
default: module.NamedExport
}))
);
When Lazy Loading Hurts
// DON'T lazy load above-the-fold content
// BAD — user sees a loading spinner for the hero section
const HeroSection = lazy(() => import("./HeroSection"));
// GOOD — eagerly load what users see first
import HeroSection from "./HeroSection";
// Lazy load what's below the fold
const Comments = lazy(() => import("./Comments"));
const RelatedPosts = lazy(() => import("./RelatedPosts"));
// Preload hints — tell the browser to start loading early
// <link rel="preload" href="/critical-image.jpg" as="image">
// <link rel="prefetch" href="/next-page-bundle.js"> <!-- for likely next nav -->
// Programmatic preload on hover/focus
function preloadOnHover(linkElement, importFn) {
let preloaded = false;
linkElement.addEventListener("mouseenter", () => {
if (!preloaded) {
importFn(); // starts loading the chunk
preloaded = true;
}
});
}
// Usage: preload settings page when user hovers the nav link
preloadOnHover(
document.getElementById("settings-link"),
() => import("./pages/Settings")
);
Common Mistakes
- Lazy loading above-the-fold content (hero image, main headline, primary CTA) — it delays the Largest Contentful Paint and makes the page feel slower, not faster.
- Forgetting
widthandheighton lazy images — the browser doesn't reserve space, so Cumulative Layout Shift (CLS) skyrockets when images finally load. - Using
React.lazywith a named export directly —React.lazyrequires a default export. Wrap named exports with.then(m => ({ default: m.Named }))or switch to a default export.
Interview Questions
Q: What is lazy loading and what are its benefits?
Lazy loading defers the loading of resources until they're actually needed. Benefits: 1) Faster initial page load — less JavaScript/images to download upfront. 2) Reduced bandwidth — users who don't scroll down never download below-fold resources. 3) Lower memory usage — fewer DOM nodes and assets in memory. 4) Better Core Web Vitals — improved LCP and FID scores.
Q: How does IntersectionObserver-based lazy loading work?
You store the real image URL in a
data-srcattribute and use a placeholder as the initialsrc. An IntersectionObserver watches each image. When the observer detects the image entering (or approaching) the viewport, it copiesdata-srctosrc, triggering the browser to download the real image. After loading, youunobservethe image to stop tracking it.
Q: When should you NOT use lazy loading?
Don't lazy load above-the-fold content (hero images, primary heading, critical CSS) — it adds unnecessary delay to what users see first, hurting LCP. Don't lazy load small resources where the overhead of the observer + dynamic loading exceeds the resource size. Don't lazy load critical functionality that users need immediately.
Q: What is import() and how does it enable code splitting?
import()is the dynamic import expression. Unlike the staticimport ... from ...at the top of a file,import("./Page")is a function call that returns a Promise resolving to the module's exports. Bundlers (Webpack, Vite, Rollup) detect eachimport()call and emit a separate chunk for its target, so the browser only downloads that chunk when the Promise is created — enabling route-level or interaction-level code splitting.
Q: Name three things you can lazy load in a typical web app.
- Images and iframes (via
loading="lazy"or IntersectionObserver +data-src). 2) Route bundles (via dynamicimport()in your router). 3) Heavy components (viaReact.lazy+Suspense, or equivalent patterns in Vue/Svelte). Bonus: fonts (font-display: swap), videos (nativepreload="none"), and third-party widgets (iframe facades).
Quick Reference — Cheat Sheet
LAZY LOADING — WHAT, HOW, WHEN NOT TO
NATIVE (HTML)
<img loading="lazy" width="W" height="H">
<iframe loading="lazy">
values: lazy | eager | auto
INTERSECTION OBSERVER (IMAGES)
1. <img data-src="real.jpg" src="placeholder">
2. new IntersectionObserver(cb, { rootMargin: "200px 0px" })
3. On intersect -> copy data-src to src, then unobserve
CODE SPLITTING (DYNAMIC IMPORT)
const mod = await import("./Page") // one chunk per call
React.lazy(() => import("./Comp")) // + <Suspense fallback>
PRELOAD / PREFETCH
<link rel="preload" href="hero.jpg" as="image"> // needed now
<link rel="prefetch" href="next.js"> // next nav
hover listener -> importFn() // warm on intent
DO NOT LAZY LOAD
- Above-the-fold hero image (hurts LCP)
- Critical inline scripts
- Tiny assets where overhead > payload
Previous: Memory Leaks -> The Silent Killer Next: Web Workers -> Parallel JavaScript Without Freezing the UI
This is Lesson 13.2 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.