Speed Lab: lazy-loading the main image

A controlled test of lazy loading on the main image: five variants, nine Lighthouse runs each, medians, ranges and the moment the image request starts.

IntermediateHow-to11 min readUpdated October 30, 2026

In this guide
  1. The question
  2. How I tested it
    1. The five variants
  3. Results
  4. What the timeline shows
    1. Why the lazy request is late
  5. What Lighthouse said
  6. The same test on a shaped network
  7. What others have found
  8. What this test does not show
  9. What to do
  10. Run it yourself
  11. Mistakes to avoid
  12. Check your own page
  13. Sources

The question

Lazy loading is a good idea for images that sit below the first screen. They are not needed yet, so the browser can leave them for later and keep the network free. The trouble starts when the rule is applied to every image, including the one at the very top. Many themes, plugins and builders do exactly that. The 2025 Web Almanac found that about 16% to 17% of pages lazy-load their Largest Contentful Paint image: about 10% of mobile pages with the native loading="lazy" attribute, and about 6% with a script that hides the address in data-src.

Everyone says this hurts. I wanted to see how much, and why. The test below changes one thing at a time and measures it several times. For the theory, see lazy loading and Largest Contentful Paint.

How I tested it

Everything ran on one machine in this session. The full method, with the settings and the rules for reading the tables, is in how the Speed Lab tests are run. In short:

The five variants

<!-- a. default -->
<img class="hero" src="hero.jpg" width="1200" height="675" alt="Spring collection">

<!-- b. explicitly eager -->
<img class="hero" src="hero.jpg" width="1200" height="675" alt="Spring collection" loading="eager">

<!-- c. native lazy loading -->
<img class="hero" src="hero.jpg" width="1200" height="675" alt="Spring collection" loading="lazy">

<!-- d. native lazy loading with a high priority -->
<img class="hero" src="hero.jpg" width="1200" height="675" alt="Spring collection" loading="lazy" fetchpriority="high">

<!-- e. a script loader: the address is in data-src, and an IntersectionObserver in app.js copies it to src -->
<img class="hero" data-src="hero.jpg" width="1200" height="675" alt="Spring collection">

Results

VariantLCP, median (lowest to highest)Compared with defaultFCP, median
a. Default2,491 ms (2,475 to 2,508)1,216 ms
b. loading="eager"2,490 ms (2,477 to 2,504)1 ms sooner1,217 ms
c. loading="lazy"2,802 ms (2,789 to 2,843)311 ms later1,214 ms
d. Lazy and fetchpriority="high"2,799 ms (2,795 to 2,824)308 ms later1,209 ms
e. Script loader with data-src3,102 ms (3,077 to 3,118)611 ms later1,215 ms

Four things stand out.

  1. Native lazy loading cost about 310 ms. The ranges of a and c do not overlap: the slowest default run was 2,508 ms and the fastest lazy run was 2,789 ms.
  2. `loading="eager"` is the same as the default. It is the default, so this is a check that the harness is steady.
  3. `fetchpriority="high"` did not rescue the lazy image. Variants c and d are within the noise of each other.
  4. The script loader was twice as bad as the native attribute.

First Contentful Paint did not move, because the page paints its header and text long before the image. Total Blocking Time and Cumulative Layout Shift were 0 in every run, since the image has its size set. Only the main image's arrival changed.

What the timeline shows

The Lighthouse report lists when each request started and ended. These are the medians for the hero image across the nine runs, in milliseconds from the first request, next to the times when its neighbors finished.

VariantHero requestedHero finishedStyle sheet finishedScript finished
a. Default6072,4661,1801,727
b. loading="eager"6132,4651,1801,727
c. loading="lazy"1,2042,7821,1811,459
d. Lazy and high priority1,2012,7811,1771,454
e. Script loader1,4943,0761,1831,461

In the default page the hero is requested at 607 ms, together with the style sheet and the script, right after the HTML arrives. In the lazy page it waits until 1,204 ms, just after the style sheet has finished. In the script version it waits until 1,494 ms, just after the script has run.

web.dev's guide to Largest Contentful Paint gives a rule of thumb that fits this: the main resource should start loading at the same time as the first resource the page loads. Variants a and b meet it. Variants c, d and e miss it by 600 to 890 ms.

Why the lazy request is late

web.dev explains the native case: lazy loading means the resource is not loaded until layout confirms that the image is in the viewport. The browser cannot lay out the page before it has the style sheet, and the timeline fits that: the lazy request starts 23 ms after the style sheet finishes. My reading is that priority cannot help, because the delay comes before the priority is used. That is why variant d looks like variant c.

The script version waits for something else. The browser cannot see the address, because it is in data-src, so nothing can start the download until the script has run and copied it across.

One more detail: the lazy request started about 600 ms later than the normal one, yet finished only about 310 ms later. The normal image took 1,859 ms to arrive, because it shared the connection with the style sheet and the script. The lazy image took 1,578 ms, because by then the style sheet was done and the script was nearly done. The delay is real, but the freed bandwidth hides some of it. A page with more to download early would show a different gap.

What Lighthouse said

Lighthouse 13.5 has an insight called "LCP request discovery". It shows three yes-or-no checks, and its score is 1 only when all three pass. Here is what it reported for my pages.

Variantfetchpriority="high" appliedDiscoverable in the first HTMLNot lazy-loaded
a. DefaultNoYesYes
c. loading="lazy"NoYesNo
d. Lazy and high priorityYesYesNo
e. Script loaderNoNoYes

It marked each of the three problem pages correctly. It also gave the default page a failing score, because fetchpriority="high" was missing. Yet on this page the fastest variants were the ones without the attribute, and adding it made no difference (the discovery lab tests that on its own). Read the third check as a prompt to look, not as proof of a problem.

The same test on a shaped network

The usual throttling charges the same delay for every request, whatever the real round trip would be, so a request that starts late is penalized more than it would be on a real connection. Here are the five variants again on the shaped network described in the test of many small files, five runs each.

VariantUsual throttlingShaped network
a. Default2,491 ms1,835 ms (1,811 to 1,844)
b. loading="eager"2,490 ms1,828 ms (1,821 to 1,833)
c. loading="lazy"2,802 ms1,834 ms (1,816 to 1,862)
d. Lazy and fetchpriority="high"2,799 ms1,837 ms (1,817 to 1,852)
e. Script loader with data-src3,102 ms2,109 ms (2,085 to 2,125)

On the shaped network loading="lazy" cost nothing: 1,834 ms against 1,835 ms. The image was still requested late, at 546 ms against 370 ms, but it finished at 1,808 ms against 1,817 ms. My reading is that the page is limited by its link. The image is 189 KB on a link of 1.6 Mbit/s and is the last thing to arrive, and the link was busy with the script while the request waited. A late request costs time only when the link would otherwise sit idle, or when other traffic ends up ahead of it. The script loader still lost 274 ms, because the address of the image appears only after the script has run, at about 1,050 ms.

So the 311 ms in the first table belongs to the lab's usual throttling. The cause, a request that starts late, belongs to every network, and whether it costs time depends on the page. The field data below show a cost on real pages.

What others have found

My test is small, so it helps to set it beside larger ones. In 2021 the Chrome team at web.dev compared real pages. Among the sites they sampled, the median page that used native lazy loading had a 75th percentile LCP of 3,546 ms, against 2,922 ms for the median page that did not. Among WordPress pages alone, the figures were 3,768 ms and 3,495 ms. They point out that this is correlation and does not prove that lazy loading caused the slowdown.

They then ran a lab test on a demo WordPress site, nine runs for each combination. Turning lazy loading off improved LCP clearly on archive pages, and made no meaningful difference on single pages, where the change was smaller than one standard deviation. Their advice was to load the images inside the first screen eagerly and to lazy-load the rest, which can improve the Web Vitals while loading fewer bytes. That agrees with what the timeline shows here: the cost falls on the image that decides the LCP. These are 2021 results, and WordPress has since changed its defaults, as the WordPress image guide explains.

What this test does not show

What to do

  1. Find out which image is your LCP element. The Lighthouse report names it.
  2. Look at its markup. In the page source, check for loading="lazy", for data-src or data-lazy in place of src, and for a plugin class such as lazyload.
  3. Take lazy loading off that image, and off any image that is on the first screen on a phone or a desktop. Keep it for the images below.
  4. Check what your tools do. WordPress leaves the first three images in the content alone by default, as described in WordPress image optimization, but a plugin or a page builder can lazy-load all of them.
  5. Run Lighthouse again and read the "LCP request discovery" insight. Make sure the image is in the HTML, with its address in src.

See the image attributes cheat sheet for the full set of attributes, and how to fix a slow LCP for the other causes.

Run it yourself

Make one folder for each variant. Each holds an index.html, a style.css, an app.js and a hero.jpg of about 190 KB. Install Lighthouse with npm install lighthouse (it needs Node 22 or later), then save this file as lab-mini.mjs next to the folders and run node lab-mini.mjs ./variants 9. It serves the folders, runs Lighthouse on each in turn and prints the median LCP with the lowest and highest values.

// lab-mini.mjs: node lab-mini.mjs <folder holding one folder per variant> [runs]
import { createServer } from 'node:http';
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, extname, resolve } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const root = resolve(process.argv[2]);
const runs = Number(process.argv[3] || 9);
const types = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.jpg': 'image/jpeg' };
const variants = readdirSync(root).filter((d) => statSync(join(root, d)).isDirectory()).sort();

const server = createServer((req, res) => {
  let path = decodeURIComponent(new URL(req.url, 'http://x').pathname);
  if (path.endsWith('/')) path += 'index.html';
  const file = join(root, path);
  if (!file.startsWith(root) || !existsSync(file)) { res.writeHead(404); return res.end(); }
  const body = readFileSync(file);
  res.writeHead(200, { 'content-type': types[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store', 'content-length': body.length });
  res.end(body);
});
await new Promise((done) => server.listen(0, '127.0.0.1', done));
const base = `http://127.0.0.1:${server.address().port}`;

const results = Object.fromEntries(variants.map((v) => [v, []]));
for (let i = 0; i < runs; i++) {
  for (const v of variants) {
    // The server lives in this process, so use the asynchronous execFile. A blocking call would freeze it.
    const { stdout } = await promisify(execFile)('npx', ['lighthouse', `${base}/${v}/`, '--only-categories=performance',
      '--throttling-method=devtools', '--output=json', '--quiet', '--chrome-flags=--headless=new'], { maxBuffer: 1 << 28 });
    results[v].push(JSON.parse(stdout).audits['largest-contentful-paint'].numericValue);
  }
}
server.close();

const median = (xs) => [...xs].sort((a, b) => a - b)[xs.length >> 1];
for (const v of variants) {
  const xs = results[v];
  console.log(v.padEnd(22), `${Math.round(median(xs))} ms`, `(${Math.round(Math.min(...xs))} to ${Math.round(Math.max(...xs))})`);
}

In a three-run check on my machine, this script printed 2,525 ms for the default page and 2,848 ms for the lazy one, within about 40 ms of the figures above, so expect small differences between setups. The loop takes the variants in turns, so a slow minute on your machine hits every variant alike. Use an odd number of runs, so the median is a real run. If you run it as the root user inside a container, add --no-sandbox to the Chrome flags. Do not use the machine for anything else while it runs, because the processor limit is relative to the speed of the host.

Mistakes to avoid

Check your own page

Run the free audit and find the Largest Contentful Paint element in the report. If it is an image, look at its markup for loading="lazy" or data-src. If either is there, remove it, run the audit again and compare the Largest Contentful Paint.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Speed optimization · Book a call

Related guides

All guides →