How to Lazy Load Images the Right Way
A typical web page loads dozens of images, yet most of them sit well below the fold where no visitor will see them until they scroll. Without lazy loading, the browser fetches every single image upfront, competing for bandwidth and delaying the content that actually matters. The result is slower initial page loads, wasted data, and worse Core Web Vitals scores.
Lazy loading solves this by deferring offscreen images until the user scrolls near them. Done correctly, it can cut initial page weight by 40-60% on image-heavy pages. Done incorrectly, it can hurt your Largest Contentful Paint (LCP), break SEO, or create a jarring user experience. This guide covers how to implement lazy loading the right way.
Native Browser Lazy Loading with loading=”lazy”
The simplest and most widely recommended approach is the native loading attribute, supported in all modern browsers (Chrome, Edge, Firefox, Safari, and Opera).
<img src="product-photo.jpg" loading="lazy" alt="Product photo" width="800" height="600">
That single attribute is all it takes. The browser handles the rest: it monitors the image’s position relative to the viewport and only initiates the network request when the image approaches the visible area. There is no JavaScript to write, no library to load, and no configuration to manage.
A few important details about the native implementation:
- Always include
widthandheightattributes. Without explicit dimensions, the browser cannot reserve space for the image before it loads. This causes layout shifts as images pop in, which directly hurts your Cumulative Layout Shift (CLS) score. - The browser determines its own threshold. You cannot control exactly how far in advance the browser begins fetching. In practice, Chrome starts loading images roughly 1250px below the viewport on fast connections and closer on slow ones.
- It works on
<iframe>elements too. If you embed videos or third-party widgets,loading="lazy"prevents them from loading until needed.
For most websites, native lazy loading is the correct default choice. Layer on JavaScript solutions only when you need fine-grained control.
Intersection Observer API for Advanced Control
When native lazy loading does not offer enough control — for instance, when you need custom loading thresholds, animation triggers, or support for background images — the Intersection Observer API is the standard JavaScript approach.
The pattern works by initially setting images with a placeholder src and storing the real URL in a data-src attribute. When the observer detects that an image has entered (or is about to enter) the viewport, you swap the source.
<img data-src="product-photo.jpg" src="placeholder.svg" alt="Product photo" class="lazy" width="800" height="600">
document.addEventListener('DOMContentLoaded', function () {
const lazyImages = document.querySelectorAll('img.lazy');
const observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px'
});
lazyImages.forEach(function (img) {
observer.observe(img);
});
});
The rootMargin option is critical. Setting it to 200px 0px tells the observer to trigger 200 pixels before the image enters the viewport, giving the browser a head start on the network request. Without a margin, users on slower connections may see a flash of empty space before the image appears.
Intersection Observer is well supported across browsers and is far more performant than older approaches that relied on scroll event listeners and getBoundingClientRect() calculations. Those legacy techniques fire on every scroll event and can cause jank on complex pages. Avoid them entirely.
What NOT to Lazy Load
Not every image on your page should be lazy loaded. Getting this wrong is one of the most common mistakes developers make, and it directly damages your LCP score.
Never lazy load above-the-fold images. Any image that is visible in the initial viewport without scrolling should load immediately. This includes hero images, header logos, and any banner or featured image at the top of the page. These are frequently your LCP element, and deferring them adds unnecessary delay to the metric Google uses to measure perceived load speed.
For your LCP image, go further and add a fetchpriority="high" hint:
<img src="hero-banner.jpg" alt="Hero banner" width="1200" height="600" fetchpriority="high">
This tells the browser to prioritize this image in the network queue. Combined with not applying loading="lazy", it ensures your most important visual content loads as fast as possible.
Do not lazy load images that are critical to the initial user experience. Product thumbnails in the first visible row of a grid, author avatars in a byline, or a primary call-to-action image should all load eagerly.
A reliable rule of thumb: if the image appears in the first viewport on both desktop and mobile, it should not be lazy loaded.
Placeholder Strategies
When a lazy-loaded image has not yet loaded, you need something in its place. An empty space or a broken layout signals poor quality to users. Several placeholder strategies address this.
Aspect-ratio boxes are the minimum requirement. By setting width and height on the <img> tag (or using CSS aspect-ratio), the browser reserves the correct amount of space. This prevents layout shifts even before the image loads.
.lazy-container {
aspect-ratio: 16 / 9;
background-color: #f0f0f0;
}
Solid color placeholders fill the reserved space with a dominant color extracted from the image. This is visually lightweight and gives users a hint of what is coming. Many image CDNs and optimization services can extract a dominant color automatically.
Blur-up (LQIP) placeholders load an extremely small version of the image (often 20-40 bytes as an inline data URI), display it at full size with a CSS blur filter, and then transition to the full image once it loads. This technique provides a preview of the content and creates a smooth visual transition. The tradeoff is added complexity in your build pipeline to generate the low-quality versions.
For most projects, aspect-ratio boxes with a neutral background color offer the best balance of simplicity and user experience. Reserve blur-up for hero sections or portfolios where visual continuity matters.
CMS Integration: WordPress Native Lazy Loading
WordPress has included native lazy loading by default since version 5.5. Every <img> tag output by wp_get_attachment_image() or the_content() automatically receives loading="lazy" — with one important exception. Starting in WordPress 5.9, the first image in post content is excluded from lazy loading to protect LCP performance.
If you use a WordPress image optimization plugin alongside lazy loading, make sure the two are not conflicting. Some optimization plugins add their own lazy loading implementation via JavaScript, which can interfere with native browser behavior. In most cases, the native loading="lazy" attribute is sufficient and the JavaScript-based approach should be disabled.
For theme developers, you can control lazy loading behavior with the wp_img_tag_add_loading_attr filter or by passing 'loading' => false to wp_get_attachment_image() for specific images you want to load eagerly.
Performance Impact and Metrics
The measurable impact of lazy loading shows up in several key metrics:
- Time to Interactive (TTI): Fewer upfront requests mean the main thread is available sooner for user interactions.
- Total Transfer Size: On a page with 30 images where only 5 are above the fold, lazy loading can reduce initial transfer by 80% or more.
- Largest Contentful Paint (LCP): Properly implemented lazy loading (with above-the-fold images excluded) reduces resource contention, which can improve LCP. Improperly applied lazy loading on the LCP image will make it worse.
- Cumulative Layout Shift (CLS): Without proper dimension attributes or placeholders, lazy-loaded images cause layout shifts as they pop in. With correct sizing, CLS stays at zero.
To validate your implementation, run Lighthouse or PageSpeed Insights and check the “Defer offscreen images” audit. If it still flags images, they are loading too early. If your LCP score regresses after adding lazy loading, check whether you accidentally deferred your above-the-fold content.
Common Mistakes to Avoid
Lazy loading all images indiscriminately. This is the most frequent error. Always audit which images appear above the fold and exclude them.
Missing width and height attributes. Without dimensions, every lazy-loaded image causes a layout shift when it finally renders. This is especially harmful on mobile where the viewport is narrow and shifts are more noticeable.
Using JavaScript lazy loading when native is sufficient. Adding a JavaScript library for basic lazy loading introduces unnecessary overhead: additional HTTP requests, parse time, and potential points of failure. Use loading="lazy" first, and add JavaScript only when you need features the native attribute cannot provide.
No placeholder or reserved space. If you use JavaScript-based lazy loading with data-src swapping, make sure the initial src is a lightweight placeholder, not an empty string. An empty src can trigger unexpected browser behavior, including duplicate requests to the page URL.
Ignoring no-JavaScript users. If you rely on JavaScript for lazy loading, wrap your real image URL in a <noscript> block so the image is still accessible when JavaScript is disabled. Native loading="lazy" does not have this problem since the real src is always present.
Summary
Lazy loading is one of the highest-impact, lowest-effort optimizations you can apply to an image-heavy website. For most projects, the native loading="lazy" attribute is the right starting point. Pair it with proper image dimensions, exclude above-the-fold content, and use the Intersection Observer API only when you need advanced control.
Combined with proper image optimization and attention to Core Web Vitals, lazy loading ensures your pages load fast without sacrificing visual quality.