Lazy Loading Looks Great in the Dashboard — Your Users Know Better
There's a specific kind of performance win that feels great in a sprint review and falls apart the moment a real user touches it. Lazy-loading images is, for a lot of teams, exactly that kind of win.
The pitch is straightforward: defer loading images until they're needed, shrink your initial payload, watch your Lighthouse score climb. It's been standard practice long enough that most developers implement it without much second-guessing. And on paper, it works. The numbers improve. The stakeholders are happy. The ticket gets closed.
But there's a category of damage that dashboards are genuinely bad at measuring, and lazy-loading has a way of producing all of it at once.
The Layout Shift Problem Nobody Wants to Own
Cumulative Layout Shift (CLS) is one of those metrics that sounds abstract until you watch someone try to tap a link on their phone and hit the wrong thing because the page moved. It's jarring, it erodes trust, and lazy-loaded images are one of the most reliable ways to produce it.
The pattern is familiar: an image loads late, the container didn't have explicit dimensions set, and everything below it jumps down the page. Developers know to set width and height attributes. The problem is that in practice — especially across teams, across content types, across a CMS that's been touched by fifteen different people — those attributes are inconsistently applied. The lazy-loading implementation gets the credit for the performance improvement, and the layout instability gets chalked up to "content issues."
Except they're the same problem. The deferred load created the window for the shift. A statically preloaded image, sized correctly and delivered with a proper <link rel="preload">, doesn't give the layout a chance to move at all.
Your JavaScript Is a Single Point of Failure
Native lazy-loading via the loading="lazy" attribute has gotten good enough that most teams have moved away from JavaScript-based solutions — and that's genuinely progress. But a surprising number of production sites are still running IntersectionObserver-based loaders, third-party lazy-load libraries, or framework-level image components that abstract the whole thing into a black box.
Every layer of JavaScript you add to your image delivery pipeline is a layer that can fail. Script errors, network timeouts, ad blockers, aggressive browser extensions — any of these can interrupt execution and leave your lazy-load logic in a broken state. When that happens, images don't load. Not slowly. Not degraded. They just don't load.
The silent failure mode is the worst part. Your monitoring probably isn't catching it. Your error tracking might not surface it. But somewhere in a coffee shop in Austin or on a commuter train outside Chicago, a user is staring at a page full of empty gray boxes and deciding your site isn't worth the trouble.
Static preloading doesn't have this failure mode. There's no script to break. The browser handles it natively, at the protocol level, before your JavaScript even parses.
The Bandwidth Math You're Not Actually Doing
Here's the assumption baked into every lazy-loading strategy: users won't scroll far enough to load most of the images on the page, so deferring them saves real bandwidth. And sometimes that's true. But it's worth asking how often, for your actual users, on your actual pages.
For content-heavy editorial pages, product listing pages, or any layout where users are actively scrolling to find something, lazy-loading often just spreads the bandwidth cost across the session instead of eliminating it. The images still load. They just load in chunks, each one triggering a new network request, each one potentially stalling rendering while the browser figures out what it just received.
Contrast that with a well-structured static delivery approach: a handful of images preloaded with priority hints, the rest served through a CDN with aggressive caching and proper srcset attributes so the browser picks the right size the first time. No cascading requests. No mid-scroll fetch delays. Just images that are there when the user needs them.
The lazy-load strategy optimizes for the metric (initial page weight) rather than the experience (does the page feel fast while I'm using it). Those aren't the same thing, and conflating them is how teams end up with great Lighthouse scores and frustrated users.
When Lazy Loading Actually Earns Its Keep
None of this means lazy-loading is always wrong. It genuinely earns its place in specific contexts.
Below-the-fold images on long-form content pages — think blog posts, documentation, long-read articles — are reasonable candidates, especially when the user's scroll behavior is genuinely uncertain. Image-heavy archive pages where most users are filtering or searching rather than scrolling through everything are another good fit. Anywhere that the realistic user path involves abandoning the page before hitting the deferred content, lazy-loading is doing real work.
The issue isn't the technique. It's the blanket application of it as a default optimization without auditing whether the tradeoffs actually make sense for your content and your users.
What a Better Default Looks Like
A more honest approach starts by splitting your images into tiers based on actual user behavior, not assumptions.
Tier one is anything visible in the initial viewport on a median device at a median connection speed. These get preloaded. No lazy-loading, no deferral, no JavaScript dependency. You use <link rel="preload"> with fetchpriority="high" and you make sure the dimensions are explicit so the layout is stable before the image arrives.
Tier two is anything likely to be seen within one or two scrolls. These use native loading="lazy" — no library, no IntersectionObserver, just the attribute. Simple, resilient, and good enough for the vast majority of cases.
Tier three is genuinely out-of-viewport content that users may never reach. This is where lazy-loading actually justifies itself, and where you can be more aggressive about deferral without hurting anyone.
The difference between this and the typical implementation isn't sophistication — it's intentionality. Most lazy-load strategies are applied uniformly because they're easy to apply uniformly. The performance benefit is real but shallow, and the hidden costs accumulate in the places your tooling doesn't look.
The Metrics Are Telling You What You Measured
Dashboards are good at measuring what you asked them to measure. Initial page weight, time to first byte, Lighthouse performance score — these are real signals, but they're not the whole picture. CLS events mid-scroll, JavaScript failures on edge-case devices, the subjective jankiness of images popping in while you're reading — these things don't show up cleanly in the report.
The teams that are actually shipping fast, stable image experiences tend to be the ones who've stopped treating lazy-loading as a free performance win and started treating it as a tradeoff with real costs on both sides. Sometimes those costs are worth it. Often, a more static, preload-first approach is simpler, more resilient, and — when you measure the right things — actually faster where it matters.