Robin van der Vleuten

Lazy loading images is just HTML now

You're building a page with a long scroll.

The top matters. The hero image, the first product photo, maybe a screenshot right under the heading. The browser should fetch those right away.

Then the page keeps going: related articles, customer logos, screenshots, gallery items, avatars in comments.

None of those images need to compete with the first screen.

For years, I reached for some combination of data-src, an IntersectionObserver, and a tiny script that became less tiny the moment the design changed. It worked. It also meant I owned one more bit of browser behavior.

loading="lazy" is the boring version I wanted all along. You tell the browser which images can wait, and the browser decides when they are close enough to fetch.

The version most of us wrote first

A JavaScript lazy loader usually starts out as a neat little trick:

html
<img
data-src="/images/cabin.jpg"
alt="A small wooden cabin between tall pine trees"
width="1200"
height="800"
>

The script finds every image with data-src, watches it, swaps the real URL into src, and removes the observer when the image has loaded.

That is not a terrible solution. IntersectionObserver is a good API, and sometimes you really do need the control.

But most image lazy loading does not need a lifecycle. It needs a hint.

Let the browser handle the boring case

Native lazy loading is just an attribute on the real image:

html
<img
src="/images/cabin.jpg"
alt="A small wooden cabin between tall pine trees"
width="1200"
height="800"
loading="lazy"
>

There is no placeholder source and no observer to clean up. The image still has its actual src, so browsers that ignore the attribute load it like a normal image.

The browser does not wait until the image is one pixel away from the viewport. It uses its own distance from the viewport, connection information, and implementation details to start the request before the user gets there.

A lazy loader that is too strict saves bytes and then shows the user a blank rectangle. I have written that one. The native version is less fussy, which is exactly the point.

Keep the first viewport eager

The temptation is to add loading="lazy" to every image and call it a performance pass.

Don't do that.

Images that are visible on first load should stay eager. The same goes for the image that is likely to become your Largest Contentful Paint. Lazy loading that image asks the browser to delay the thing the user came to see.

html
<!-- First viewport: let the browser load these normally. -->
<img
src="/images/product-hero.jpg"
alt="The walnut writing desk from the front"
width="1600"
height="1000"
>
<!-- Further down the page: this can wait. -->
<img
src="/images/product-detail.jpg"
alt="Close-up of the brass drawer handle"
width="1200"
height="800"
loading="lazy"
>

I usually draw the line there: eager for the first screen, lazy once the image is clearly part of the scroll. You can be more exact on a page that needs it, but that rule catches the common case without turning every template into a performance policy document.

Dimensions are not optional

Lazy loading changes when an image arrives. It should not change how much room the page keeps for it.

Give the browser width and height, even when CSS controls the rendered size:

html
<img
src="/images/gallery-04.jpg"
alt="A workbench covered with hand tools"
width="1200"
height="800"
loading="lazy"
>
css
img {
display: block;
height: auto;
max-width: 100%;
}

Those attributes let the browser calculate the image's aspect ratio before the file has loaded.

Without dimensions, unloaded images can start life as 0x0. That can turn a performance fix into layout shift: the reader gets halfway through a paragraph, the image arrives, and the text jumps under their eyes.

It works with picture too

Responsive images do not change much here. Put the loading attribute on the fallback img:

html
<picture>
<source
srcset="/images/cabin-wide.avif"
type="image/avif"
media="(min-width: 48rem)"
>
<source
srcset="/images/cabin-wide.webp"
type="image/webp"
media="(min-width: 48rem)"
>
<img
src="/images/cabin.jpg"
alt="A small wooden cabin between tall pine trees"
width="1200"
height="800"
loading="lazy"
>
</picture>

The browser still picks the right source. The img carries the loading hint because it is the element that represents the image in the document.

Small detail. Easy to forget.

When JavaScript still earns its place

Native lazy loading is a good default, not a ban on custom code. I would still write the JavaScript when the page needs something more specific:

  • Tune the threshold yourself. The native distance from the viewport is browser-defined. If a page needs a precise trigger point, use an observer.
  • Lazy load something that is not an image. Background images, videos, embeds, and expensive widgets need their own strategy.
  • Coordinate animations or analytics. If loading an asset is part of a larger interaction, keep that logic explicit.
  • Support an older browser with the same behavior. Browsers that do not understand the attribute ignore it, which is a fine fallback for many sites. It is not the same as polyfilled lazy loading.

For ordinary offscreen images, though, the boring solution has become the better one.

The version I use now

Most of the time, my image markup now looks like this:

html
<img
src="/images/workshop.jpg"
alt="A narrow workshop with shelves of tools on both walls"
width="1200"
height="800"
loading="lazy"
>

That is for images below the initial viewport. If the image is visible on load, especially if it may become the LCP image, I leave the attribute off. If I need a precise threshold or I am lazy loading something other than an img, I still reach for JavaScript.

A real src, useful alt text, stable dimensions, and one attribute for images that can wait. That is a nice amount of ceremony.

Sometimes HTML catches up with the thing we kept solving around it. This is one of those times.