When lazy-loading responsive images with an observer, store srcset and sizes in data attributes alongside src, and on intersection assign sizes first, then srcset, then src — for picture, update every source before the img — so the browser evaluates the candidates once and downloads a single, correctly sized file.

Problem / Scenario Context

A photography portfolio serves each image in five widths through srcset. Their observer-based loader only knows about data-src, so lazily loaded images always download the largest fallback file — 2400 px wide on phones that need 600 px. A first fix adds data-srcset but assigns img.src before img.srcset; the Network panel then shows two requests per image on some browsers: the fallback, immediately cancelled or completed, and then the right candidate.

Responsive images add an ordering problem to lazy loading. The Lazy Loading Images & Media topic covers the basic loader; this page covers srcset, sizes and picture.

Mechanics Explanation

An img element's current request is updated whenever a relevant attribute changes: src, srcset, sizes, and — for an img inside picture — the parent's source elements. Each update runs the image selection algorithm: if srcset is present, pick the best candidate for the current sizes value and device pixel ratio; otherwise use src.

Setting attributes one at a time therefore triggers selection several times. Set src first and the browser starts fetching the fallback, because at that moment there is no srcset. Set srcset next and it restarts with a new candidate — the first request may be aborted, or may already be complete and wasted. Setting sizes last can change the chosen candidate yet again.

The safe order is the one that makes the first selection correct: sizes, then srcset, then src (which, with srcset present, is only the fallback for browsers that do not understand srcset). For picture, the source elements' srcset/sizes must be in place before the img gets its attributes, because the img's selection looks at its sources.

Attribute Order and DownloadsTwo columns. Setting src first, then srcset, then sizes makes the browser start fetching the large fallback, then switch to a srcset candidate, then possibly switch again when sizes arrives, producing up to three requests. Setting sizes first, then srcset, then src makes the first selection correct and produces a single request for the right width.src → srcset → sizessrc: fetch the 2400w fallbacksrcset: switch to a candidatesizes: may switch againUp to three requests per imagesizes → srcset → srcsizes: nothing to fetch yetsrcset: first selection is correctsrc: fallback only, ignoredOne request, right width

Comparison Table: Attributes to Defer

Element Defer Keep in markup from the start Assign order on intersection
img src, srcset, sizes alt, width, height sizes → srcset → src
picture > source srcset, sizes type, media all sources, then the img
picture > img src, srcset, sizes alt, width, height after sources
img with sizes="auto" src, srcset loading="lazy", sizes="auto" native only — see below

Minimal Reproducible Example

TypeScript
// Wrong order: fallback first.
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target as HTMLImageElement;
    img.src = img.dataset.src!;              // starts the 2400w fallback download
    img.srcset = img.dataset.srcset!;        // re-selects: second request
    img.sizes = img.dataset.sizes!;          // may re-select again
    io.unobserve(img);
  }
});

With the Network panel filtered to images and caching disabled, scroll on a phone-sized viewport: some images show two or three requests.

Production-Safe Solution

HTML
<picture>
  <source type="image/avif"
          data-srcset="/p/42-600.avif 600w, /p/42-1200.avif 1200w, /p/42-2400.avif 2400w"
          data-sizes="(max-width: 700px) 100vw, 50vw">
  <img class="lazy-responsive"
       data-srcset="/p/42-600.jpg 600w, /p/42-1200.jpg 1200w, /p/42-2400.jpg 2400w"
       data-sizes="(max-width: 700px) 100vw, 50vw"
       data-src="/p/42-1200.jpg"
       alt="Fog over a harbour at dawn" width="1200" height="800">
</picture>
TypeScript
function applyResponsive(img: HTMLImageElement): void {
  const picture = img.parentElement instanceof HTMLPictureElement ? img.parentElement : null;

  // 1. Sources first: the img's selection consults them.
  picture?.querySelectorAll<HTMLSourceElement>('source[data-srcset]').forEach((s) => {
    if (s.dataset.sizes) s.sizes = s.dataset.sizes;
    s.srcset = s.dataset.srcset!;
    s.removeAttribute('data-srcset');
  });

  // 2. Then the img: sizes → srcset → src, so the first selection is final.
  if (img.dataset.sizes) img.sizes = img.dataset.sizes;
  if (img.dataset.srcset) img.srcset = img.dataset.srcset;
  if (img.dataset.src) img.src = img.dataset.src;
  delete img.dataset.srcset; delete img.dataset.sizes; delete img.dataset.src;
}

const responsive = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    applyResponsive(e.target as HTMLImageElement);
    obs.unobserve(e.target);
  }
}, { rootMargin: '50% 0px' });

document.querySelectorAll('img.lazy-responsive').forEach((img) => responsive.observe(img));

Each image now makes exactly one request, for the candidate that matches its layout width and the device's pixel ratio. width and height in the markup reserve the aspect ratio before any file arrives, so nothing shifts.

Bytes Downloaded per Image on a PhoneA bar chart of bytes per image on a phone-width viewport. Using only data-src loaded the 2400 pixel fallback at about 520 kilobytes. Setting src before srcset loaded the fallback and then the right candidate, about 610 kilobytes in total. Setting sizes, then srcset, then src loaded only the 1200 pixel AVIF that matches a 3x phone, about 48 kilobytes.One portfolio image, 390 px wide viewport at 3xdata-src only~520 KB (2400w JPEG)src before srcset~610 KB (two requests)sizes → srcset → src~48 KB (1200w AVIF)

sizes="auto" and Native Lazy Loading

Writing accurate sizes values is tedious: they duplicate layout knowledge that lives in CSS. The sizes="auto" value lets the browser use the image's actual laid-out width instead — but only for images with loading="lazy", because only then does the browser wait for layout before choosing a candidate. Chromium supports it; other engines ignore auto and fall back to the rest of the sizes list, so the recommended form is sizes="auto, (max-width: 700px) 100vw, 50vw".

That makes native lazy loading particularly attractive for responsive images: accurate candidate selection without hand-maintained sizes. Use an observer-based loader for responsive images only when you need its extra control, as discussed in native loading="lazy" vs IntersectionObserver.

Choosing How to Defer a Responsive ImageA decision chain. If the image is above the fold, load it eagerly with its full srcset and sizes. Otherwise, if native lazy loading is acceptable, use loading lazy with sizes auto plus a fallback list. Otherwise, if an observer is needed for placeholders or a custom distance, defer sizes, srcset and src and assign them in that order. For picture elements, update every source before the img.Above the fold?Eager, full srcset and sizes, fetchpriority highyesnoIs native lazy loading acceptable?loading="lazy" with sizes="auto, …fallback"yesnoNeed placeholders or a custom distance?Observer: assign sizes, then srcset, then srcyesnoFor picture: update every source before the img.

Verification Steps

  • Filter the Network panel to images with cache disabled and confirm one request per image.
  • Check the chosen file (the request URL) matches the expected width at phone, tablet and desktop sizes.
  • Test high-DPR devices to confirm 2× and 3× candidates are chosen appropriately.
  • Disable JavaScript and confirm a noscript fallback or eager path exists.
  • Measure CLS while scrolling; reserved dimensions should keep it at zero.

Common Mistakes to Avoid

  • Deferring only src. The largest fallback downloads on every device.
  • Assigning src before srcset. It can cause a second download.
  • Updating the img before its source elements. The first selection ignores the sources.
  • Dropping width and height from responsive images. Aspect ratio must still be reserved.

FAQ

Why does setting src first cause two downloads?

Because each attribute change re-runs image selection. With only src present, the browser starts fetching it; when srcset arrives, it selects a different candidate and starts another request. The first may be aborted or may already have completed.

Do I need a src at all if I have srcset?

Browsers that support srcset ignore src when a srcset is present, but src remains a sensible fallback and some tools expect it. Set it last so it never triggers a fetch of its own.

What does sizes="auto" do?

It tells the browser to use the image's actual layout width when selecting a candidate. It is only honoured for lazy-loaded images, because only they wait for layout before choosing.

Can the observer read the element's width and build sizes itself?

It could, from entry.boundingClientRect.width, but that locks sizes to the width at the moment of loading and ignores later layout changes. sizes="auto" with native lazy loading, or an accurate media-query sizes value, is more robust.

Does lazy loading work with art direction in picture?

Yes, as long as every source's srcset is deferred and restored before the img is updated, so the browser evaluates media conditions and types with all sources present.

How early should responsive images start loading?

Larger candidates take longer to arrive, so a bigger margin — half a screen to a full screen — works better than for small thumbnails. Measure how often images are still loading when they enter the viewport and adjust.


↑ Back to Lazy Loading Images & Media