Lazy Loading in JavaScript: Complete Tutorial
Master lazy loading in JavaScript. Learn code splitting, dynamic imports, IntersectionObserver patterns, and strategies to defer JavaScript, CSS, and assets until they are needed.
Lazy loading is a strategy that shifts resource loading from "now" to "when needed." Instead of downloading every image, script, and data payload when the page first loads, you defer non-critical resources and load them on demand.
The strategy applies to every resource type: JavaScript modules, images, iframes, CSS, JSON data, and even entire page sections. The goal is always the same: ship less code up front so the user sees meaningful content faster.
Why Lazy Loading Matters
Before lazy loading:
The user waits for everything. A 2MB JavaScript bundle and 50 images all must download before the page responds. On slow connections, this can mean several seconds of staring at a blank screen.
After lazy loading:
The page becomes interactive after only the critical resources load. Everything else waits until the user signals interest through scrolling, clicking, or typing.
Lazy Loading JavaScript: Dynamic import()
The import() function loads a JavaScript module on demand. Unlike static import statements at the top of a file, import() returns a promise and can be called anywhere:
// Static import: downloaded immediately with the bundle
import { renderChart } from "./Chart.js";
// Dynamic import: downloaded only when called
document.querySelector("#load-chart").addEventListener("click", async () => {
const { renderChart } = await import("./Chart.js");
renderChart("#container", data);
});The browser downloads Chart.js only when the user clicks the button. Before the click, that code does not exist in the bundle.
Route-Level Code Splitting
In a single-page application, each route can load its own code:
// Simple SPA router with lazy loading
const routes = {
"/": () => import("./pages/Home.js"),
"/about": () => import("./pages/About.js"),
"/dashboard": () => import("./pages/Dashboard.js"),
"/settings": () => import("./pages/Settings.js")
};
async function navigate(path) {
const loader = routes[path];
if (!loader) {
console.error(`No route for ${path}`);
return;
}
showLoadingSpinner();
const page = await loader();
hideLoadingSpinner();
page.render(document.querySelector("#app"));
}Visiting the home page downloads only Home.js. Navigating to the dashboard downloads Dashboard.js on demand. The user never pays for code they do not use.
This is what bundlers like webpack, Vite, and Rollup implement when they see import(). They split each dynamic import into a separate chunk. The runtime loads chunks over the network when import() is called. See the code splitting guide for the full bundler picture.
Lazy Loading with IntersectionObserver
The IntersectionObserver API tells you when an element enters or leaves the viewport. Combine it with lazy loading to load content as the user scrolls:
function observeAndLoad(selector, loadFn) {
const elements = document.querySelectorAll(selector);
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadFn(entry.target);
observer.unobserve(entry.target);
}
});
}, {
rootMargin: "300px" // Start loading before the element is visible
});
elements.forEach(el => observer.observe(el));
}Use it to lazy load data when a section scrolls into view:
observeAndLoad("[data-load-comments]", async (element) => {
const postId = element.dataset.postId;
const comments = await fetch(`/api/posts/${postId}/comments`).then(r => r.json());
renderComments(element, comments);
});Or load heavy third-party widgets only when needed:
observeAndLoad("#map-container", async (element) => {
const { initMap } = await import("./MapWidget.js");
initMap(element);
});For image-specific lazy loading patterns, see how to lazy load images and components.
Prefetching: The Best of Both Worlds
Lazy loading saves bandwidth but adds latency. The user clicks a button and waits for the chunk to download. Prefetching bridges the gap: download in the background before the user needs it:
// Prefetch when the user hovers over a link
document.querySelectorAll("a[data-prefetch]").forEach(link => {
link.addEventListener("mouseenter", () => {
const path = link.dataset.prefetch;
// Dynamically create a prefetch link tag
const prefetch = document.createElement("link");
prefetch.rel = "prefetch";
prefetch.href = path;
document.head.appendChild(prefetch);
});
});
// Or preload a component likely to be needed soon
const link = document.createElement("link");
link.rel = "modulepreload";
link.href = "/assets/Dashboard-abc123.js";
document.head.appendChild(link);The browser downloads the resource in the background at low priority. When the user navigates, the resource is already in the cache. Lazy loading with preloading gives you both fast initial loads and fast subsequent interactions.
Lazy Loading CSS
Non-critical CSS can be deferred. Inline critical styles in the <head> and load the rest asynchronously:
<!-- Critical CSS: inlined in the head -->
<style>
/* Header, hero, and above-the-fold styles only */
body { font-family: system-ui; margin: 0; }
.hero { padding: 2rem; text-align: center; }
</style>
<!-- Non-critical CSS: loaded asynchronously -->
<link
rel="preload"
href="/styles/full.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>The preload + onload pattern loads CSS without blocking rendering. The page paints with critical styles first, then upgrades when the full stylesheet arrives.
Lazy Loading Data: Infinite Scroll
Combine IntersectionObserver with data fetching for infinite scroll:
function createInfiniteScroll(container, loadMore) {
const sentinel = document.createElement("div");
sentinel.className = "scroll-sentinel";
container.appendChild(sentinel);
let page = 1;
let loading = false;
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
const items = await loadMore(page);
page += 1;
if (items.length === 0) {
observer.unobserve(sentinel);
sentinel.remove();
return;
}
renderItems(container, items);
loading = false;
});
observer.observe(sentinel);
}
// Usage
createInfiniteScroll(
document.querySelector("#feed"),
(page) => fetch(`/api/posts?page=${page}`).then(r => r.json())
);A sentinel element at the bottom of the list triggers loading when it becomes visible. The loading flag prevents duplicate requests if the observer fires rapidly.
What NOT to Lazy Load
| Resource | Reason |
|---|---|
| Hero image (LCP) | Delays the main content the user sees first |
| Critical CSS | The page looks broken without it |
| Core JavaScript bundle | The app cannot become interactive |
| Above-the-fold fonts | Text renders invisible while fonts load |
| Primary navigation | The user cannot navigate without it |
The rule: if it is visible or needed in the first second, load it eagerly. If it is below the fold, behind a click, or conditionally used, lazy load it.
Common Mistakes
Lazy loading the LCP image. This is the most common performance anti-pattern. The Largest Contentful Paint image must load immediately. Mark it with fetchpriority="high" and never set loading="lazy" on it.
No loading indicator. If a lazy-loaded component takes 500ms to download after a click, show a skeleton or spinner. Otherwise the user thinks the app is broken.
Lazy loading everything. Going too far creates a "death by a thousand cuts" experience where every interaction triggers a small loading delay. Lazy load only what is genuinely large or conditionally used.
Rune AI
Key Insights
- Lazy loading defers resource loading until the resource is needed.
- Dynamic import() is the JavaScript mechanism for lazy loading code modules.
- IntersectionObserver fires callbacks when elements enter or leave the viewport.
- Code splitting + lazy loading is how modern SPAs keep initial bundles small.
- Combine lazy loading with prefetching for the best of both worlds.
Frequently Asked Questions
What is the difference between lazy loading and eager loading?
Does lazy loading hurt SEO?
Can I lazy load CSS?
Conclusion
Lazy loading shifts resource downloads from 'immediately' to 'when needed.' Use it across every resource type: images with loading="lazy", JavaScript with dynamic import(), data with fetch-on-scroll, and CSS with media queries. The result is a faster, lighter initial page load that scales gracefully as users interact with your app.Lazy loading is a mindset: ship only what the user needs right now. Use dynamic import() for JavaScript code splitting. Use IntersectionObserver for scroll-triggered loading. Use prefetching to eliminate the latency of lazy loading. Apply the strategy to every heavy resource on your page, but never to the critical content the user sees first.
The best lazy loading is invisible. The user should never notice that something was deferred. They should only notice that the page loaded fast.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.