The LCP Image Starts Downloading at 1.5s and at Low Priority
This is the runbook for one specific failure: the hero image is correctly sized and modern-formatted, yet Largest Contentful Paint is stuck above 3s because the image request is discovered late or issued at Low priority. It sits within the broader workflow in image CDNs and fetchpriority and the Image & Media Optimization discipline; here we go straight from symptom to fix. The target is unambiguous: the LCP image request should start within ~100-200ms of the document's first byte at High priority, so LCP lands under 2.5s at field p75.
Rapid Diagnosis: A Three-Check DevTools Pass
Confirm you have this problem before applying fixes, because the same slow LCP can come from oversized bytes or a slow server, which these fixes will not touch.
- Confirm the LCP element. In the console run
performance.getEntriesByType('largest-contentful-paint').at(-1).elementand verify it is the hero<img>you expect. If it is a text block, stop — priority hints are the wrong tool. - Read the request start time. In the Network panel, throttle to Slow 4G, reload, and find the hero request. Note its Start time. If it begins later than ~300ms after the document response, you have a discovery/priority problem.
- Read the Priority column. Right-click the Network table header, enable Priority, and check the hero row.
Low(orLowthen a late boost toHigh) confirms the image is not being prioritized at parse time.
If the request starts early but is large, this is not your page — go optimize bytes via the CDN transforms in the parent guide instead. If it starts late at Low priority, continue.
One refinement makes the diagnosis airtight. The Network panel sometimes shows the hero priority as Low that later flips to High mid-load — Chrome's automatic boost firing after layout confirmed the image was in the viewport. That late boost is the symptom, not the cure: by the time it fires, the early connection window is already spent on scripts and fonts, so the request that should have started at 150ms started at 1.5s. Treat any image whose priority starts Low as unprioritized for LCP purposes, regardless of where it ends up. Cross-check with the Waterfall column: a long pale "queued/stalled" segment before the green download bar is the visual fingerprint of an image that was discovered but parked in the Low queue while higher-priority resources held the connection.
Root Cause Analysis
Four named mechanisms produce a late or low-priority LCP image. Identify which apply before fixing, because the fixes differ.
1. Default image priority is Low until layout proves visibility
Chrome fetches in-body <img> elements at Low priority by default and only boosts to High after layout confirms the image is within the initial viewport. For the LCP image that boost arrives too late — the connection has already been spent on scripts and fonts. The image is discoverable, but it is queued behind everything else during the critical early window.
2. The LCP image is lazy-loaded
A loading="lazy" attribute — often a component-library default applied to every image — defers the request until the image nears the viewport via IntersectionObserver. For an above-the-fold hero this guarantees a late fetch: the request does not even exist until after first layout. This is the single most common cause in framework codebases.
3. The preload scanner cannot see the image
If the hero is rendered by client-side JavaScript, set via a CSS background-image, or injected after hydration, the preload scanner never encounters its URL during the initial HTML parse. There is no early request to prioritize because there is no request at all until the JS runs or CSS resolves.
A second-order version of this cause is the streamed-<head> race: if the document streams and the hero's URL is not known when the head flushes, the preload is emitted late or with a placeholder URL, so even an "eager, high-priority" image is discovered only when its component finally renders. Confirm by viewing source (not the live DOM) — if the preload link is missing from the streamed HTML, the scanner never had it.
4. High priority is spread across many resources
Marking several images (or scripts) fetchpriority="high", or letting a heavy render-blocking script hold the connection, dilutes the queue so the real LCP image gains nothing. Priority is relative; everything-high is the same as nothing-high. The classic instance is a hero, a logo, and three carousel slides all carrying priority from a copy-pasted component prop — Chrome now has five High-priority images contending for the same connection, and the one that paints last gets no advantage. Audit the <head> for the count of preload links and the DOM for fetchpriority="high" occurrences; on a healthy page there is exactly one of each for imagery.
Step-by-Step Resolution
Apply these in order of impact. Re-measure after each — you may not need all four.
Fix 1: Set fetchpriority="high" on the LCP image
The highest-leverage change. It promotes the hero out of the default Low queue at parse time so the request is issued immediately.
<img
src="https://images.example.com/hero.jpg?w=800&auto=format,compress"
srcset="https://images.example.com/hero.jpg?w=400&auto=format,compress 400w,
https://images.example.com/hero.jpg?w=800&auto=format,compress 800w,
https://images.example.com/hero.jpg?w=1600&auto=format,compress 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="1600" height="900" alt="Quarterly dashboard"
fetchpriority="high" decoding="async">
<!-- trade-off: fetchpriority=high steals early-connection bandwidth from other
resources. Apply it to exactly ONE image; on a page whose LCP is text, this
hint wastes priority on a non-LCP element and can regress the real metric. -->
Expected outcome: moves the request out of the Low queue, typically pulling its start time forward by 400-900ms on a script-heavy page and cutting LCP load delay proportionally.
Fix 2: Remove lazy loading from the hero (make it eager)
If the hero carries loading="lazy", the request is deferred entirely. Force it eager so the request exists at parse time.
<img
src="https://images.example.com/hero.jpg?w=800&auto=format,compress"
width="1600" height="900" alt="Quarterly dashboard"
loading="eager" fetchpriority="high" decoding="async">
<!-- trade-off: eager loading every image inflates initial requests and steals
bandwidth from the hero. Keep loading="lazy" for everything BELOW the fold;
only the LCP image should be eager + high priority. -->
Expected outcome: eliminates the IntersectionObserver deferral, recovering the full lazy-load delay (commonly 500-1500ms) for an above-the-fold hero.
Fix 3: Preload the image when the scanner cannot find it
For JS-rendered or CSS background-image heroes, declare the resource in <head> so the scanner finds it before any script runs. Mirror imagesrcset/imagesizes to the <img> so the same candidate is preloaded and rendered.
<link rel="preload" as="image" fetchpriority="high"
href="https://images.example.com/hero.jpg?w=800&auto=format,compress"
imagesrcset="https://images.example.com/hero.jpg?w=400&auto=format,compress 400w,
https://images.example.com/hero.jpg?w=800&auto=format,compress 800w,
https://images.example.com/hero.jpg?w=1600&auto=format,compress 1600w"
imagesizes="(max-width: 600px) 100vw, 800px">
<!-- trade-off: a preload whose candidate differs from the rendered img causes a
double download of an unused file. Keep imagesrcset/imagesizes byte-identical
to the img, and preload ONLY the LCP image — extra preloads dilute its priority. -->
Expected outcome: makes the request discoverable at parse time instead of after hydration, removing the JS-execution gap (often 800-2000ms) from load delay.
Fix 4: Deprioritize competitors and unblock the connection
If the hero is high priority but still queues behind other work, lower the priority of below-the-fold images and defer non-critical scripts so the connection is free for the hero.
<!-- below-the-fold imagery yields the early-connection budget to the hero -->
<img src="https://images.example.com/card.jpg?w=400&auto=format,compress"
loading="lazy" fetchpriority="low" width="400" height="300" alt="Case study">
<script src="/analytics.js" defer fetchpriority="low"></script>
<!-- trade-off: marking too much as low can starve genuinely needed resources and
delay interactivity. Lower only what is provably below the fold or non-blocking;
do not blanket-demote scripts the above-the-fold UI depends on. -->
Expected outcome: reclaims connection bandwidth for the hero, shaving a further 100-400ms off load time when contention was the residual bottleneck.
Fix 5: Resolve the hero URL early in streamed responses
When the page is server-streamed and the preload is missing from the source HTML (root cause 3, streamed-head variant), the cure is to make the LCP image URL knowable before the shell renders so it can be flushed in the first chunk. Lift the identifier into the route's data layer rather than reading it deep inside a lazily-rendered component.
// Resolve the hero image in the route loader, flush its preload in the head
export async function loader({ params }) {
const post = await getPost(params.slug);
return { post, heroUrl: imageUrl(post.heroPath, { w: 800 }) };
}
// in the document head, emitted in the first streamed chunk:
// <link rel="preload" as="image" fetchpriority="high" href={data.heroUrl} ... />
// trade-off: hoisting the URL couples the route loader to a presentational
// detail. Only do this for the LCP image; pushing every image URL into the
// loader bloats the data payload and slows the very TTFB you depend on.
Expected outcome: the preload appears in the first streamed bytes, so the scanner issues the request before any component renders, removing the streaming-induced discovery gap (often 300-800ms).
Verification
Confirm the fix moved the metric, not just the markup.
- Network panel diff. Reload under Slow 4G. The hero row should now show High priority and a Start time within ~100-200ms of the document response, versus the
Low/ ~1.5s baseline. - LCP element + phase. Re-run the Performance trace; LCP should be under 2.5s, and the LCP breakdown's load-delay segment should shrink to near zero. Confirm
performance.getEntriesByType('largest-contentful-paint').at(-1)still points at the same image. - No double download. Search the Network panel for the hero filename — a
rel=preloadmismatch shows two requests. Bothimagesrcsetandimagesizesmust match the<img>exactly. - CI assertion. Lock the fix so a future redesign cannot silently remove the hint:
{
"ci": {
"assert": {
"assertions": {
"prioritize-lcp-image": ["error", { "minScore": 1 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
}
}
}
}
prioritize-lcp-image fails the build if the LCP image loses its fetchpriority/preload.
- Field check. Lab proves the mechanism; the p75 of your RUM LCP is what ships. Confirm the field metric drops before closing the issue, and segment by device class — the priority fix often helps low-end mobile far more than desktop, and an aggregate p75 can mask a large mobile win or a small desktop regression.
A common false summit is worth calling out: the lab number improves, you ship, and field LCP barely moves. The usual reason is that your lab profile (Fast 4G, mid-tier CPU) under-represents the slowest real devices, where the connection is worse and contention is heavier — exactly the conditions where late discovery hurts most. If that happens, re-run the trace under Slow 4G with a 4x CPU throttle, which approximates the p75 device, and confirm the request still starts early there. The other frequent culprit is that the LCP element differs in the field from the lab: on a viewport you did not test, a different image or a text block becomes the candidate, and your prioritized hero is not what the metric measures. Use the RUM beacon's recorded element identifier to verify the field LCP element is the one you optimized before declaring the fix complete.
Related
- Image CDNs and fetchpriority — the full workflow this runbook draws from, including the CDN transform layer.
- Image & Media Optimization — the wider media-weight strategy these fixes serve.
- How to fix LCP over 2.5 seconds on React apps — when the late hero is a symptom of a broader client-render bottleneck.
- Lazy loading images without hurting LCP — set the correct eager/lazy boundary so the hero never defers.
- Serving AVIF and WebP with fallbacks — shrink the prioritized bytes once discovery is fixed.