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.
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
// 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
<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>
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.
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.
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
noscriptfallback 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
srcbeforesrcset. It can cause a second download. - Updating the
imgbefore itssourceelements. The first selection ignores the sources. - Dropping
widthandheightfrom 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.
Related
- Building a Lazy Image Loader with IntersectionObserver — the base loader
- Preloading the Next Image Before It Scrolls In — getting ahead of the scroll
- Accessible Lazy Images: Alt Text and Placeholders — static attributes
↑ Back to Lazy Loading Images & Media