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:
<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
- Make the LCP image a real
<img>in the initial HTML. - Remove
loading="lazy"from it; addfetchpriority="high". - Give it honest
srcsetandsizes. - Preload it only if steps 1 to 3 cannot be done (a background you cannot change).
- Lazy-load everything below the fold and de-prioritise the small stuff above it.
- 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.