Made by Dhawal Made by Dhawal

The Lazy Lantern: why loading=”lazy” can make your page slower

Kai put loading="lazy" on all forty gallery images and his hero painting arrived late. Vayu, Domlok's Air guide, wakes the Lazy Lantern: keep the first image eager with fetchpriority="high", lazy-load the rest, and give every image its size.

Told by Vayu, with Kai asking the questions

The Lazy Lantern, a sleepy red paper lantern spirit with one drowsy eye, beside the title "The Lazy Lantern"

Vayu: You lit the whole road at once, Kai, and then you put out the one lantern every traveller sees first.

But I made the page lighter!

If you added loading="lazy" to every image and your page got lighter but your Largest Contentful Paint got slower, the lazy attribute is probably on your hero image. Keep the first big image eager, give it fetchpriority="high", lazy-load the images below the fold, and give every image a width and height.

In the world of Domlok this is the work of the Lazy Lantern, a chōchin-obake: a paper lantern spirit that dozes on the mountain road. It never breaks anything and never throws an error. It just falls asleep in exactly the wrong place, so the first thing a visitor should see arrives late.

Your guide for this episode is Vayu, the Air guide of Domlok, who looks after everything about speed: loading, rendering, bundle size and Core Web Vitals. Deciding which images load now and which can wait is pure performance work, so this bug is his to catch. Kai, as usual, is the one who let the lantern doze off, while trying to do the right thing.

Forty lanterns at once

Kai’s temple gallery has one big hero painting at the top and thirty-nine more in a grid below it. None of the images has a loading attribute:

gallery.html
<img src="hero.avif" alt="The mountain temple at dawn" />
<ul class="grid">
<li><img src="scroll-01.avif" alt="Koi pond" /></li>
<li><img src="scroll-02.avif" alt="Bamboo grove" /></li>
<!-- … 37 more -->
</ul>

An image with no loading attribute is loading="eager", the default. The browser fetches it straight away, whether it is on screen or two thousand pixels further down. On Kai’s page that is all forty images, several megabytes, before anyone has scrolled.

KaiEasy. I’ll put loading=”lazy” on all of them.

The first lantern falls asleep

Kai’s change half works. The images below the fold now wait until they get close to the viewport, so the page requests a handful of images at load instead of forty. But the hero painting, the biggest thing in the first view and so the page’s LCP element, shows up later than before.

KaiFewer requests, but the big picture is late. How is lazy slower?

VayuAn eager image is requested as soon as the browser reads its tag. A lazy one has to wait until the page is laid out, so the browser can work out how far it is from the viewport. On the hero, all of that waiting lands on LCP.

This is not only a theory. In a web.dev study of lazy loading on WordPress archive pages, median desktop LCP was about 1,759 ms with lazy loading switched off and 2,029 ms with it on, even though lazy loading roughly halved the image bytes. Loading the images in the first view eagerly, and only the rest lazily, kept the savings and brought LCP back.

The fix: light the first, let the rest wait

Treat the first view differently from the rest of the page:

gallery.html
<!-- the first view: eager (the default) and high priority -->
<img src="hero.avif" alt="The mountain temple at dawn" width="1600" height="900" fetchpriority="high" />
<!-- everything below the fold -->
<img src="scroll-01.avif" alt="Koi pond" width="800" height="600" loading="lazy" />
  • The hero stays eager. Leave out loading (or write loading="eager") on the images that are likely to be in view when the page opens, above all the LCP image.
  • The hero gets fetchpriority="high". It tells the browser to fetch this image ahead of the other images on the page.
  • Everything below the fold gets loading="lazy". Chrome still starts those requests early: about 1,250px before they reach the viewport on fast connections and about 2,500px on slow ones, so they are usually ready by the time someone scrolls to them.

VayuDon’t try to have it both ways. An image with loading=”lazy” and fetchpriority=”high” is still delayed while it is off screen, so the pair doesn’t help.

Reserve the space

Until an image arrives, the browser doesn’t know how big it is. When it lands, everything below it jumps down, and that movement counts towards Cumulative Layout Shift (CLS). Lazy images make it worse: they arrive while the reader is already scrolling, and an unloaded image with no size is 0 by 0, so nothing is held open for it.

Write the image’s real width and height on every <img>. The browser turns them into an aspect ratio and reserves the box before a single byte arrives. One line of CSS keeps the image responsive:

styles.css
img { max-width: 100%; height: auto; /* keeps the ratio from width and height */ }

KaiWidth and height in the HTML, height auto in the CSS. The text stopped jumping!

Lanterns that aren’t images

The loading attribute works on <img> and <iframe>. A CSS background image or a heavy widget has no such attribute, so you light that lantern yourself with an IntersectionObserver:

lazy-backgrounds.js
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add('is-lit'); // CSS sets the background
io.unobserve(e.target);
}
}, {
rootMargin: '200px'
});
document.querySelectorAll('.lazy-bg').forEach((el) => io.observe(el));

The rootMargin of 200px grows the viewport by 200 pixels on every side, so the class is added a little before the element scrolls into view. In CSS, only .lazy-bg.is-lit sets the background-image, so the file isn’t requested until then. For plain images, stick with loading="lazy": it needs no script.

What it is How to load it
The hero or LCP image eager (no loading) + fetchpriority="high" + width and height
Other images in the first view eager + width and height
Images below the fold loading="lazy" + width and height
Embeds and maps (<iframe>) below the fold loading="lazy"
CSS backgrounds and heavy widgets IntersectionObserver with a rootMargin

Catch the Lazy Lantern in DevTools

1. Watch the Network tab. Open DevTools, go to Network, filter by Img and reload. Only the images in the first view should load. Scroll down slowly and the rest should arrive just before you reach them.

2. Record a page load in the Performance panel. The LCP request discovery insight checks the LCP image: it should not be lazy-loaded, it should be discoverable in the initial HTML, and it should have fetchpriority="high".

3. Ask the page itself. Paste this into the Console and reload:

DevTools console
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
console.log(lcp.element, lcp.element?.loading);
}).observe({
type: 'largest-contentful-paint',
buffered: true
});

It prints the LCP element and its loading value. If it says 'lazy', wake that image up.

KaiIt says eager. The first lantern is lit!

Before you ship an image

  • Is this image likely to be visible when the page opens? Then no loading="lazy".
  • Is it the LCP image? Then add fetchpriority="high".
  • Is it below the fold? Then loading="lazy".
  • Does it have its real width and height, with height: auto in CSS?
Acharya's Rule #15

Load the hero now, lazy-load the rest, and give every image its size.

Next episode: The Race of Two Fetches. Two requests leave, the slower one comes back last and wins, and your page shows the wrong results. Raiden shows how to ignore stale responses with AbortController.

Think you can wake the right lanterns yourself? Play Vayu’s Lantern Road on domlok.in and earn seal 15.

Watch the episode and the 34-second Short on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.

Further reading

Frequently asked questions

Why did loading="lazy" make my LCP worse?+
A lazy-loaded image is not requested until the browser has laid out the page and checked how close it is to the viewport. If that image is your Largest Contentful Paint element, such as a hero image, the wait is added to LCP. Keep images in the first view eager and lazy-load only the ones below the fold.
Should I lazy load images above the fold?+
No. Images that are likely to be visible when the page opens, especially the LCP image, should use the default eager loading. Use loading=”lazy” for images outside the initial viewport.
What does fetchpriority="high" do on an image?+
It hints to the browser that this image should be fetched at a higher priority than other images on the page. Use it on the LCP image. It does not help on a lazy-loaded image, which is still delayed while it is off screen.
Do lazy-loaded images need width and height?+
Yes. Width and height let the browser reserve the right space before the image loads, which prevents layout shift (CLS). Add height: auto in CSS so the image stays responsive while keeping its aspect ratio.
How do I check if my LCP image is lazy loaded?+
Record a page load in the Chrome DevTools Performance panel and look at the LCP request discovery insight, which flags a lazy-loaded LCP image. You can also log the LCP entry’s element and its loading property with a PerformanceObserver in the console.