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.

8 min read

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:

Eager loading: everything downloads before the page is interactive

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:

Lazy loading: critical resources first, rest on demand

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:

javascriptjavascript
// 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:

javascriptjavascript
// 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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
// 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:

htmlhtml
<!-- 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:

javascriptjavascript
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

ResourceReason
Hero image (LCP)Delays the main content the user sees first
Critical CSSThe page looks broken without it
Core JavaScript bundleThe app cannot become interactive
Above-the-fold fontsText renders invisible while fonts load
Primary navigationThe 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

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.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between lazy loading and eager loading?

Eager loading downloads everything immediately when the page loads. Lazy loading defers downloads until the resource is needed. Lazy loading improves initial load time but may cause a small delay when the resource is first requested.

Does lazy loading hurt SEO?

No, if implemented correctly. Googlebot scrolls pages and triggers lazy loading just like a user. Use IntersectionObserver or native loading="lazy" as Googlebot supports both. Avoid lazy loading content behind user interactions (clicks) that bots cannot trigger.

Can I lazy load CSS?

Yes, but carefully. You can use media queries to load non-critical CSS (print styles, dark mode styles). For critical CSS, always load it eagerly. Inlining critical CSS in the head and deferring the rest is a common pattern.

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.