loading="lazy" On Your Hero Image Waits For Your CSS. Same Page, Same Attribute: 16ms Slower In One Test, 428ms In The Next.
loading="lazy" on an above-the-fold image does not wait for a scroll, it waits for layout. Measured in Chromium, Firefox and WebKit: the lazy hero request starts right after the stylesheet lands, costing 16ms on one network profile and 428ms on another, why fetchpriority cannot rescue it, and why making everything eager is worse.
One Attribute, Two Very Different Bills
I built a boring page. A header, one big hero image, a heading, a line of copy, and eight more images far below the fold. One render-blocking stylesheet, one defer script. Then I served it from a little Node server that adds latency to every request and pushes all the bytes through one shared, throttled pipe, so it behaves the same way in Chromium, Firefox and WebKit.
First test: add loading="lazy" to the hero. Nothing else changes. In Chromium, Largest Contentful Paint went from 844ms to 860ms. Sixteen milliseconds. If you stopped there, you would conclude the "never lazy-load your LCP image" advice is folklore.
Second test: same page, same image, same attribute. The only difference is that the stylesheet now takes an extra 450ms to start arriving - roughly what you pay when your CSS lives on a different origin and the browser has to do DNS, TCP and TLS before the first byte. This time the lazy hero cost 428ms. LCP went from 848ms to 1,276ms. That is a 50% regression from one attribute that most people add because a linter, a CMS or an image component told them it was a performance best practice.
loading="lazy" on an above-the-fold image does not wait for the user to scroll. It waits for your CSS.
The rule "do not lazy-load the LCP image" is correct. But the explanation people usually attach to it - "the browser waits until the image scrolls into view" - is wrong, and the wrong explanation is why the rule keeps getting broken. The hero is already in view. The browser just does not know that yet, and finding out is the expensive part.
This piece is about what the browser is actually waiting for, why the cost swung from 16ms to 428ms on an identical page, why fetchpriority="high" does not rescue a lazy hero, and why the obvious overcorrection - making everything eager - was the slowest variant I tested. It is a companion to my piece on Long Animation Frames, which covers the other half of a slow page: a busy main thread.
The Browser Cannot Know Where Your Image Is Until It Has Done Layout
Here is what happens to a normal <img src="hero.jpg"> in the HTML. While the HTML parser is still blocked on the stylesheet in the head, a second, lighter parser - the preload scanner - races ahead through the raw markup looking for URLs. It finds hero.jpg and starts the request immediately. The image downloads in parallel with your CSS.
Now add loading="lazy". The HTML spec says a lazy image should only be fetched once it is close enough to the viewport, and leaves "close enough" to the browser. Chromium uses a distance of 1,250px on fast connections and 2,500px on slower ones (it was 3,000px and 4,000px until Chrome cut the thresholds in July 2020). Either way, the question "is this image within 1,250px of the viewport?" has a precondition: the browser has to know where the image is. That requires layout. Layout requires styles. Styles require the render-blocking stylesheet to have arrived and been parsed.
web.dev puts it in one line that I wish more image components quoted: "The browser can't lazy-load an image until it knows where the image should be on the page."
You do not have to take the diagram on trust. In every lazy run I recorded the moment the hero request started and the moment the stylesheet finished. In the cold-CSS profile in Chromium, the stylesheet finished at a median of 802ms and the lazy hero's request started at 813ms. Eleven milliseconds later. The eager hero's request had started at 160ms. Firefox and WebKit showed the same pattern: the lazy request starts right after the CSS, every time.
loading="lazy" on an in-viewport image turns your stylesheet's latency into your image's latency. Whatever your CSS costs, your hero now pays it first and then starts downloading.
How I Measured It, And What This Setup Is Not
I want to be specific about the setup, because "I measured it" means nothing without the conditions, and some of these results surprised me enough that I re-ran them.
- The page: a 56px header, a 1600×1067 JPEG hero (242KB) rendered full-width in a 1280×800 viewport, an
<h1>and a paragraph, a 3,000px spacer, then eight 900×600 images (about 80KB each). One 30KB render-blocking stylesheet in the head and a 160KBdeferscript. Every image haswidthandheightattributes. - The server: plain Node HTTP/1.1 on localhost. Every response waits a fixed latency before headers, then all bodies share one throttled pipe, split evenly between whatever is in flight.
Cache-Control: no-storeeverywhere. - Three network profiles: "fast" (150ms latency, about 8Mbps), "slow" (300ms, about 1.6Mbps) and "cold CSS" (the fast profile, plus 450ms extra before the stylesheet only).
- Seven variants of the hero: eager (no attribute),
loading="lazy", eager withfetchpriority="high", lazy withfetchpriority="high", a classic JavaScript lazy-loader (data-srcplus an IntersectionObserver in the deferred script), a CSSbackground-image, and "all eager", where the eight below-the-fold images lose theirloading="lazy"too. - The browsers: Playwright 1.59.1 driving Chromium 147, Firefox 148 and WebKit 26.4, headless, on an Apple Silicon Mac. A fresh browser context for every load, so nothing is cached. LCP comes from a buffered
PerformanceObserver; request timings from Resource Timing. I report medians, mostly of seven runs.
This is a lab, not field data. A shared pipe on localhost is a simplification of a real mobile network, HTTP/1.1 is not HTTP/2 or HTTP/3, and WebKit here is Playwright's build, not a shipping Safari. The absolute numbers will not match your site. The shape of the results - when lazy costs nothing, when it costs a lot, and why - is the part I am confident about, and it matches Google's own field data further down.
Why The Same Mistake Cost Only 16ms
The fast-profile result bothered me, so I looked at when things started and finished instead of just at LCP. The lazy hero did start late. Its request began at a median of 390ms, against 163ms for the eager one - 227ms later, waiting on the stylesheet exactly as described above. And yet it finished only about 15ms later, and LCP moved by 16ms.
The reason is the pipe. During those 227ms the network was not idle. The eager hero was sharing the connection with the stylesheet and the 160KB script, each getting a slice. The lazy hero skipped that crowd and then had the pipe mostly to itself once the CSS and most of the script were done. Total bytes were the same, the pipe was full the whole time, so everything finished at roughly the same moment.
This is also why you can find people online who "tested it" and saw no difference. On a fast desktop connection, with CSS from the same origin and a hero that is small relative to everything else on the page, a lazy hero really can be almost free. That is not evidence that it is fine. It is evidence that the test did not have an idle gap to expose.
The slow profile makes the same point from the other side. At 1.6Mbps the pipe is saturated for the whole load, and lazy against eager was a wash in both engines that finished the profile: 3,068ms vs 3,080ms in Chromium, 3,090ms vs 3,090ms in Firefox. When bandwidth is the bottleneck, the start time barely matters; the bytes just queue.
When The Pipe Goes Quiet, Every Millisecond Counts
Real pages have idle gaps all the time, and they are almost always in the head of the document: CSS on a separate CDN host, a fonts stylesheet from a third-party origin, a CSS file behind a redirect, a cold edge cache. Any of those means the browser spends a few hundred milliseconds waiting on a handshake or a slow first byte with nothing else to download.
That is what the cold-CSS profile simulates, and it is where the attribute shows its real price. Chromium medians, seven runs each:
The eager hero started at 160ms and was fully downloaded by 782ms - before the stylesheet even finished. When layout happened, the image was already there, so LCP landed at 848ms, just after the CSS. The lazy hero did not start until 813ms. It then needed its own full download time on top. That download time is the bill.
Notice what this means for "how bad is it on my site?" The cost is roughly min(time your CSS takes, time your hero takes). A big hero with slow CSS is the worst case, and it is exactly the case where you most care about LCP: marketing pages, product pages, article pages with a large header image. Exactly the pages a CMS or an image component is most likely to lazy-load by default.
A lazy hero does not show up as a slow image. It shows up as an image that started late, which is much harder to spot in a waterfall.
Making Everything Eager Was The Slowest Thing I Tested
The natural reaction to all of this is "fine, I will remove loading="lazy" everywhere". I tested that too. The "all eager" variant keeps the hero eager and also removes lazy loading from the eight images that sit 3,000px below the fold. Here is what happened to LCP:
| Profile (Chromium) | Eager hero, lazy rest | Lazy hero | Everything eager |
|---|---|---|---|
| Fast | 844ms | 860ms | 1,564ms |
| Cold CSS | 848ms | 1,276ms | 1,544ms |
| Slow | 3,080ms | 3,068ms | 6,724ms |
Everything-eager was worse than the lazy hero in every profile. The eight below-the-fold images are discovered by the preload scanner at the same moment as the hero, so they compete with it for the same pipe from the very first millisecond. In the slow profile that more than doubled LCP. Firefox showed the same thing: 6,741ms all-eager against 3,090ms for the normal page. WebKit was the exception in the cold-CSS profile, where it held every image back until about 500ms anyway, so all-eager (999ms) landed close to its plain eager hero (1,004ms) - still no better.
So lazy loading is not the villain. It is doing exactly its job for those eight images: keeping them out of the way of the one image the user is actually looking at. The mistake is only ever which images get it. Above the fold: eager. Below the fold: lazy. The hard part is that "above the fold" is a layout question, and your component usually does not know the answer.
When WordPress 5.5 started adding loading="lazy" to every image by default, Felix Arntz and Rick Viscomi looked at the result on web.dev. Across CrUX origins, pages using native lazy loading had a median LCP of 3,546ms against 2,922ms for pages that did not - correlation, not causation, as they say themselves. In their lab A/B test on a WordPress theme, simply not lazy-loading above-the-fold images improved LCP by 14% on desktop archive pages and 18% on mobile, without loading any extra image bytes. WordPress 5.9 then stopped lazy-loading the first content image.
Firefox And WebKit Agree On Lazy, And Disagree On Everything Else
The core finding held in all three engines. With cold CSS, a lazy hero's request waited for the stylesheet in Chromium, Firefox and WebKit, and adding fetchpriority="high" to a lazy image changed nothing in any of them. What surprised me was the eager column.
| Cold CSS, median LCP | Chromium 147 | Firefox 148 | WebKit 26.4 |
|---|---|---|---|
| Eager | 848ms | 1,253ms | 1,004ms |
| Eager + fetchpriority="high" | 854ms | 1,246ms | 836ms |
| loading="lazy" | 1,276ms | 1,273ms | 1,292ms |
| Lazy + fetchpriority="high" | 1,272ms | 1,283ms | 1,297ms |
| JS lazy-loader (data-src) | 1,276ms | 1,270ms | 1,290ms |
| CSS background-image | 1,264ms | 1,265ms | 1,289ms |
WebKit did not start a plain eager hero until about 500ms in, well after the preload scanner could have seen it. With fetchpriority="high" the request started at 164ms, same as Chromium, and LCP dropped from 1,004ms to 836ms. In the slow profile it was the same story: eager started at 951ms, eager with high priority at 319ms. So on WebKit, in this setup, fetchpriority="high" was the difference between an early hero and a late one.
Firefox started the eager hero at 164ms, as early as Chromium, but in most runs it finished much later, and the eager median ended up close to lazy. The runs were bimodal - the fastest eager run hit 801ms, most landed around 1,250ms - and high priority did not change that. I do not have an explanation I am confident in for why the download stretched out, so I am not going to invent one. What I can say is that eager was never slower than lazy in Firefox, and in its good runs it was 470ms faster.
In Chromium, fetchpriority="high" made no measurable difference on this page. That is not because the attribute is useless there. My page had one image competing with one script on HTTP/1.1; there was no crowd of same-priority images for the hero to jump ahead of. On a real page with logos, avatars and product thumbnails in the first viewport, the priority boost has something to do.
Priority decides the order of requests the browser has already decided to make. loading="lazy" decides whether to make the request yet. Until layout says the image is near the viewport, there is no request to prioritise. Lazy + high was within noise of plain lazy in all three engines.
The Hero Bugs That Do Not Say "lazy" Anywhere
Two variants in the table never use the loading attribute, and they behave just like it. Both are common, and both are harder to catch in review.
JavaScript lazy-loaders
The pre-2019 pattern: put the real URL in data-src, leave src empty or pointing at a placeholder, and let a script swap it in when an IntersectionObserver fires. The preload scanner cannot see a URL that is only in a data attribute, so nothing starts until the script has downloaded, run, and the observer has had its first callback - which needs layout too. In the fast profile that cost 152ms in Chromium (996ms against 844ms), more than native lazy, because the request now waits for a 160KB script rather than a 30KB stylesheet. With cold CSS it landed at the same 1,276ms as native lazy, since the CSS was the slower of the two. Many sites still ship a lazy-loading library from that era, often bundled inside a theme or a carousel, and it applies to every image it finds, hero included.
CSS background images
A hero done as background-image on a div is discovered only when the stylesheet has been parsed and the rule matched against an element. Same wait as lazy: in the cold-CSS profile the request started at 804ms, right behind the CSS at 802ms, and LCP was 1,264ms. LCP does count background images, so this shows up in your metric; it just does not show up as an <img> you can grep for.
<!-- In the head, so the preload scanner sees it immediately --> <link rel="preload" as="image" href="/hero-1600.jpg" imagesrcset="/hero-800.jpg 800w, /hero-1600.jpg 1600w" imagesizes="100vw" fetchpriority="high">
If the hero is a real <img> in server-rendered HTML, you do not need the preload - the scanner already finds it. Preload is for when the URL would otherwise be invisible to it. And if your preload uses imagesrcset, it has to match the image's srcset and sizes, or the browser downloads two different files.
Your Image Component Probably Chose Lazy For You
Most lazy heroes are not written by hand. They come from a default somewhere.
- next/image defaults to
loading="lazy". Next.js 16 deprecated the oldpriorityprop in favour ofpreload, and the current docs say that in most cases you should useloading="eager"orfetchPriority="high"instead ofpreload. If your hero is a<Image>with none of those props, it is lazy. - WordPress added
loading="lazy"to every image in 5.5, which is the change behind the web.dev numbers above. 5.9 stopped doing it for the first content image, 6.3 raised that to the first three and started addingfetchpriority="high"to the likely hero automatically. Themes and page builders that render the hero outside the main content, or plugins that apply their own JS lazy-loading, can still undo all of that. - Design-system image components are the one I would check first in any codebase. Someone adds
loading="lazy"as the default because it is a good default for a list of avatars, and two years later the same component renders the hero on the landing page.
The common thread: a component cannot know whether it is above the fold. Only the page does. So the decision has to be made where the page is composed - a prop like priority, preload or eager that the page sets on the one or two images it knows are on top - not inside the component.
import Image from "next/image"; export function Hero({ src, alt }: { src: string; alt: string }) { return ( <Image src={src} alt={alt} width={1600} height={1067} sizes="100vw" loading="eager" fetchPriority="high" /> ); }
Finding A Lazy Hero In Two Minutes
Lighthouse and the Performance panel in Chrome DevTools already check this. The LCP request discovery insight looks at three things for an image LCP: that lazy loading is not applied, that the request is discoverable in the initial HTML, and that fetchpriority="high" is set. The first two would have caught every slow variant in my table, including the JS loader and the background image.
Lab tools only see the viewport you test with, though, and the hero on a 390px phone is often a different element from the hero on a laptop. web.dev's suggestion was to log it from real users, which is a few lines:
new PerformanceObserver((list) => { const entries = list.getEntries(); const lcp = entries[entries.length - 1]; const el = lcp.element; if (!el) return; const problems = []; if (el.getAttribute("loading") === "lazy") problems.push("loading=lazy"); if (el.dataset.src || el.dataset.srcset) problems.push("js-lazy-loader"); if (el.tagName !== "IMG" && lcp.url) problems.push("css-background"); if (problems.length) { // Send to your RUM/analytics endpoint instead of the console console.warn("LCP image is discovered late:", problems, el, Math.round(lcp.startTime)); } }).observe({ type: "largest-contentful-paint", buffered: true });
It is crude, and data-src is only a convention, but it answers the useful question with real devices and real viewports: how often is the thing users wait for the thing we told the browser to deprioritise? Tag it with the route and viewport width and the answer usually points at one template.
Lazy Below, Eager Above, And Know Which Is Which
loading="lazy"on an in-viewport image does not wait for a scroll. It waits for layout, which waits for your render-blocking CSS. In every engine I tested, the lazy hero's request started right after the stylesheet finished.- The cost is the network idle time during that wait. Same page, same attribute: 16ms when the pipe was busy anyway, 428ms when the stylesheet sat behind a cold connection. A quick test on a fast connection can easily show "no difference".
fetchpriority="high"does not rescue a lazy image - there is no request to prioritise yet. On an eager hero it can matter a lot: in WebKit it moved the request from about 500ms to 164ms and LCP from 1,004ms to 836ms.- JS lazy-loaders and CSS background heroes have the same problem without the word "lazy" in the markup. Use a real
<img>in the HTML, or a<link rel="preload" as="image">when you cannot. - Do not overcorrect. In Chromium and Firefox, making every image eager was the slowest variant in every profile - up to 6.7 seconds against 3.1 in the slow one. Lazy loading is right for the images below the fold.
- Components cannot know where they render. Make the page pass an explicit "this is the hero" prop, check your image component's default, and log the LCP element's loading mode from real users.
The frustrating part is that lazy loading is a genuinely good feature, and the hero bug is almost always introduced by someone trying to make the page faster. The diff looks like an optimisation. The waterfall even looks fine - the image downloads at a normal speed. It just starts late, and "started late" is the easiest problem in performance work to stare at and not see.
Lazy loading is a promise that the user will not need this image yet. On the hero, that promise is false on every single page view.
Related essays
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- JavaScript · Browser Internals
JavaScript Reads "2026-10-06" As UTC And "2026-10-06T00:00" As Local Time. Most Off-By-One Date Bugs Start There.
new Date("2026-10-06") is UTC midnight, new Date("2026-10-06T00:00") is local midnight. Tested in Chromium, Firefox and WebKit across three time zones: where the day slips, why Indian teams rarely see it, "06-10-2026" becoming 10 June, DST arithmetic, and what Temporal fixes.
- Browser Internals · Performance
Chrome Stopped Running Your unload Handler. If Your Analytics Still Use It, Your Numbers Already Changed.
Since Chrome 154, unload handlers are off by default on every Chrome page load - they register without error and never run. Reproduced in Chromium: what it does to the Back button, which data quietly went missing during the seven-month rollout, and the pagehide pattern to use instead.