How to Fix LCP with Responsive Images
The hero image is the LCP element on the vast majority of content sites, and serving it without srcset, modern formats, or correct priority signals is one of the most impactful performance mistakes you can make. A single correctly-optimized hero can move LCP from 4+ seconds to under 2.5 seconds without changing a single line of application logic.
This guide covers everything from understanding why images dominate LCP to the exact HTML attributes, image format chains, and CDN transforms that produce the fastest possible load. Whether you are working with a static site, a React SPA, or a Next.js application, the core browser mechanics are identical. For framework-specific details, see the LCP in Next.js fix, the LCP in Astro fix, or the LCP in React fix.
<picture> element with srcset and sizes. Add fetchpriority="high" and loading="eager" to the hero only. Never lazy-load the LCP image. Consider an image CDN for automatic format conversion and dimension resizing.
Expected results
Following all steps in this guide typically produces these improvements on a content-heavy page with an unoptimized hero image:
Before
4.8 s
LCP (Poor) -- oversized JPEG hero, no srcset, no priority signal, loaded as a generic resource
After
1.9 s
LCP (Good) -- AVIF hero with srcset, fetchpriority=high, and CDN-side resizing
Why the hero image is usually the LCP element
Largest Contentful Paint measures the render time of the largest visible element in the viewport when the page first loads. The browser considers four categories of elements as LCP candidates: <img> elements, <video> elements using the poster attribute, elements with a background-image set via CSS url() (only when the element itself is the paint target, not a pseudo-element), and block-level elements containing text nodes. For a detailed breakdown of these thresholds and scoring, see the complete LCP guide.
In practice, hero images win the LCP race on almost every content site because they are physically large -- a 1200x600 pixel image covers more viewport pixels than any text heading or paragraph. The LCP algorithm computes the rendered area of each candidate (excluding any portion outside the viewport) and the image with the largest area wins.
Text can be the LCP element, but only when there is no large above-the-fold image. On landing pages, blog posts, e-commerce product pages, and news articles, the hero or featured image consistently dominates. This is confirmed by HTTP Archive data: across the top 1 million sites, image elements account for roughly 70% of all LCP elements.
A key implication: every byte you save on the hero image directly reduces LCP. There is no caching trick, no server optimization, and no JavaScript deferral that competes with simply sending fewer bytes for the most important resource on the page.
srcset and sizes done right
The srcset attribute lets you list multiple versions of an image at different widths, and the browser selects the best one for the current device. The key detail that trips up most developers is the difference between x descriptors and w descriptors.
The x descriptor specifies device pixel ratio only -- srcset="hero@2x.jpg 2x" means "use this file on 2x displays." This works for fixed-size images (icons, logos, thumbnails at a known CSS size) but is wrong for fluid layout images because it does not account for the actual rendered width in pixels.
The w descriptor specifies the intrinsic width of the file in pixels and works in tandem with the sizes attribute. The sizes attribute is a prediction from you to the browser: "here is how wide this image will be rendered at each breakpoint." The browser divides the available display width by device pixel ratio, compares it against the sizes hint, and picks the srcset entry that is just large enough to fill that layout slot at the device's pixel density.
<img
src="hero-800.jpg"
srcset="
hero-480.jpg 480w,
hero-800.jpg 800w,
hero-1200.jpg 1200w,
hero-1920.jpg 1920w
"
sizes="
(max-width: 600px) 100vw,
(max-width: 1200px) 80vw,
1200px
"
alt="A mountain landscape at sunrise"
width="1200"
height="630"
>
Read the sizes value from top to bottom like a CSS media query cascade: on viewports up to 600 px wide, the image is full-width (100vw); on viewports up to 1200 px, it is 80% of the viewport; on anything wider, it is fixed at 1200 px. A 390 px wide iPhone at 3x pixel ratio has an effective CSS width of 390 px, so the browser looks for the smallest srcset entry at or above 390 px -- it picks hero-480.jpg, saving a 1200 px download. Without sizes, the browser assumes 100vw at the largest possible screen and fetches the 1920 px file on every device.
Always include explicit width and height attributes on the <img> element. These allow the browser to reserve the correct amount of vertical space before the image loads, preventing layout shift -- a CLS fix that costs nothing extra.
The picture element and art direction
The <picture> element wraps multiple <source> elements and a fallback <img>. Browsers process the sources in order and use the first one they understand. This gives you two superpowers: format negotiation and art direction.
Format negotiation means listing AVIF first, WebP second, and JPEG last. A Chrome 122 browser picks AVIF; a Safari 15 browser skips to WebP; an older Edge picks the JPEG. Each browser downloads only the file it will use, so you never ship a 200 KB JPEG to a browser that can render a 70 KB AVIF of the same image.
Art direction means using different media queries to serve a differently-cropped image at different breakpoints -- a wide landscape crop for desktop, a tighter portrait crop for mobile. This is the primary reason to use <picture> over img srcset for hero images: the layout often demands different compositions at different sizes.
<picture>
<!-- AVIF: best compression, ~50-60% smaller than JPEG -->
<source
type="image/avif"
srcset="
hero-480.avif 480w,
hero-800.avif 800w,
hero-1200.avif 1200w,
hero-1920.avif 1920w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
>
<!-- WebP: ~25-35% smaller than JPEG, near-universal support -->
<source
type="image/webp"
srcset="
hero-480.webp 480w,
hero-800.webp 800w,
hero-1200.webp 1200w,
hero-1920.webp 1920w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
>
<!-- JPEG fallback: required for older browsers -->
<img
src="hero-800.jpg"
srcset="
hero-480.jpg 480w,
hero-800.jpg 800w,
hero-1200.jpg 1200w,
hero-1920.jpg 1920w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
alt="A mountain landscape at sunrise"
width="1200"
height="630"
fetchpriority="high"
loading="eager"
>
</picture>
<source>. Always place AVIF before WebP before JPEG. If you reverse the order, every browser will use JPEG regardless of what it supports.
AVIF encoding should target quality 60-70 (on a 0-100 scale) for photographic content -- this produces files roughly 50-60% smaller than equivalent JPEG. WebP works best at quality 80-85. Use cwebp, avifenc, Squoosh, or any image CDN to produce these variants. CLI tools like sharp (Node.js) or Pillow (Python) can batch-process entire image directories in CI.
fetchpriority=high and preload scanner interactions
Browsers include a preload scanner -- a lightweight HTML parser that runs ahead of the main parser to discover resources like scripts, stylesheets, and images before the main thread even begins layout. When your LCP image is in an <img> or <picture> element in the raw HTML source, the preload scanner finds it immediately and queues it for download at high priority.
The fetchpriority="high" attribute goes one step further: it promotes the image request above other high-priority resources competing for bandwidth. In Chrome's network scheduler, resources are grouped into priority buckets. Adding fetchpriority="high" bumps the image to the "Very High" bucket, the same level as CSS and synchronous scripts. The improvement is most measurable on slow connections (3G, low-tier mobile) where bandwidth contention matters most.
<head>
<!-- Preload the AVIF variant with imagesrcset + imagesizes -->
<!-- The browser selects only the variant it will actually use -->
<link
rel="preload"
as="image"
href="hero-800.avif"
imagesrcset="
hero-480.avif 480w,
hero-800.avif 800w,
hero-1200.avif 1200w,
hero-1920.avif 1920w
"
imagesizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
fetchpriority="high"
>
</head>
<!-- In body: the actual picture element -->
<picture>
<source type="image/avif" srcset="..." sizes="...">
<source type="image/webp" srcset="..." sizes="...">
<img
src="hero-800.jpg"
srcset="..."
sizes="..."
alt="Hero image"
width="1200"
height="630"
fetchpriority="high"
loading="eager"
>
</picture>
There are three situations where fetchpriority=high has the most impact: when the image is below several render-blocking resources in the network waterfall; when a JavaScript framework renders the hero client-side (making it invisible to the preload scanner); and when a carousel or tab component delays the hero render. In the third case, consider whether the carousel itself is worth the LCP cost -- see the LCP guide for a full discussion of component-level tradeoffs.
When preload hurts: Adding a rel=preload link for an image that the preload scanner already discovers naturally causes the browser to fetch the image twice -- once from the preload link and once from the element. Always use browser DevTools Network tab to verify no duplicate requests exist after adding a preload link.
Modern image CDNs and automatic format conversion
Managing AVIF, WebP, and JPEG variants manually for every image at every breakpoint is tedious and error-prone. Image CDNs solve this by accepting a single source image and serving the optimal format and dimensions on demand via URL parameters. The CDN handles format negotiation server-side using the Accept request header, eliminating the need for <picture> source chains in many cases.
For teams already on CDN-optimized deployments, most platforms include built-in image optimization:
- Vercel Image Optimization -- The
next/imagecomponent uses/_next/image?url=...&w=800&q=75URLs and serves AVIF or WebP automatically based on the Accept header. Width and quality are configurable per-image. Zero configuration outside the component props. - Cloudflare Images -- Stores images at a canonical URL and serves size variants via
/cdn-cgi/image/width=800,format=auto/path transforms. Format auto-selection uses Accept headers. Built into all Cloudflare plans above free tier. - Cloudinary -- The industry reference for image transformation via URL. Append
f_auto,q_auto,w_800to any Cloudinary URL for automatic format, quality, and resize. Theirdpr_autoparameter handles device pixel ratio withoutsrcset. - imgproxy -- Open-source, self-hostable. Signs transformation URLs for security. Supports AVIF, WebP, JPEG, PNG, GIF, and SVG. Ideal for teams that cannot send images to third-party CDNs.
- Bunny Optimizer -- Bunny CDN's image processing layer. Supports format conversion, resizing, and WebP/AVIF serving based on Accept headers. Simple to add as a layer on existing Bunny CDN configurations.
<!-- Cloudinary: f_auto selects AVIF/WebP/JPEG by Accept header -->
<!-- q_auto selects quality automatically per image content -->
<img
src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_800/hero.jpg"
srcset="
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_480/hero.jpg 480w,
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_800/hero.jpg 800w,
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_1200/hero.jpg 1200w,
https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_1920/hero.jpg 1920w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
alt="Hero image"
width="1200"
height="630"
fetchpriority="high"
loading="eager"
>
The key quality and dimension tradeoffs to understand: quality settings above 85 (on a 0-100 scale) produce diminishing returns -- the file size grows faster than perceived quality improves. For hero images at 1200 px wide, quality 75-80 is typically indistinguishable from quality 90 at normal viewing distances. Always use intrinsic dimensions matching the largest srcset entry, not the original source image, to avoid wasted pixels in the fallback.
Mistakes that defeat LCP optimization
Each of the following patterns silently undoes the work above. They are surprisingly common in production codebases because they look innocuous in isolation.
-
lazy-loading the LCP image.
loading="lazy"tells the browser not to fetch the image until it is near the viewport. The LCP image is already in the viewport -- lazy-loading it means the browser deliberately defers the most important image download. This is the single most common and most damaging LCP mistake. Always useloading="eager"(the default) for any above-the-fold image. -
Using
decoding="async"withoutloading="eager".decoding="async"tells the browser to decode the image off the main thread, which is useful for below-the-fold images. But when used alone on the LCP image, some browsers interpret it as a low-priority signal and delay both fetching and decoding. The correct combination isloading="eager" decoding="sync"(or simply omit both, using browser defaults) for the LCP image. -
CSS
background-imagefor the hero. As detailed in the FAQ below, background images are invisible to the preload scanner and require a full CSS parsing pass before the browser even discovers they exist. The LCP penalty is consistently 300-800 ms on real devices. -
Missing
sizesattribute on a fluid image. Withoutsizes, browsers assume100vwat the largest viewport. On a desktop browser with a 1440 px viewport, this triggers a 1440 px image download even if the image renders at 600 px in a two-column layout. Always write an accuratesizesstring that reflects your CSS layout. -
Preloading all srcset variants. Adding multiple
<link rel=preload>tags for the same image at different sizes causes multiple fetches. Use one preload link withimagesrcsetandimagesizes-- the browser fetches only the variant it will use. - Serving an oversized source to an image CDN. If your origin image is 12 MB and your CDN resizes on the first request, the first visitor still waits for the 12 MB transfer. Pre-compress source images to a reasonable maximum (3-5 MB for photographs) before uploading to any CDN.
Step-by-step fix
Step 1: Identify the LCP element with Lighthouse and CrUX attribution
Before optimizing anything, confirm exactly which element is the LCP candidate. Run Lighthouse in Chrome DevTools (Audits tab, select Performance, mobile throttling) and look for the "Largest Contentful Paint element" diagnostic. It will show you the exact DOM element with its selector. For the full Lighthouse audit walkthrough, see the dedicated tutorial.
For field data, open PageSpeed Insights for your URL. The "Diagnose performance issues" section includes LCP attribution broken down by subpart: Time to First Byte, Resource Load Delay, Resource Load Duration, and Element Render Delay. The Resource Load Duration subpart directly reflects image download time -- this is the number your optimization will reduce.
import { onLCP } from 'web-vitals';
onLCP((metric) => {
// metric.attribution.lcpEntry.element gives the actual DOM element
const lcpEl = metric.attribution.lcpEntry?.element;
console.log('LCP element:', lcpEl);
console.log('LCP value:', metric.value);
// Send to your analytics endpoint
navigator.sendBeacon('/analytics', JSON.stringify({
name: metric.name,
value: metric.value,
element: lcpEl?.tagName + (lcpEl?.src ? '#' + lcpEl.src : ''),
}));
});
Step 2: Convert the hero to AVIF + WebP with JPEG fallback
Generate three format variants for every breakpoint size. For a typical hero that renders at 480 px, 800 px, 1200 px, and 1920 px, you need 12 files total (4 sizes x 3 formats). Use the sharp Node.js library or the Squoosh CLI for batch conversion in CI pipelines.
import sharp from 'sharp';
const sizes = [480, 800, 1200, 1920];
for (const width of sizes) {
// AVIF -- quality 65 is visually lossless for photos
await sharp('hero-source.jpg')
.resize(width)
.avif({ quality: 65 })
.toFile(`hero-${width}.avif`);
// WebP -- quality 82 is a safe default
await sharp('hero-source.jpg')
.resize(width)
.webp({ quality: 82 })
.toFile(`hero-${width}.webp`);
// JPEG fallback -- quality 80, progressive encoding
await sharp('hero-source.jpg')
.resize(width)
.jpeg({ quality: 80, progressive: true })
.toFile(`hero-${width}.jpg`);
}
Step 3: Add srcset and sizes (or picture) for device-pixel-ratio-aware loading
Replace the plain <img src="hero.jpg"> with a <picture> element listing all three format sources in order. Match the sizes attribute exactly to your CSS layout rules -- measure the actual rendered image width at each breakpoint using DevTools and translate that into the sizes string.
<picture>
<source
type="image/avif"
srcset="hero-480.avif 480w, hero-800.avif 800w,
hero-1200.avif 1200w, hero-1920.avif 1920w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
>
<source
type="image/webp"
srcset="hero-480.webp 480w, hero-800.webp 800w,
hero-1200.webp 1200w, hero-1920.webp 1920w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
>
<img
src="hero-800.jpg"
srcset="hero-480.jpg 480w, hero-800.jpg 800w,
hero-1200.jpg 1200w, hero-1920.jpg 1920w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
alt="Descriptive alt text for the hero image"
width="1200"
height="630"
>
</picture>
Step 4: Set fetchpriority="high" and loading="eager" on the hero only
Add fetchpriority="high" to the <img> element (not the <source> elements -- only the fallback img is read by the preload scanner for priority purposes). Ensure loading="eager" is explicit. If you have other images on the page below the fold, add loading="lazy" to those to improve overall resource loading. Optionally add a preload link for the AVIF source using imagesrcset.
<head>
<!-- Optional: preload the AVIF variant for browsers that support it -->
<link
rel="preload"
as="image"
imagesrcset="hero-480.avif 480w, hero-800.avif 800w,
hero-1200.avif 1200w, hero-1920.avif 1920w"
imagesizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
fetchpriority="high"
>
</head>
<picture>
<source type="image/avif" srcset="..." sizes="...">
<source type="image/webp" srcset="..." sizes="...">
<img
src="hero-800.jpg"
srcset="hero-480.jpg 480w, hero-800.jpg 800w,
hero-1200.jpg 1200w, hero-1920.jpg 1920w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
alt="Descriptive alt text"
width="1200"
height="630"
fetchpriority="high"
loading="eager"
>
</picture>
<!-- All other images below the fold: lazy-load them -->
<img src="other-image.jpg" alt="..." loading="lazy" width="800" height="450">
Step 5: Verify LCP improvement in Lighthouse and real-user monitoring
After deploying, run Lighthouse three times and take the median score -- single-run Lighthouse results have significant variance. In the filmstrip view, the hero image should appear in the first or second frame. Check the Network panel in DevTools: the hero image should be one of the first resources shown and should have Priority: Highest.
For field data, instrument your pages with the web-vitals library and send LCP values to your analytics system. Use the attribution property to log which element is LCP and which subpart (Resource Load Duration) dominates. After 28 days of traffic, the Chrome User Experience Report in PageSpeed Insights will reflect the improvement in real-user data. See the performance FAQ for guidance on interpreting CrUX data and setting up ongoing monitoring.
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
const { timeToFirstByte, resourceLoadDelay,
resourceLoadDuration, elementRenderDelay } = metric.attribution;
// Log which subpart is the bottleneck
const bottleneck = [
['TTFB', timeToFirstByte],
['Load Delay', resourceLoadDelay],
['Load Duration', resourceLoadDuration],
['Render Delay', elementRenderDelay],
].sort((a, b) => b[1] - a[1])[0][0];
console.log(`LCP: ${metric.value}ms | Bottleneck: ${bottleneck}`);
});
Quick checklist
- LCP element identified via Lighthouse and confirmed with CrUX attribution
- Hero image available in AVIF, WebP, and JPEG at 480w / 800w / 1200w / 1920w
-
<picture>element has AVIF source first, then WebP, then JPEG fallback img -
sizesattribute reflects actual rendered width at each breakpoint -
Hero
<img>has explicitwidthandheightattributes to prevent CLS -
fetchpriority="high"andloading="eager"are set on the hero<img>only -
No
loading="lazy"on above-the-fold images -
Hero is not a CSS
background-image-- it is an inline<img>or<picture> - LCP improvement confirmed in Lighthouse and real-user monitoring after deploy
Frequently asked questions
Use img srcset when all you need is resolution switching for the same image crop and format. Use <picture> when you need format negotiation (AVIF, WebP, JPEG fallback chain) or art direction (different crops per breakpoint). In practice, <picture> is the recommended pattern for LCP hero images because it handles both format selection and resolution switching in one element, and the AVIF/WebP/JPEG fallback chain is almost always worth implementing for the file size savings.
fetchpriority=high helps most when the LCP image is invisible to the preload scanner -- for example, rendered by a JavaScript framework, set as a CSS background-image, or inside a client-side carousel. For a static <img> already in HTML source, the preload scanner already assigns high priority, and fetchpriority=high provides an incremental boost by moving it to the Very High bucket, most noticeable on bandwidth-constrained connections. It should always be set on the LCP image as defensive best practice.
AVIF has approximately 95% global browser support as of 2026, covering Chrome 85+, Firefox 93+, Safari 16+, and Edge 121+. The remaining gap is primarily older Android WebView instances and some niche browsers. Using <picture> with a JPEG fallback means zero risk -- unsupported browsers simply use the JPEG source. AVIF encoding can be slow on the origin side, but any image CDN handles that transparently. There is no reason not to ship AVIF in 2026 as long as a fallback is present.
Yes, consistently and significantly. A CSS background-image is invisible to the browser's preload scanner until the CSS file is downloaded, the CSS is parsed, and the element with the background-image rule is in the render tree. This adds at least one full blocking waterfall step -- typically 300-800 ms on real mobile connections -- compared to an inline <img> that the preload scanner discovers during HTML parsing. Additionally, the LCP specification applies background-image candidates only to certain element configurations, creating inconsistency across browsers. Always use <img> or <picture> for the LCP image.
Use <link rel=preload as=image imagesrcset="..." imagesizes="..."> in the <head> to preload only the variant the browser will actually fetch. The browser evaluates imagesizes against the current viewport and downloads just the one matching entry from imagesrcset -- not all of them. This is identical to how the browser evaluates srcset + sizes on the <img> element itself. Add fetchpriority="high" to the preload link as well. Verify in DevTools that the preload link and the picture element are not triggering two separate requests for the same file.
Alex Rivera — Senior Frontend Engineer
Alex specializes in Core Web Vitals optimization and image delivery pipelines. He has worked on performance engineering at high-traffic media and e-commerce properties and contributes to open-source image tooling.
Related fixes
LCP in Next.js
Fix LCP for Next.js applications using next/image, priority props, and Vercel's built-in image optimization.
FixCDN Optimization for LCP
Use CDN edge caching, image transforms, and origin shield strategies to minimize LCP resource load time.
FixFix Render-Blocking CSS Hurting LCP
Eliminate render-blocking stylesheets that delay the LCP image from painting, even after the image has loaded.