It got slower while I optimized it
Lazy loading only helps when you aim it, and there are three places where it makes things worse.
There’s a one-word attribute now that defers loading an image until it’s near the viewport. It works in the browsers most of my visitors use and it needs no JavaScript. That’s one word replacing a whole library.
So I put it on every single image on a client site. It took 20 minutes, and that included hunting down all the templates.
Then I measured it, because I’d spent the spring being sanctimonious about measuring things. The main content was showing up later than it had before. It wasn’t by a lot, but it happened on every page I checked on a throttled connection.
The site had less to download and it was slower.
Fashionably late
The mistake comes straight out of a detail that’s easy to skim past.
Normally the browser’s preload scanner finds an <img> while the HTML is still streaming in, before CSS has been parsed and long before layout has run. It starts fetching immediately. That head start is one of the oldest and best optimizations browsers do, and you get it for free without asking.
A lazily loaded image opts out of all that. The browser has to know whether the image is near the viewport. It can’t know that until it has the CSS and has done layout, so the request doesn’t even get considered until much later in the load.
Which is exactly what you want for something 3000 pixels down the page. For anything visible right away, it’s strictly a loss.
One: above the fold
That’s my whole mistake, and apparently it’s the most common one there is.
The hero image is usually the largest element on the screen. On most marketing pages that makes it the thing the loading metrics are measuring. Deferring it takes the single most important request on the page and moves it from “as early as physically possible” to “after CSS and layout.”
I’d taken a page’s headline visual and put it behind a queue.
The rule is easy and I apply it now without thinking: nothing above the fold is lazy. Not the hero, not the logo, not the first two cards in a grid. If it’s likely to be on screen at 0 ms, it loads eagerly. And if it’s the biggest thing on screen, you should probably be preloading it instead of merely not deferring it.
It gets awkward because “above the fold” varies by viewport, and a template has no way of knowing. In practice I’ve settled for loading the hero and the first row of anything eagerly, everywhere. That’s a little wasteful on a phone, but it’s a lot better than the alternative.
Two: the JavaScript version is worse
Before there was an attribute, everybody did this with a script and a placeholder. Plenty of sites still do, and two of mine did until this spring.
<img src="placeholder.gif" data-src="/img/real.jpg" class="js-lazy" alt="…">
There’s no image in that markup, just a string in an attribute the browser has no opinion about.
So the preload scanner sees nothing to fetch anywhere on the page, the hero included. All of your image loading now waits behind your JavaScript bundle.
If the script fails or a third party breaks it or somebody’s blocker eats it, the page has no images at all. And I don’t mean slow images. I mean none.
Search engines and anything else reading the HTML see a placeholder too. That’s a different conversation, and one I’ve had with an unhappy client.
The native attribute has none of those problems because the real URL is in src where it belongs. The browser gets to make an informed decision instead of a script faking one.
Where I still need the scripted version for browsers that don’t support the attribute yet, I flip it around. The real src stays in the markup and the script removes work instead of being required to do it.
Three: lazy-load all the things
This one is subtler, and it’s why the whole site got worse instead of just the hero.
Lazy loading has a cost per image: a check, a threshold, a deferred decision. On a 40-pixel avatar or a 2 KB icon, that cost is a meaningful fraction of the savings. On a page with 60 small images, you’ve traded a handful of tiny parallel requests for 60 deferred decisions.
It also interacts badly with anything that changes layout. If an image without dimensions loads late, everything below it jumps. Now you’ve turned a loading problem into a visual stability problem, which is worse because it happens in front of the person instead of before they see the page.
So dimensions go on everything, always. Lazy loading only goes where there’s a real chance the image is never seen.
My greatest hits
I’ve made this same mistake in three other forms, and at least I recognize it now.
One was preloading everything, which amounts to preloading nothing plus contention. The browser ends up fetching 11 “critical” resources in parallel over a connection that can comfortably carry 2, and every one of them arrives later than it would have.
Another was inlining all the CSS to save a request on a site where people visit four pages. The same stylesheet gets re-sent four times and the cache never gets used.
The third was splitting assets across multiple domains. That was genuinely good advice a decade ago and became actively harmful once connections were reused. It’s still in a lot of documentation.
Every one of those is a real technique that helps in the situation it was designed for. I applied each one uniformly because applying something uniformly feels rigorous and takes 20 minutes. It’s the same instinct as putting !important on everything that isn’t working.
An optimization is a trade. If you can’t say what you’re giving up, you haven’t finished reading about it.
Measure twice
The fix took about 10 minutes: eager on the hero and the first row, lazy on everything below, dimensions everywhere, and a preload on the hero. That version really is faster than where I started. I measured it on a throttled phone.
The lesson I’d actually keep isn’t about images. It’s that I did 20 minutes of work and felt good about having improved something. I would have released it and told the client it was faster.
That’s the part that makes me wince. The measuring afterward was almost an afterthought, and it was the only part that was worth anything.