What this usually means
Slow LCP usually means the largest element (image, heading, hero block) is delayed by one of three things: a slow server response (TTFB > 200ms), render-blocking resources (CSS/JS) that delay first render, or the LCP resource itself (usually an image) taking too long to load. Non-obvious causes include lazy-loading the hero image, unoptimized images, client-side rendering without preloading, or third-party scripts blocking the main thread.
The first ten minutes — establish facts before touching code.
- 1Run `lighthouse --output=json --chrome-flags='--headless' https://example.com` and extract `lcp` from `audits['largest-contentful-paint'].numericValue`
- 2Open DevTools Performance tab, record a load, and find the LCP element in the Timings section; note its URL and size
- 3Check TTFB: `curl -o /dev/null -s -w 'TTFB: %{time_starttransfer}s\n' https://example.com` — target < 0.2s
- 4Inspect the LCP element's network request: was it preloaded? Is it lazy-loaded? Check `loading=lazy` attribute
- 5Verify if render-blocking resources exist: `grep -oP 'href="[^"]+\.css"' index.html` and see if they are critical
- 6Simulate 3G throttling in DevTools: Network tab -> Disable cache, set throttling to Slow 3G, reload, measure LCP
The specific files, logs, configs, and dashboards that usually own this bug.
- searchChrome DevTools -> Performance -> Timings (LCP marker)
- searchChrome DevTools -> Network -> find the LCP image request (use filter `largest-contentful-paint` or inspect timing)
- searchPageSpeed Insights report (field data vs lab data)
- searchWeb Vitals extension (chrome://web-vitals/)
- searchServer logs: `tail -f /var/log/nginx/access.log` for response times per request
- searchLighthouse report JSON: `lhr.audits['largest-contentful-paint'].details.items[]`
- searchCrUX API: `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=API_KEY` with URL
Practical causes, not theory. These are the things you will actually find.
- warningTTFB > 200ms due to slow backend, database queries, or server location
- warningRender-blocking CSS/JS that delays first paint (e.g., large external CSS not inlined)
- warningLCP image not preloaded; browser discovers it late in the HTML
- warningLCP image lazy-loaded with `loading=lazy` (should be `eager` or preloaded)
- warningImage dimensions too large (e.g., 5000x3000 px hero image compressed poorly)
- warningClient-side rendering (CSR) where the LCP element is injected after JS executes
- warningThird-party scripts (analytics, ads) blocking main thread during load
Concrete fix directions. Pick the one that matches your root cause.
- buildImprove TTFB: add caching, upgrade hosting, use CDN, optimize database queries
- buildInline critical CSS and defer non-critical CSS (`<link rel='preload' ... as='style'>`)
- buildAdd `<link rel='preload' as='image' href='hero.jpg'>` for the LCP image
- buildRemove `loading=lazy` from above-the-fold images; set `fetchpriority='high'`
- buildCompress and resize images: use WebP/AVIF, serve responsive sizes via `srcset`
- buildFor CSR: server-side render (SSR) the LCP element or use static generation
- buildDefer or async third-party scripts, or load them after onload
A fix you cannot prove is a guess. Close the loop.
- verifiedRe-run Lighthouse and confirm LCP is under 2.5s (ideally < 1.5s)
- verifiedCheck CrUX data after 28 days to see real-user improvement
- verifiedUse Web Vitals extension while browsing the page; LCP should be green
- verifiedRun `curl` TTFB test again to verify server response improvement
- verifiedTest with 3G throttling in DevTools; LCP should drop significantly
- verifiedMonitor RUM (Real User Monitoring) data from tools like SpeedCurve or Datadog
Things that make this bug worse or harder to find.
- warningPreloading every image; preload only the LCP element to avoid bandwidth contention
- warningUsing `loading=lazy` on hero images; it delays LCP intentionally
- warningOnly measuring in lab conditions; real-user LCP often differs due to network and device
- warningIgnoring TTFB; LCP cannot improve if server is slow
- warningCompressing images without resizing; a 2MB image compressed to 500KB is still too large
- warningAdding too many preloads that compete for bandwidth; prioritize LCP resource
Hero Image Lazy-Loaded by Accident
Timeline
- 09:00Alert: LCP for product page spikes from 1.8s to 4.2s in CrUX
- 09:15Run Lighthouse on staging: LCP 4.5s, TTFB 0.3s (fine)
- 09:30DevTools Performance shows LCP element is hero image, starts loading at 2.5s
- 09:45Check network waterfall: hero image request appears late, after several JS bundles
- 10:00Inspect HTML: `<img loading='lazy' src='hero.jpg' />` — but is above the fold!
- 10:10Change to `loading='eager'` and add preload link in head
- 10:20Redeploy, re-run Lighthouse: LCP drops to 1.2s
- 10:30Monitor for 24 hours; CrUX data starts improving
We had a Next.js product page with a large hero image. The LCP was always around 1.8s, which was acceptable. Then a new developer added `loading='lazy'` to all images as a 'performance improvement' without checking if they were above the fold. The hero image, being the LCP element, was now lazy-loaded. The browser didn't start fetching it until the user scrolled near it, but since it's the hero, it's immediately visible. The result: LCP jumped to 4.2s.
I caught this by opening the Performance tab and seeing the LCP marker appear late. The network waterfall showed the image request starting after 2.5s, way after the page was visible. I searched the codebase for `loading=lazy` and found the hero image. Changed it to `loading='eager'` and added a preload link in the `<head>`. The LCP dropped to 1.2s.
The fix was simple, but the root cause was a lack of understanding of lazy loading. We added a code review rule: never use `loading=lazy` on above-the-fold content. We also added a CI check that warns if the LCP element in a Lighthouse report is lazy-loaded.
Root cause
Hero image had `loading='lazy'` attribute, causing the browser to delay fetching it until near viewport, even though it was above the fold.
The fix
Removed `loading='lazy'` from the hero image and added `<link rel='preload' as='image' href='/hero.jpg'>` in the document head.
The lesson
Never assume lazy loading is safe for above-the-fold content. Always verify the LCP element's loading behavior in DevTools.
Open DevTools, go to the Performance tab, start recording, and reload the page. After the load, look for the 'Timings' section. You'll see a marker labeled 'Largest Contentful Paint'. Click on it; the bottom panel shows the element's details, including its tag, source URL, and size.
Alternatively, in the Console, run `new PerformanceObserver((list) => { list.getEntries().forEach(e => console.log(e.element, e.url, e.size)); }).observe({type: 'largest-contentful-paint', buffered: true})`. This prints the LCP element and its attributes.
TTFB (Time to First Byte) is the time from the request to the first byte of the response. If TTFB is over 200ms, LCP is likely to be poor because the browser cannot start rendering until it receives the HTML. Use `curl -w '@%{time_starttransfer}' -o /dev/null -s https://example.com` to measure TTFB. Target under 200ms.
Common causes of high TTFB: slow server-side rendering, database queries, lack of CDN, or geographic distance. Fix by optimizing backend, using a CDN, or adding server-side caching (e.g., Redis, Varnish).
If the LCP element is an image, add `<link rel='preload' as='image' href='/path/to/hero.jpg'>` in the `<head>`. This tells the browser to fetch it early. For fonts or CSS, use `as='font'` or `as='style'`. Be careful not to preload too many resources; only preload the LCP element and maybe critical CSS.
Verify preload is working: in DevTools Network tab, filter by `preload` and ensure the request starts early in the waterfall. If the LCP element is still delayed, check if preload is blocked by other resources (e.g., a big CSS file that takes long).
In React, Vue, or Angular apps that render on the client, the LCP element may not be visible until JavaScript executes. This adds a significant delay. The best fix is server-side rendering (SSR) the LCP element. For Next.js, use `getServerSideProps` or static generation. For other frameworks, consider using a prerendering service.
If SSR is not feasible, use a loading placeholder that matches the LCP element's size to prevent layout shift, and ensure the critical JS is tiny and loaded synchronously. But ideally, render the LCP HTML on the server.
Frequently asked questions
What if my LCP element is a text block instead of an image?
Text-based LCP is usually fast if fonts load quickly. Ensure fonts are preloaded and use `font-display: swap` to avoid invisible text. Also check that the text is not blocked by render-blocking CSS.
Can LCP be affected by browser extensions?
Yes, extensions can inject scripts or block resources. But for debugging, use incognito or a fresh Chrome profile. Real-user LCP should be measured with RUM that captures actual user conditions.
Why does Lighthouse show different LCP than CrUX?
Lighthouse runs in a simulated environment with a fixed network throttling, while CrUX collects real-user data. Lab data can be optimistic or pessimistic. Always check field data from CrUX for the real picture.
How do I find the LCP element from the Chrome UX Report?
CrUX does not expose the element itself. You need to use RUM tools like Web Vitals library or SpeedCurve to capture the element's details. Lighthouse and DevTools are the best for lab debugging.
Is it okay to use `fetchpriority='high'` on the LCP image?
Yes, since Chrome 101, you can add `fetchpriority='high'` to the LCP image to hint the browser to load it earlier. Combine with preload for best results, but avoid overusing it.