Lazy load WebP images correctly. Native attribute, IntersectionObserver, priority hints, and the rules that decide which images should never lazy load.
Deferring Image Loads Without Hurting Performance
Lazy loading is the single highest-leverage performance change on most image-heavy pages — and the single easiest to apply incorrectly. Done well, it eliminates wasted bandwidth and unblocks the main thread. Done badly, it regresses Largest Contentful Paint and slows the pages users actually look at. This guide gives you the rules for both.
For where lazy loading sits in the broader performance picture, see WebP Optimisation. For the metric impact, see Core Web Vitals & Images.
What lazy loading actually does
When a browser parses HTML and encounters an <img> tag, the default behaviour is to start downloading the image immediately. For images far below the fold, that means the browser is fetching content the user may never scroll to — burning bandwidth, blocking the connection pool, and competing with the critical request that does matter for the visible viewport.
Lazy loading inverts the default. The image's network request is deferred until the browser predicts the user is about to see it.
The browser implements this through two related signals:
- Viewport intersection. The browser monitors each lazy image and starts the fetch when the image enters (or comes near) the viewport.
- Decode timing. With
decoding="async", the actual pixel decode happens off the main thread, so a slow decode does not block other rendering work.
The native attribute (use this first)
The right way to lazy load is the HTML attribute:
<img
src="/img/photo.webp"
alt="Descriptive alt text"
width="1200"
height="675"
loading="lazy"
decoding="async"
/>
loading="lazy" is supported in Chrome, Firefox, Safari, Edge, and every browser still under vendor support. No JavaScript, no IntersectionObserver wiring, no library dependency. The browser handles all the intersection math, the scrolling prediction, and the request prioritisation.
decoding="async" complements it by allowing the decode to happen off the main thread.
These two attributes are the right answer for almost every image on a page. Reach for JavaScript only when the attribute is insufficient — and that case is rarer than tutorials suggest.
The rule that breaks most implementations
Never lazy-load the LCP image.
The Largest Contentful Paint element is usually the hero image, the first product photo, or the article header. By definition, it is in the initial viewport when the page first renders.
Adding loading="lazy" to an in-viewport image causes the browser to:
- Parse the HTML.
- See
loading="lazy". - Skip the image request during the initial fetch pass.
- Run layout and intersection observation.
- Discover the image is, in fact, in the viewport.
- Request the image.
That detour costs anywhere from 200ms to 1.5s of LCP, depending on network conditions. It is the single most common cause of lazy-loading regression. The fix is simple: in-viewport images get eager loading and a priority hint:
<img
src="/img/hero.webp"
alt="Descriptive alt text"
width="1600"
height="900"
fetchpriority="high"
decoding="async"
/>
Note the omission: no loading="lazy". The default loading="eager" is what you want for above-the-fold content.
fetchpriority="high" tells the browser this is the image that matters. It moves up in the request queue, ahead of stylesheets, fonts, and other images. Use it on exactly one image per page — the LCP candidate.
Preloading the LCP image
For maximum LCP optimisation, also preload the LCP image from <head>:
<link
rel="preload"
as="image"
href="/img/hero-1200.webp"
imagesrcset="/img/hero-800.webp 800w, /img/hero-1200.webp 1200w, /img/hero-1800.webp 1800w"
imagesizes="100vw"
type="image/webp"
fetchpriority="high"
/>
The imagesrcset and imagesizes attributes let the preload participate in responsive image selection — the browser picks the right resolution rather than always downloading the largest.
Preload exactly one image per page. Preloading multiple images puts them in contention and slows the one that matters.
What about IntersectionObserver?
Before loading="lazy" was widely supported, IntersectionObserver was the standard pattern. It is now mostly historical. The cases where it is still useful:
- Background images —
loading="lazy"only works on<img>and<iframe>. CSS background-image must be observed manually. - Custom thresholds —
loading="lazy"uses the browser's heuristic distance from viewport (typically 1.5× viewport height). If you need to control that exactly, IntersectionObserver gives you arootMargin. - Progressive enhancement to placeholder — fading from a blur to the full image, or running a custom shader on visibility.
A minimal IntersectionObserver lazy loader for CSS backgrounds:
const lazyBackgrounds = document.querySelectorAll("[data-bg]");
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
el.style.backgroundImage = `url(${el.dataset.bg})`;
observer.unobserve(el);
}
}
},
{ rootMargin: "200px 0px" }
);
for (const el of lazyBackgrounds) observer.observe(el);
rootMargin: "200px 0px" starts the fetch 200 pixels before the element enters the viewport — a good default for typical scroll speeds.
For ordinary <img> lazy loading, the native attribute is always preferable. IntersectionObserver is a tool for the cases the attribute does not cover.
Browser heuristic differences
The HTML spec leaves the exact lazy-loading trigger distance to the browser. Different engines use different heuristics:
- Chrome and Edge trigger fetches when an image is within roughly 1.5× viewport height. The threshold adjusts based on connection speed — slower connections trigger earlier.
- Firefox uses a similar threshold with slightly different tuning.
- Safari triggers slightly closer to the viewport, typically around 1× viewport height.
In practice this means lazy images on slow connections appear with less perceptible delay than on fast connections — the browser pre-fetches more aggressively when it has reason to expect slow image loads. You do not need to configure this; the browser handles it.
These heuristics also explain why a manually tuned IntersectionObserver implementation often produces worse perceived performance than the native attribute. The browser has more signals than your script does.
Lazy loading with <picture>
loading="lazy" goes on the <img> inside <picture>, not on the <source> elements:
<picture>
<source
type="image/webp"
srcset="
/img/photo-800.webp 800w,
/img/photo-1200.webp 1200w
"
sizes="(max-width: 768px) 100vw, 1200px"
/>
<img
src="/img/photo-1200.jpg"
srcset="/img/photo-800.jpg 800w, /img/photo-1200.jpg 1200w"
sizes="(max-width: 768px) 100vw, 1200px"
alt="Descriptive alt"
width="1200"
height="675"
loading="lazy"
decoding="async"
/>
</picture>
The lazy behaviour applies to whichever <source> the browser ultimately selects. See WebP Browser Support for more on the <picture> pattern.
Framework integration
Most modern frameworks now provide image components that handle lazy loading and priority hints correctly:
Next.js
import Image from "next/image";
export default function Hero() {
return (
<Image
src="/img/hero.webp"
alt="Descriptive alt"
width={1600}
height={900}
priority
/>
);
}
priority on the LCP image opts out of lazy loading and adds fetchpriority="high". Omit it for below-the-fold images and Next.js applies lazy loading automatically.
Astro
---
import { Image } from "astro:assets";
---
<Image src={hero} alt="..." loading="eager" fetchpriority="high" />
Plain HTML
Both attributes are universally supported. No framework required.
Common pitfalls
Lazy loading every image, including the hero
The most common regression. Tutorials say "always add loading='lazy'" and people apply it sitewide. The hero image lazy-loads, LCP jumps from 1.8s to 3.2s, and the team blames WebP for the regression. The fix is to identify the LCP candidate per template and exempt it.
No explicit dimensions
Without width and height (or aspect-ratio in CSS), every image causes a layout shift when it loads. Lazy loading does not change this — it actually makes it worse, because the shifts happen during scroll rather than during initial paint. Always include intrinsic dimensions.
Lazy loading offscreen images that will never be seen
If your page has 100 images and the median user only scrolls past 5 of them, lazy loading is doing exactly the right work. If your page has 100 images and the user views all 100 (a gallery, a one-page product catalogue), lazy loading just delays the inevitable. Consider whether infinite scroll or pagination is a better fit for the content shape.
Combining loading="lazy" with fetchpriority="high"
These attributes contradict each other. fetchpriority="high" says "request this immediately, ahead of other things"; loading="lazy" says "do not request this until needed". The browser will obey loading="lazy" and ignore the priority hint, but the markup makes the intent unclear. Pick one.
Using a JS library when the attribute works
A surprising number of sites still ship 5–15 KB of "lazyload.js" to do something the browser does natively in zero bytes. Audit your bundle: if you find a lazy-loading library, check whether you can drop it.
Lazy loading without decoding="async"
Lazy loading defers the network request; decoding="async" defers the decode. Without the latter, when a lazy image finally enters the viewport and the request completes, the decode happens on the main thread and can stutter scrolling. Use both attributes together.
Measuring impact
Before and after any lazy-loading change, measure:
- Largest Contentful Paint (LCP) — should be < 2.5s at the 75th percentile.
- Total bytes transferred during initial load — should drop noticeably after lazy loading is applied correctly.
- Time to Interactive — should improve as fewer competing requests block the main thread.
The full measurement framework is in Core Web Vitals & Images.
Where to go from here
- WebP Optimisation — broader performance walkthrough
- Core Web Vitals & Images — what these changes do to your metrics
- WebP Browser Support — the
<picture>fallback pattern - WebP Compression Settings — the bytes that lazy loading defers
- Convert content: PNG to WebP, JPG to WebP
Lazy loading is a default win for any image-heavy page that gets it right. Get the LCP image right first, then lazy-load everything else.