This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive

Honest Lazy Loading: IntersectionObserver vs Native loading=lazy (The Real Difference)

The two tools, in one paragraph

Honest Lazy Loading: IntersectionObserver vs Native loading=lazy (The Real Difference)

Article content

The two tools, in one paragraph

Native loading="lazy" on <img> and <iframe> is a browser-managed hint. The browser decides when to load based on its own heuristics, and it usually does a good job. IntersectionObserver is a programmable primitive that tells your code exactly when an element enters a viewport band. Both are useful. They are not the same thing, and treating them as interchangeable is how you end up with images that load too late or scripts that fire too early.

This post is the mental model I use to pick between them.

The happy path: native lazy on images below the fold

For most images on a blog, native lazy loading is correct and sufficient.

<img src="keyboard.jpg" alt="A keyboard on a wooden desk" width="1920" height="1280" loading="lazy" decoding="async" />

What this gets you, for free:

  • The image does not download until the browser predicts it is needed.
  • No JavaScript required.
  • The decoding="async" hint tells the browser it can decode off the main thread.
  • width and height reserve the aspect-ratio box so there is no layout shift when the image eventually arrives.

The browser's heuristic is not perfect, but it is tuned for the 80% case of scrolling pages. If your images are content images on a standard reading flow, loading="lazy" does the job.

Where native lazy is wrong

Three situations where you should not use loading="lazy", or where you have to combine it with IntersectionObserver.

1. LCP candidates above the fold

The image that is going to be your Largest Contentful Paint should load eagerly. Setting loading="lazy" on an LCP image delays the one number that most affects your page quality metric.

<img src="hero.jpg" alt="Hero image" width="1200" height="800" loading="eager" fetchpriority="high" />

fetchpriority="high" tells the browser this resource should be prioritized over others. Use it sparingly - if everything is "high", nothing is. One LCP candidate per page.

2. <source> inside <picture>

loading="lazy" on an <img> inside a <picture> applies to the image as a whole. But the <source> elements inside the picture have already been evaluated by the time the browser decides whether to defer the image. The browser will still download a WebP variant even if the fallback <img> is lazy.

For large below-the-fold <picture> blocks, the fix is to store the srcset in data-srcset and swap it with IntersectionObserver when the element is near the viewport.

<picture>
  <source
    type="image/webp"
    data-srcset="/img/hero-320.webp 320w, /img/hero-640.webp 640w"
    sizes="(max-width: 640px) 100vw, 640px"
  />
  <img src="/img/hero.jpg" alt="..." width="1920" height="1280" loading="lazy" />
</picture>
const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const source = entry.target;
      source.srcset = source.dataset.srcset;
      source.removeAttribute('data-srcset');
      io.unobserve(source);
    }
  },
  { rootMargin: '200px' }
);

document.querySelectorAll('source[data-srcset]').forEach((s) => io.observe(s));

This is the only reliable way I have found to avoid eager downloads of large WebP variants for below-the-fold pictures.

3. Iframes from third parties

Giscus, CodePen embeds, video embeds: these are expensive third-party resources that you do not want to load on every page view. loading="lazy" on the iframe helps, but you often want stricter control - load only when the user scrolls close, or only when a "Show comments" button is pressed.

<div class="comments-shell" data-giscus-src="https://giscus.app/client.js" data-giscus-attrs='{ "data-repo": "..." }'>
  <button type="button" class="load-comments">Load comments</button>
</div>
document.querySelector('.load-comments')?.addEventListener('click', (e) => {
  const shell = e.target.closest('.comments-shell');
  const attrs = JSON.parse(shell.dataset.giscusAttrs);
  const script = document.createElement('script');
  script.src = shell.dataset.giscusSrc;
  for (const [k, v] of Object.entries(attrs)) script.setAttribute(k, v);
  script.crossOrigin = 'anonymous';
  script.async = true;
  shell.appendChild(script);
  e.target.remove();
});

The cost of opting into "click to load" for comments is one button and a 20-line handler. The benefit is a significantly smaller critical path for readers who do not engage with comments.

On dout.dev I ship the Giscus embed lazily via data-loading="lazy" and do not hide it behind a button, because comments are part of the editorial experience. On a page with heavier embeds, the button pattern is the right default.

The mental model

  • Is the element above the fold and likely the LCP? → loading="eager" + fetchpriority="high".
  • Is it an image or plain iframe below the fold? → loading="lazy".
  • Is it a <source> inside a <picture> below the fold? → IntersectionObserver with data-srcset swap.
  • Is it a heavy third-party embed? → IntersectionObserver with a bigger root margin, or a click-to-load button.

Everything else is a variation.

What I do not do

I do not reinvent native lazy for plain images. I do not ship a "lazyload.js" dependency. I do not observe scroll events. IntersectionObserver is already in every target browser and has been for years.

The takeaway

Native lazy is a good default. Use it first. Reach for IntersectionObserver when the browser's heuristic is not under your control (nested picture sources, third-party iframes, expensive runtime costs). The two tools complement each other; they do not compete.

References

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.