Lazy Loading
Lazy loading is a strategy that defers the loading of non-critical resources until they are actually needed. Instead of downloading every image, iframe, and JavaScript module when the page first loads, lazy loading waits until the user scrolls near the content or triggers an action that requires it. This reduces initial page weight, speeds up the first meaningful paint, and saves bandwidth for users who never scroll to the bottom of the page.
Native Lazy Loading for Images and Iframes
The simplest way to implement lazy loading is with the native loading attribute, which is supported by all modern browsers. Just add loading="lazy" to your <img> or <iframe> elements:
<img
src="photo.jpg"
alt="A scenic mountain landscape"
width="800"
height="600"
loading="lazy">
<iframe
src="https://www.youtube.com/embed/VIDEO_ID_HERE"
width="560"
height="315"
loading="lazy"
title="Video player">
</iframe>
The loading attribute accepts three values:
lazy— defer loading until the element is near the viewport. The browser determines the exact threshold (typically a few hundred pixels before the element scrolls into view).eager— load immediately, regardless of position. This is the default behavior.auto— let the browser decide. In practice, this usually behaves the same aseager.
Native lazy loading has significant advantages over JavaScript-based approaches:
- Zero JavaScript required — it works with a single HTML attribute
- No layout shifts — when combined with
widthandheightattributes, the browser reserves space before the image loads - Browser-optimized — the browser can make intelligent decisions about when to start loading based on network conditions and device capabilities
- Works with JavaScript disabled — the images still load (just not lazily)
width and height attributes on lazy-loaded images. Without them, the browser cannot reserve space, leading to layout shifts (CLS problems) when images pop in.
When NOT to Lazy Load
Lazy loading is not appropriate for every image. Deferring the wrong resources can actually hurt performance rather than help it.
Above-the-Fold Content
Images that are visible when the page first loads (in the initial viewport, or "above the fold") should never be lazy loaded. The browser needs to display these immediately, and lazy loading adds unnecessary delay by waiting for the Intersection Observer to confirm the element is visible.
This is especially critical for the LCP image — the largest visible element that defines your Largest Contentful Paint metric. Lazy loading the LCP image forces the browser to:
- Parse the HTML and build the DOM
- Determine that the image is in the viewport
- Only then start downloading the image
Without lazy loading, the browser can begin downloading the image as soon as it encounters the <img> tag during HTML parsing, which is significantly earlier.
<!-- Hero image: do NOT lazy load, use fetchpriority instead -->
<img
src="hero.jpg"
alt="Hero banner"
width="1600"
height="900"
fetchpriority="high">
<!-- Below-the-fold image: lazy load -->
<img
src="gallery-photo.jpg"
alt="Gallery photo"
width="800"
height="600"
loading="lazy">
loading="lazy" to your LCP image is one of the most common performance mistakes. It can add hundreds of milliseconds to your LCP score. Use fetchpriority="high" on the LCP image instead to signal that it should be downloaded with the highest priority.
Images Already in the Viewport on Load
Beyond the hero image, any image that is visible without scrolling should use loading="eager" (or simply omit the loading attribute, since eager is the default). This includes navigation logos, author avatars next to article titles, and thumbnail images at the top of a gallery page.
Critical Iframes
If an iframe contains content essential to the page's primary purpose (like an embedded form or payment widget), lazy loading it can create a poor user experience. Users might see a blank space where the widget should be.
Intersection Observer API
For cases where native lazy loading isn't sufficient — such as lazy loading background images, custom components, or triggering animations on scroll — the Intersection Observer API provides a performant, JavaScript-based alternative.
The Intersection Observer watches target elements and fires a callback when they enter or exit the viewport (or a specified ancestor element). Unlike scroll event listeners, Intersection Observer runs asynchronously and doesn't block the main thread.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px' // Start loading 200px before visible
});
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
The rootMargin option is particularly useful: it expands the observation area so images start loading before they scroll into view, avoiding the jarring experience of images appearing as the user scrolls.
Use Cases for Intersection Observer
- Lazy loading CSS background images — native
loading="lazy"only works on<img>and<iframe>, not background images set via CSS - Infinite scroll — load the next batch of content when the user approaches the bottom of the page
- Scroll-triggered animations — start animations when elements come into view
- Analytics — track which sections of the page users actually see
- Video autoplay — start playing videos when they become visible and pause when they leave
Lazy Loading JavaScript Modules
Lazy loading isn't limited to media. You can defer loading entire JavaScript modules until they're needed using dynamic import() statements. This is particularly powerful for single-page applications where different routes require different code.
// Load the module only when the user navigates to the settings page
async function loadSettings() {
const { SettingsModule } = await import('./settings.js');
SettingsModule.init();
}
// Load a heavy library only when the user clicks "Export"
document.getElementById('export-btn').addEventListener('click', async () => {
const { exportToPDF } = await import('./pdf-exporter.js');
exportToPDF(document.getElementById('report'));
});
Dynamic imports return a Promise that resolves to the module. Bundlers like Webpack and Vite automatically create separate chunks for dynamically imported modules, so the browser only downloads them when the import is triggered.
Common scenarios for lazy loading JavaScript:
- Route-based splitting — each page of a single-page application loads its own JavaScript bundle
- Feature-based splitting — heavy features like a rich text editor, chart library, or PDF generator load only when the user activates them
- Conditional loading — load a polyfill only if the browser doesn't support a feature natively
Skeleton Screens and Placeholder Content
When content is lazy loaded, users see something in its place. The quality of that placeholder matters for perceived performance. There are several approaches:
Skeleton Screens
Skeleton screens show a simplified, content-free version of the layout using gray blocks that mirror the structure of the real content. They communicate that content is loading and will appear in a predictable layout. Facebook, LinkedIn, and YouTube all use skeleton screens extensively.
Skeleton screens are better than spinners because they:
- Give users a sense of the page structure before content arrives
- Feel faster than a blank space or a generic loading indicator
- Reduce perceived loading time even when actual loading time is the same
- Prevent layout shifts when real content replaces the skeleton
Low-Quality Image Placeholders (LQIP)
For image-heavy pages, you can show a tiny, heavily blurred version of the image (often just 20-40 bytes as a base64-encoded data URI) that instantly conveys the image's colors and composition. When the full image loads, it fades in smoothly. This technique is sometimes called "blur-up."
Dominant Color Placeholders
An even simpler approach: extract the dominant color from each image at build time and use it as a solid background. This is less informative than LQIP but has zero performance cost since it's just a CSS background color.
Impact on Core Web Vitals
Lazy loading affects all three Core Web Vitals, sometimes positively and sometimes negatively:
- LCP — lazy loading below-the-fold images improves LCP by reducing contention for network bandwidth, allowing the LCP image to load faster. But lazy loading the LCP image itself hurts LCP significantly.
- CLS — lazy loading without proper placeholders causes layout shifts when images pop in. Always reserve space with
width/heightattributes or CSS aspect ratios. Skeleton screens also help prevent CLS. - INP — lazy loading JavaScript reduces the amount of code the browser needs to parse and compile upfront, freeing the main thread for user interactions. This directly improves INP by reducing input delay.
Lazy Loading Checklist
- Add
loading="lazy"to all below-the-fold images and iframes - Never lazy load the LCP image — use
fetchpriority="high"instead - Always include
widthandheighton lazy-loaded images - Use Intersection Observer for background images and custom lazy loading behavior
- Implement code splitting with dynamic
import()for heavy JavaScript features - Add skeleton screens or placeholders to prevent blank spaces during loading
- Test with throttled network conditions to ensure the lazy loading experience is smooth
Resources
- web.dev Lazy Loading Guide — comprehensive guide to native and JavaScript-based lazy loading
- MDN Intersection Observer API — complete API reference with examples
- Don't Lazy Load LCP Images — Google's guidance on avoiding lazy loading for critical images
- MDN Dynamic import() — reference for lazy loading JavaScript modules