How to Lazy Load Images and Components in JS

Learn how to lazy load images and components in JavaScript to speed up page loads. Use native loading, IntersectionObserver, and dynamic imports to defer non-critical resources.

7 min read

Lazy loading defers the loading of non-critical resources until they are needed. Images below the fold, components behind a tab, and videos the user has not scrolled to yet all wait until they are about to enter the viewport.

The result: faster initial page loads, less bandwidth wasted, and better Core Web Vitals scores. The user downloads only what they need to see right now.

Native Image Lazy Loading

The simplest approach requires zero JavaScript. Add the loading attribute to your image tags:

htmlhtml
<!-- Eager: loads immediately (good for hero images) -->
<img src="hero.jpg" loading="eager" alt="Hero banner" />
 
<!-- Lazy: loads when approaching the viewport -->
<img src="photo.jpg" loading="lazy" alt="A scenic view" />

The browser handles everything. It calculates when the image is about to enter the viewport, fetches it, and renders it. You write one attribute.

For iframes, the same attribute works:

htmlhtml
<iframe src="https://example.com/embed" loading="lazy"></iframe>

Rule: never set loading="lazy" on images in the initial viewport. Those images are part of the Largest Contentful Paint (LCP). Lazy loading them delays when the user sees the main content. Use loading="eager" (the default) or omit the attribute for above-the-fold images.

IntersectionObserver for Custom Lazy Loading

When you need more control, such as fading images in, showing a placeholder first, or tracking when an element becomes visible, use the IntersectionObserver API:

javascriptjavascript
function lazyLoadImages() {
  const images = document.querySelectorAll("img[data-src]");
 
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
 
      const img = entry.target;
      img.src = img.dataset.src;
 
      // Optional: also load a higher-resolution version
      if (img.dataset.srcset) {
        img.srcset = img.dataset.srcset;
      }
 
      img.addEventListener("load", () => {
        img.classList.add("loaded");
      });
 
      // Stop observing this image once loaded
      observer.unobserve(img);
    });
  }, {
    rootMargin: "200px" // Start loading 200px before the image enters view
  });
 
  images.forEach(img => observer.observe(img));
}
 
// Kick off when the page is ready
document.addEventListener("DOMContentLoaded", lazyLoadImages);

Your HTML uses data-src instead of src:

htmlhtml
<img
  data-src="actual-image.jpg"
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E"
  alt="Lazy loaded photo"
  class="lazy-img"
/>

The inline SVG is a transparent placeholder that prevents the browser from showing a broken image icon before the real image loads.

IntersectionObserver lazy load flow

The rootMargin: "200px" tells the observer to fire when the image is 200px away from the viewport, not when it is already visible. This gives the browser time to fetch the image before the user sees the placeholder.

Lazy Loading Components with Dynamic Import

For JavaScript components, lazy loading means splitting your code so parts of it only download when needed. Use dynamic import():

javascriptjavascript
// Instead of: import { HeavyChart } from './HeavyChart.js';
 
// Lazy load the chart component only when it is needed
document.querySelector("#show-chart-btn").addEventListener("click", async () => {
  const { HeavyChart } = await import("./HeavyChart.js");
  const chart = new HeavyChart("#chart-container");
  chart.render(data);
});

The HeavyChart.js file is not downloaded until the user clicks the button. This reduces the initial JavaScript bundle size.

Combine dynamic imports with IntersectionObserver to load components when they scroll into view:

javascriptjavascript
function lazyLoadComponent(selector, importFn) {
  const element = document.querySelector(selector);
  if (!element) return;
 
  const observer = new IntersectionObserver(async (entries) => {
    if (!entries[0].isIntersecting) return;
 
    const module = await importFn();
    module.mount(element);
 
    observer.unobserve(element);
  });
 
  observer.observe(element);
}
 
// Usage: load the reviews section only when scrolled to
lazyLoadComponent("#reviews-section", () => import("./Reviews.js"));

This pattern is the foundation of route-level code splitting. See the code splitting guide for more on splitting JavaScript bundles.

Lazy Loading Videos

Videos are some of the heaviest assets on a page. Lazy load them by replacing the src with a poster image and loading the real video on interaction:

htmlhtml
<video
  controls
  poster="video-poster.jpg"
  data-src="heavy-video.mp4"
  class="lazy-video"
  width="640"
  height="360"
>
  Your browser does not support video.
</video>
javascriptjavascript
document.querySelectorAll(".lazy-video").forEach(video => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      video.querySelector("source")?.setAttribute("src", video.dataset.src);
      video.load();
      observer.unobserve(video);
    });
  });
 
  observer.observe(video);
});

For autoplaying background videos, consider replacing them entirely with a static poster on mobile where autoplay is unreliable.

Handling Layout Shift

When an image loads, it pushes content down if the browser does not know its dimensions in advance. This is called Cumulative Layout Shift (CLS). Always set width and height on images:

htmlhtml
<!-- Good: browser reserves space before image loads -->
<img src="photo.jpg" loading="lazy" width="800" height="600" alt="..." />
 
<!-- With CSS for responsive images -->
<style>
  img {
    max-width: 100%;
    height: auto;
  }
</style>

Modern browsers use the width and height attributes to calculate the aspect ratio and reserve the correct space, even with max-width: 100% in CSS.

Performance Checklist

DoDo not
Lazy load images below the foldLazy load the LCP (hero) image
Set width and height on every imageLet images push content around
Use rootMargin to load earlyWait until the image is fully visible
Add a loading spinner or skeletonShow nothing while loading
Test on slow 3G connectionsOnly test on fast WiFi

When to Use Each Approach

ApproachBest forBrowser support
loading="lazy"Simple image and iframe deferralAll modern browsers
IntersectionObserverCustom loading effects, analytics, fine controlAll modern browsers
Dynamic import()JavaScript components, heavy librariesAll modern browsers
preload + loading="lazy"Images that are lazy but high priority when neededAll modern browsers

For broader strategies on deferring all types of resources, see lazy loading in JavaScript.

Rune AI

Rune AI

Key Insights

  • Native loading="lazy" is the simplest way to lazy load images and iframes.
  • IntersectionObserver gives you full control over when offscreen content loads.
  • Dynamic import() splits components into separate chunks that load on demand.
  • Never lazy load above-the-fold images -- it hurts LCP scores.
  • Always provide placeholder dimensions to prevent layout shift during loading.
RunePowered by Rune AI

Frequently Asked Questions

Does loading="lazy" work in all browsers?

It is supported in all modern browsers as of 2022. Chrome, Firefox, Edge, and Safari all support the native loading="lazy" attribute. For older browsers, use an IntersectionObserver polyfill as a fallback.

Should I lazy load all images?

Never lazy load the hero image or any image visible in the initial viewport (above the fold). Lazy loading those images delays the Largest Contentful Paint (LCP) and hurts performance scores.

How is lazy loading different from code splitting?

Lazy loading is about deferring the load of assets (images, iframes, videos) until they are needed. Code splitting is about splitting JavaScript bundles so users only download the code needed for the current page. Dynamic import() is the mechanism that enables code splitting.

Conclusion

Lazy loading is one of the highest-impact performance optimizations you can make. Use loading="lazy" for images, IntersectionObserver for custom lazy loading and analytics, and dynamic import() for component-level code splitting. Start with what the user sees first. Load everything else later.Lazy loading is the simplest performance win available. Start with loading="lazy" on below-the-fold images. Add IntersectionObserver when you need custom behavior like fade-in effects or component lazy loading. Use dynamic import() to split JavaScript bundles. Always set explicit dimensions to prevent layout shift. The goal: show the user what they came for immediately. Load everything else when they are about to need it.