Core Web Vitals

LCP Images the Browser Cannot Discover, and the Preload Fix

Why some hero images start loading a second late: CSS backgrounds, JavaScript-inserted images, lazy loading on the wrong element. See the delay and fix it.

5 min read
Core Web VitalsLCPPerformance
944 words5 min read

Two kinds of slow image

An LCP image can be slow because it is big: too many bytes for the connection. Or it can be slow because it starts late: the browser did not know it needed the image until well after it could have begun downloading. The second kind is invisible to "compress your images" advice and it is the more common cause of LCP over 2.5 seconds on sites whose images are already reasonably sized.

The browser's preload scanner reads the HTML as it arrives and starts fetching every <img src>, <link rel=stylesheet> and <script src> it sees, before the page is laid out or any script runs. An image the scanner can see starts downloading within milliseconds of the HTML arriving. An image it cannot see waits for whatever eventually reveals it.

Seeing the delay

In the DevTools Performance panel, record a load. Find the LCP marker in the Timings track. Click it: the summary shows the LCP element and, for images, a breakdown into time to first byte, resource load delay, resource load duration, and element render delay.

Resource load delay is the gap between the HTML arriving and the image request starting. Under 100 milliseconds means the scanner found it. Several hundred milliseconds or more means something hid it. That number is the target of everything below.

Pattern 1: CSS background-image

The hero is a <div> with background-image: url(...) in a stylesheet or inline style. The browser cannot request the image until the CSS is downloaded and parsed and the element is laid out and matched to the rule. On a typical site that is 500 to 1500 milliseconds after the HTML.

Backgrounds also have no srcset, so the phone downloads the desktop image.

Fix, best: render it as an <img> with srcset, sizes, fetchpriority="high", positioned with object-fit: cover to get the same visual. Every framework's image component does this.

Fix, if the background must stay: preload it in the HTML head:

HTML
<link rel="preload" as="image" href="/hero-1200.jpg"
      imagesrcset="/hero-600.jpg 600w, /hero-1200.jpg 1200w, /hero-1800.jpg 1800w"
      imagesizes="100vw" fetchpriority="high">

The imagesrcset form lets the preload pick the right size, which a plain href preload does not.

Pattern 2: image inserted by JavaScript

Sliders, carousels, hero components rendered client-side, lazy-load libraries that swap data-src into src. The image URL is not in the HTML the scanner reads; it appears when a script runs, which is after the script downloads, parses and executes.

Fix: put the first slide's image in the HTML as a real <img>, with the slider enhancing it after load. Remove JavaScript lazy-loading libraries in favour of native loading="lazy" on below-fold images and nothing on the hero. If the hero is a client-rendered React component, render it on the server instead; a hero is the last thing that should be client-only.

Pattern 3: loading="lazy" on the LCP image

Native lazy loading tells the browser to wait until the image is near the viewport before fetching. The scanner skips it. For the hero, that means waiting for layout to confirm it is in view, which is exactly the delay lazy loading exists to create for below-fold images.

CMSs and frameworks add loading="lazy" automatically and often get the hero wrong. WordPress exempts the first content image but not template images; Next.js's Image lazy-loads by default unless priority is set.

Fix: no loading="lazy" on the LCP image, and fetchpriority="high" so it wins over other early requests.

Pattern 4: the image is behind a CSS or JS gate

The hero is inside a <picture> whose sources depend on a class a script adds, or inside a component that renders null until a font or theme loads, or in an @imported stylesheet. Each is a gate before the URL becomes visible.

Fix: find the gate in the trace (what ran just before the image request?) and remove it from the critical path. The hero's HTML should be static and complete in the initial response.

Pattern 5: the wrong image is prioritised

Everything above the fold is fetchpriority="high" and preloaded, so the hero competes with the logo, three icons and a background pattern for the first connection slots. Or a large below-fold image is not lazy and grabs bandwidth first.

Fix: exactly one image gets high priority on each page: the LCP element. Logos and icons are small and can wait a few milliseconds; below-fold images are lazy.

Pattern 6: discovered late because the HTML is late

Not strictly a discovery problem, but it looks like one: a long time to first byte means the scanner has nothing to read. A hero that starts 800 milliseconds after navigation because the server took 800 milliseconds to respond has a TTFB problem, not an image one. The trace breakdown separates these; check TTFB first.

The order of fixes

  1. Make the LCP image a real <img> in the initial HTML.
  2. Remove loading="lazy" from it; add fetchpriority="high".
  3. Give it honest srcset and sizes.
  4. Preload it only if steps 1 to 3 cannot be done (a background you cannot change).
  5. Lazy-load everything below the fold and de-prioritise the small stuff above it.
  6. Re-record and check resource load delay is now under 100 milliseconds.

Then, and only then, worry about the bytes.

Where this fits

Resource load delay is the first number we read in a Core Web Vitals audit when LCP is failing, because half the time the image is fine and the discovery is broken, and the fix is markup rather than compression. It is also why the hero on every site we build is a server-rendered <img> with priority, before anyone discusses image quality.

All writingHire me for this