Lazy loading defers the loading of non-critical resources until they are needed, reducing initial page weight and improving Time to Interactive through native attributes, Intersection Observer, and dynamic imports.
The loading="lazy" attribute on img and iframe elements defers loading with zero JavaScript, supported in all modern browsers.
An efficient browser API for detecting when elements enter the viewport, replacing scroll listeners for custom lazy loading behavior.
The import() expression loads JavaScript modules on demand and enables bundlers to create separate chunks for code splitting.
React's built-in component-level lazy loading wraps dynamic imports with declarative fallback UI during loading.
Lazy loading is a design pattern that delays the initialization or fetching of resources until they are actually needed. Instead of loading everything upfront, you load only what is required for the initial view and defer the rest. This directly improves initial page load time, reduces bandwidth consumption, and lowers memory usage.
The simplest approach uses the loading attribute on <img> and <iframe> elements:
<img src="photo.webp" loading="lazy" alt="Description" />
<iframe src="video.html" loading="lazy"></iframe>The browser automatically defers loading until the element is near the viewport. This requires zero JavaScript, works in all modern browsers, and is the recommended approach for images and iframes below the fold. The browser uses heuristics to determine "near the viewport" — typically starting the fetch when the element is within a few viewport heights of the visible area.
Important: Never lazy-load above-the-fold content. Images visible on initial load should use loading="eager" (the default) and consider fetchpriority="high" for the Largest Contentful Paint (LCP) element.
For custom lazy loading behavior beyond images, the Intersection Observer API provides an efficient way to detect when elements enter the viewport:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadContent(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '200px' });This is more performant than scroll event listeners because the browser batches observations and runs them off the main thread. Use it for lazy-loading components, triggering animations on scroll, implementing infinite scroll, or tracking element visibility for analytics.
The import() expression loads JavaScript modules on demand, returning a promise:
button.addEventListener('click', async () => {
const { heavyFunction } = await import('./heavyModule.js');
heavyFunction();
});Bundlers like Webpack and Vite recognize import() and automatically create separate chunks for dynamically imported modules. This is the foundation of code splitting — routes, features, or large dependencies can be loaded only when the user needs them.
React provides built-in component-level lazy loading:
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
);
}React.lazy wraps a dynamic import and returns a component that renders the imported module's default export. Suspense provides a fallback UI while the component is loading. In Next.js, next/dynamic extends this with SSR control and custom loading states.
Lazy load content below the fold (images, videos, iframes), heavy third-party libraries (chart libraries, rich text editors, maps), routes the user has not visited yet, and features behind user interaction (modals, dropdowns, tooltips). Do not lazy load critical above-the-fold content, small modules where the loading overhead exceeds the savings, or content needed immediately after page load.
Native loading="lazy" is for images and iframes. Intersection Observer is for custom viewport-triggered behavior. import() is for JavaScript modules and code splitting. React.lazy is for component-level splitting with Suspense fallbacks. Each technique targets a different type of resource.
Fun Fact
The Intersection Observer API was designed by Google engineers who found that scroll-based lazy loading libraries were themselves causing performance problems. Thousands of websites were attaching expensive scroll event listeners to implement lazy loading, ironically degrading the performance they were trying to improve.