Lazy loading arrived in the browser

The category page transferred 4.2 megabytes, of which 3.8 was images, of which about a tenth was ever seen — the page showed sixty products and nobody scrolled past twelve. There was already a lazy-loading library doing this, in JavaScript, badly, and the browser can now do it with an attribute.

The symptom

$ curl -s https://shop.example/category/tools | grep -c '<img'
60

# from the network panel, on a cold load
#   documents      1     14 kB
#   stylesheets    3     62 kB
#   scripts        8    412 kB
#   images        60  3,812 kB     ← 4 above the fold
#   total              4,300 kB

# and the library that was supposed to prevent this
$ grep -rn 'lazysizes' resources/js/
resources/js/app.js:  import 'lazysizes';    // 8 kB, and it was not working

The library was configured, loaded and had stopped working after a markup change six months earlier — the data-src attribute had been renamed during a template refactor and nothing failed, because a lazy-loading library that finds no elements does nothing quietly.

Why it happens

The browser parses the document and starts fetching images before it has laid anything out, so at the moment the fetch decision is made it does not know what is offscreen. That is why deferral required JavaScript: a library empties the src, watches for intersection and restores it, which means the browser is being deliberately lied to and then corrected.

The attribute is a hint that changes the fetch decision itself, which is why it works before any JavaScript has run and why it survives a template refactor.

The fix

The attribute, and the images it must not go on

<img src="/photo.jpg" width="800" height="600" loading="lazy" alt="">
<iframe src="/map" loading="lazy" title="Location"></iframe>

<!-- and the ones it must NOT go on -->
<!--   the hero, the logo, the first product image -->
<!--   anything that is the Largest Contentful Paint element -->
<img src="/hero.jpg" width="1600" height="900" loading="eager">

Applying it to every image with a blanket filter is the mistake and it makes the Largest Contentful Paint worse: the browser now waits to discover the hero image rather than fetching it immediately, which adds a round trip to the metric everybody is being measured on.

measured on the category page, three variants:

  no lazy loading           LCP 2.9s   transferred 4,300 kB
  lazy on EVERY image       LCP 3.4s   transferred   680 kB
  lazy except the first 4   LCP 1.8s   transferred   720 kB

the middle row is the one people ship.

Width and height are required again

<!-- the browser reserves the box before the image arrives -->
<img src="/photo.jpg" width="800" height="600" loading="lazy" alt="">

<style>
img { max-width: 100%; height: auto; }   /* still responsive */
</style>

Removing the attributes was correct advice for a decade of responsive design and it is what makes a page jump while it loads. Browsers now compute an aspect ratio from them and apply it before the image arrives, so the space is reserved and nothing below moves — and height: auto is what keeps the image responsive despite the fixed attributes.

This matters more than it appears for lazy loading specifically: without dimensions, sixty deferred images means sixty layout shifts as the user scrolls, which is a worse experience than the slow load it replaced. Chrome only applies native lazy loading to images with dimensions for exactly this reason.

The distance is a hint, not a threshold

how far below the fold loading starts, in Chrome 80:

  fast connection (4G)        1,250 px
  slow connection (3G)        2,500 px
  data saver enabled            800 px

and these numbers changed twice during 2020.

which means: you cannot rely on an image being loaded by
the time it is visible, and you cannot rely on it NOT being
loaded when it is not.

The distances are deliberately generous because a visible placeholder is worse than a wasted fetch, and they are tuned per engine and per connection. Anything whose behaviour depends on knowing whether an image has loaded still needs the observer, and anything that merely wants fewer bytes does not.

Support in April, and whether the polyfill is worth it

Chrome 76+      yes, since August 2019
Edge 79+        yes
Firefox 75      images only, from April 2020
Safari          NO. behind a flag until 15.4, in 2022.

Safari is ~18% of this site's traffic.
if ('loading' in HTMLImageElement.prototype) {
  // nothing to do. the attribute works.
} else {
  import('lazysizes').then(() => {
    document.querySelectorAll('img[loading="lazy"]').forEach(img => {
      img.dataset.src = img.src;
      img.removeAttribute('src');
      img.classList.add('lazyload');
    });
  });
}

The feature detection plus a dynamic import means the library is downloaded only by browsers that need it, which is the arrangement that makes the polyfill defensible — 82% of traffic pays nothing and 18% pays 8 kilobytes. Loading it unconditionally to serve a fifth of users is the version that is not worth it.

Whether to polyfill at all is a genuine judgement. Safari users get the old behaviour, which is what everybody had last year, so the cost of not polyfilling is that a fifth of traffic sees no improvement rather than a regression.

Verifying it worked

# transferred bytes, cold load, no scroll
#   before  4,300 kB
#   after      720 kB

# and the metric that matters, from field data rather than a lab
$ npx lighthouse https://shop.example/category/tools 
    --only-categories=performance --chrome-flags='--headless'

  Largest Contentful Paint   1.8 s     (was 2.9 s)
  Cumulative Layout Shift    0.02      (was 0.31)
  Total Blocking Time        120 ms    (was 340 ms)

$ grep -c 'loading="lazy"' <(curl -s https://shop.example/category/tools)
56          # 60 images, 4 eager

The layout shift number moved further than the load time did, and that was not the goal — it came from adding the width and height attributes, which the lazy loading required. That is worth noting because it is the more valuable improvement and it would not have happened without the other work.

A lab measurement proves the change is present; the number that counts is the 75th percentile of real user data, which took three weeks to move and is the only one Google uses. Checking both is the discipline, and checking only the lab one is how a change that helps a fast laptop and not a phone gets shipped.

What this costs

An attribute each engine interprets differently, with distances that changed twice during the year and no way to influence them. That is a genuine loss of control compared with a library where the threshold was a configuration value — and it is a reasonable trade, because the library’s threshold was chosen by guessing and the browser’s is chosen from connection information the page does not have.

The second cost is that the first-image exception has to be maintained by hand. Which image is the Largest Contentful Paint element depends on the viewport, the template and the content, so a rule such as “the first four” is an approximation that is wrong on some pages. Automating it properly requires knowing the layout, which the template does not — so this stays a judgement in each template, and a template somebody adds next year will get it wrong.