Lazy Loading Images Almost Tanked My Core Web Vitals

I broke my own Core Web Vitals by being clever with lazy loading. Here is the ugly LCP data, what actually fixed it, and the image loading rules I use now.
Lazy Loading Images Almost Tanked My Core Web Vitals
Photo by Javier Mazzeo / Unsplash

Lazy loading that made everything slower

I had a week where my Core Web Vitals looked like a slow-motion car crash.

New layout. New images. New lazy loading strategy I felt pretty smart about. Lighthouse loved it locally. Then the real-world data arrived.

Chrome UX Report came back with my LCP in the 3.4s to 3.7s range on mobile for some key pages. Before the redesign I was sitting around 2.1s to 2.4s. So I “optimized” and lost over a full second of LCP.

The culprit was not big JavaScript. It was not fonts. It was me getting too aggressive with loading="lazy".

The setup: what I changed that broke LCP

The site is simple.

  • Static content
  • Next.js, no heavy client-side routing
  • Mostly text, a few images per page

The redesign added more visuals. Hero images, small illustrations, some inline screenshots. Classic trap: once you have more images, you feel obligated to lazy load everything because “performance”.

So I did exactly that. I shipped a release where every image had one of these attached:

<img src="/images/post-hero.jpg" 
     alt="Post hero" 
     loading="lazy" />

No exception for the hero. No exception for the image that always becomes LCP. Just blanket lazy loading, sprinkled like salt.

The real numbers: before and after the mess

This is the part I actually care about when reading posts like this, so here is mine.

Before the redesign

  • Homepage LCP (CrUX, mobile, 28-day median): 2.2s
  • Blog post template LCP (mobile): 2.3s
  • Good LCP share on mobile: around 85% to 88%

My LCP element was usually a heading or a small thumbnail. Nothing dramatic. Images were small and loaded early.

After the redesign + lazy everything

  • Homepage LCP (mobile): 3.5s
  • Blog post template LCP (mobile): 3.4s
  • Good LCP share on mobile dropped to around 58% to 62%

That is the moment you stop feeling clever and start reading HAR files.

After the fix

  • Homepage LCP (mobile): 2.3s to 2.5s
  • Blog post template LCP (mobile): 2.4s to 2.6s
  • Good LCP share on mobile recovered to around 86% to 90%

I did not remove lazy loading. I just stopped being stupid with it.

How lazy loading actually interacts with LCP

Largest Contentful Paint is brutally simple. Chrome picks the largest visual element in the viewport and measures when it is fully rendered.

On my pages that element was almost always the hero image. By putting loading="lazy" on it, I told the browser: “Please wait until you think this is near the viewport before fetching it.”

So the sequence looked like this, based on WebPageTest and performance panel traces:

  • HTML downloaded
  • CSS downloaded and applied
  • Hero image discovered, but flagged as lazy
  • Intersection logic kicked in late on some devices
  • Actual image request started hundreds of milliseconds later

That extra gap was exactly the regression in my LCP. I had shifted the LCP asset from “first in line” to “whenever you get around to it”.

The three specific mistakes I made

This was not one bug. It was a cluster.

1. Lazy loading above-the-fold images

This is the obvious one. You read one article from 2019 that says “add lazy to all images” and you turn off your brain.

My hero image was always inside the initial viewport on mobile. That image should behave like a critical asset, not a nice-to-have below-the-fold picture of a cat.

Once I removed loading="lazy" from that hero, my lab tests showed LCP dropping from ~3.2s back to ~2.5s. Real users took a bit longer to reflect it, but the trend matched.

2. No explicit sizes, so layout shifted

On top of lazy loading, I also skipped proper width and height. Good combination, right.

<img src="/images/post-hero.jpg" 
     alt="Post hero" 
     loading="lazy" />

The browser had no idea how much space to reserve. So the layout jumped once the hero image arrived. That made CLS worse, and it also gave the browser less confidence about when layout was stable enough to call LCP.

Once I added sizing:

<img src="/images/post-hero.jpg" 
     alt="Post hero" 
     width="1200" 
     height="630" />

LCP stabilized. CLS went down from around 0.13 to around 0.02 on those pages. That matters for Core Web Vitals overall, not just for the LCP number.

3. Treating all images like they are equal

My first pass did not distinguish between hero, inline illustration, code screenshot, or footer logo. Same attribute on everything.

Realistically I had three types of images:

  • Critical above-the-fold (hero, key visual next to title)
  • Near-fold but not LCP (first inline image, small icons)
  • Deep content (later inline screenshots, bottom of article)

Those three need different treatment. My markup did not reflect that at all.

The fixed strategy: simple rules that actually work

I am not interested in clever heuristics here. I want rules I can follow at 1 a.m. without thinking too hard.

Rule 1: Never lazy load the LCP candidate

On my pages, the likely LCP is almost always the hero image. So rule one is simple.

<img src="/images/post-hero.jpg" 
     alt="Post hero" 
     width="1200" 
     height="630" 
     fetchpriority="high" />

No lazy attribute at all. I also set fetchpriority="high" to hint that this thing should start downloading early. That bumped the image fetch up in the waterfall by about 200 to 300 ms on throttled mobile tests.

Result for real users:

  • Mobile LCP on blog posts moved from ~3.4s to ~2.6s in CrUX within a release cycle.

Rule 2: Lazy load anything that starts fully below the fold

If an image is clearly below the fold on mobile, I do not feel bad about lazy loading it.

<img src="/images/deep-screenshot.png" 
     alt="Deep in the article" 
     loading="lazy" 
     width="800" 
     height="450" />

That includes later inline screenshots and section thumbnails further down.

On my pages, this dropped total image bytes on initial load by 40 to 60 percent, depending on the article. More importantly, it did not touch LCP at all, because these images never become the largest element in the initial viewport.

Rule 3: Be careful with near-fold images

The tricky ones are images near the fold. On a tall screen they are above the fold, on a smaller device they are not.

I experimented with laziness on these and saw some nasty LCP spikes for certain viewports. In the end I went conservative.

If an image appears:

  • in the first section after the hero, and
  • wide enough that it can realistically become LCP on smaller screens

Then I treat it like a critical image and do not lazy load it. In HTML that looks like a normal image tag with proper sizing and fetchpriority="auto" (or just omitted).

That choice stabilized my LCP distribution. Worst-case LCP outliers on mobile dropped from over 6s down to around 3.8s for slower connections.

How I validated the fixes

I do not trust local Lighthouse scores since this mess. They are fine for rough direction, but my regressions did not show there until it was already in production.

Here is what I actually used.

1. WebPageTest: see the waterfall

I set up tests for a couple of representative pages. Throttled mobile, 4G, real browser. I wanted to see:

  • When the HTML finished
  • When CSS finished
  • When the hero image request started
  • When the hero image finished

Before the fix, the hero request sat awkwardly late in the timeline. Sometimes it started more than a second after the initial HTML. After I removed lazy loading and added fetchpriority="high", that request started almost immediately after CSS.

2. Chrome DevTools performance panel: confirm LCP element

I recorded a couple of traces on mobile emulation and inspected the “Largest Contentful Paint” event.

During the broken version, LCP was usually the hero image but with a timestamp around 3.1s to 3.4s. After the fix, the same element had timestamps around 1.9s to 2.3s under similar conditions.

The key was seeing the correlation between the start of the image request and the LCP timestamp. You want those close together, not separated by half a second of nothing.

3. CrUX and Search Console: real user confirmation

The real test was Chrome UX Report and Search Console’s Core Web Vitals report. Those lag behind by a few days, so I had to wait.

The pattern looked like this:

  • Week 0: shipped redesign with aggressive lazy loading
  • Week 1: “Good” LCP share on mobile dropped from ~88% to ~60%
  • Week 2: shipped fix (no lazy on hero, sizes, fetchpriority)
  • Week 3: “Good” LCP share on mobile climbed back to ~86%

That is the part that matters for SEO and for users actually feeling the speed.

The mental model I use now

I treat image loading less like a generic optimization and more like traffic control.

  • Fast lane: hero and early large visuals. No lazy. Sometimes fetchpriority="high".
  • Middle lane: near-fold images that could become LCP on some devices. Usually not lazy, conservative approach.
  • Slow lane: clearly below-the-fold images. loading="lazy", full width and height, no guilt.

That model kept my LCP honest while still saving bandwidth further down the page.

If you want a quick checklist

If you are skimming this for the parts that matter, here is what actually moved my metrics.

  • Remove loading="lazy" from your hero or any likely LCP element.
  • Add explicit width and height on every image.
  • Use fetchpriority="high" on your main hero image.
  • Lazy load only images that are safely below the fold on most devices.
  • Validate with WebPageTest and real CrUX data, not just local Lighthouse.

This is not fancy. It is boring markup. But boring markup got me from a broken 3.5s LCP back into the comfortable 2.4s-ish zone on mobile.

If your Core Web Vitals suddenly look worse right after you “optimize” images, check your lazy loading first. I learned that lesson the hard way, so you maybe do not have to.

Subscribe to my newsletter

Subscribe to my newsletter to get the latest updates and news

Member discussion