The IntersectionObserver I deleted
Mat Marquis — who chaired the group that standardised responsive images in the first place — recently published The end of responsive images, which is roughly a confession. The syntax was hard because it had to be: a browser decides what to request long before it knows anything about the page’s layout, so somebody had to tell it, and that somebody was you.
I read it, went to look at a lazy-loading utility I had been quietly proud of, and deleted the whole thing.
What I used to write
Every project got some version of this. An observer, a margin picked by feel, a
data-src swapped in on intersection, and a class to fade the result:
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const img = entry.target
img.src = img.dataset.src
img.addEventListener('load', () => img.classList.add('is-loaded'))
io.unobserve(img)
}
},
{ rootMargin: '200px' },
)
for (const img of document.querySelectorAll('img[data-src]')) {
io.observe(img)
}Fifteen lines, plus the CSS, plus the markup convention, plus remembering to re-run it after any client-side render. Multiply by every project.
The replacement is one attribute:
<img src="/photo.jpg" alt=""
width="1200" height="800" loading="lazy">It is not just shorter, it is better
The part that stung slightly is that my version was worse, and not marginally.
rootMargin: '200px' is a number I invented. Chrome’s threshold is not a
constant — it moves with the effective connection type, so a phone on a slow
network starts fetching much earlier than my fixed margin ever would. I was not
tuning that. I was guessing once and shipping the guess.
Worse, data-src actively hides images from the preload scanner, the thing that
races ahead of the parser looking for resources to start early. Every image on
the page had to wait for my JavaScript to execute before it could even be
requested. I had written a performance optimisation that made the first screen
slower.
And loading="lazy" is properly settled: Safari 16.4 in early 2023, Firefox 121
at the end of it. It has been Baseline widely available since then. There is no
fallback to write, because a browser that does not know the attribute simply
loads the image, which is the old behaviour and is fine.
The two attributes that come with it
width and height. Not the pixel size you want to display — the intrinsic
size of the file. The browser divides them into an aspect-ratio and reserves
the box before a single byte of image arrives, which is the whole of your CLS
problem solved. This also retires the padding-top wrapper div, which I do not
miss either.
fetchpriority="high", on exactly one image. The rule that matters is the
negative one: never put loading="lazy" on the image that is your Largest
Contentful Paint. Lazy is a deprioritisation, and applying it to the hero is the
single most common way to make a page measurably slower while believing you
optimised it. Mark the hero high, leave everything else alone, and skip
fetchpriority="auto" entirely — omitting the attribute says the same thing
with less markup.
I would not bother with decoding="async". It is well supported, and on a
normal page you will not measure the difference.
The part Marquis is actually celebrating, and the asterisk on it
His real subject is sizes, which is the genuinely miserable attribute — you
end up writing things like (min-width: 1340px) 257px, (min-width: 1040px) calc(24.64vw - 68px) and recalculating them every time the layout moves.
sizes="auto" deletes that. Paired with loading="lazy", the request happens
late enough that the browser already knows how big the rendered image is, so it
can answer its own question. It is also written to degrade: auto goes first,
your old list stays behind it as the fallback.
<img
srcset="/photo-400.jpg 400w,
/photo-800.jpg 800w,
/photo-1600.jpg 1600w"
sizes="auto, (min-width: 60rem) 30vw, 100vw"
src="/photo-800.jpg"
alt="" width="1200" height="800" loading="lazy">Here is the asterisk the celebration leaves out. sizes="auto" is Chrome 126 and
Firefox 150, and Safari has not shipped it at all. Baseline calls that
limited availability, and it means the calculation you were promised you could
delete is still the calculation every iPhone in your analytics will use. Ship it
by all means — it costs nothing and it is free improvement for the browsers that
have it — but keep the fallback list accurate. The end of responsive images is
announced; it has not arrived.
What the observer is still for
Not images. It is for the things it was always actually good at, where the question is genuinely did this element become visible: loading the next page of an infinite list, firing an impression event, lighting up the current heading in a table of contents.
There is still exactly one on this site, and it is instructive about where the
line falls. The screen recordings on the project pages are video, and video
has no settled loading="lazy" — it landed in Chrome 150 and nowhere else yet.
So the component renders a video with no source children at all until the
observer fires, which means there is nothing for the browser to request, and it
pauses playback again on the way out so it is not decoding frames for an empty
room. Deferring the fetch, and reacting to the element leaving: an attribute
does not do either of those for video yet.
Everything else I used to reach for it has been absorbed. Deferring the render
of offscreen sections is content-visibility. Animating something as it scrolls
into view is a scroll-driven animation, in CSS, off the main thread.
Which is the pattern, I think. The clever code you write to work around a gap in the platform has a shelf life, and the good outcome — the one you are supposed to want — is that it expires. It is still a slightly strange feeling to open a file you were pleased with and replace it with an attribute.