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
Vayu: You lit the whole road at once, Kai, and then you put out the one lantern every traveller sees first.
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:
<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:
<!-- 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 writeloading="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:
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:
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:
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
widthandheight, withheight: autoin CSS?
Acharya's Rule #15Load 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
- MDN: the img element (loading, fetchpriority, width and height)
- web.dev: Browser-level image lazy loading for the web
- web.dev: The performance effects of too much lazy loading
- Chrome DevTools: LCP request discovery insight
- MDN: IntersectionObserver() constructor and rootMargin