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.
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:
<!-- 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:
<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:
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:
<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.
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():
// 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:
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:
<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>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:
<!-- 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
| Do | Do not |
|---|---|
| Lazy load images below the fold | Lazy load the LCP (hero) image |
Set width and height on every image | Let images push content around |
Use rootMargin to load early | Wait until the image is fully visible |
| Add a loading spinner or skeleton | Show nothing while loading |
| Test on slow 3G connections | Only test on fast WiFi |
When to Use Each Approach
| Approach | Best for | Browser support |
|---|---|---|
loading="lazy" | Simple image and iframe deferral | All modern browsers |
| IntersectionObserver | Custom loading effects, analytics, fine control | All modern browsers |
Dynamic import() | JavaScript components, heavy libraries | All modern browsers |
preload + loading="lazy" | Images that are lazy but high priority when needed | All modern browsers |
For broader strategies on deferring all types of resources, see lazy loading in JavaScript.
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.
Frequently Asked Questions
Does loading="lazy" work in all browsers?
Should I lazy load all images?
How is lazy loading different from 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.
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.