Reducing LCP from Hero Images on Static Sites

On most marketing and documentation landing pages the hero image is the Largest Contentful Paint (LCP) element — it is the biggest thing in the first viewport, so the browser counts its paint time as your LCP. If that image is a 1.4 MB JPEG loaded at default priority, your LCP is whatever time it takes that file to arrive and render, and no amount of font or JavaScript tuning will move the metric until you fix it.

This guide is the hero-image recipe: right size, right format, preloaded, and never lazy-loaded. It targets static sites specifically, where every hero variant is a build artifact you generate ahead of time rather than transforming on the fly, so the whole problem reduces to emitting the right files and referencing them correctly. It is the image-focused companion to Largest Contentful Paint Optimization for Static Sites, inside the larger Performance Optimization & Core Web Vitals for SSGs effort. Because a badly sized hero also shifts layout as it lands, the steps here double as a Cumulative Layout Shift fix — the same reservation logic covered for text in Self-Hosting Google Fonts to Eliminate Layout Shift.

Prerequisites

  • A static site (Astro, Hugo, Eleventy, or Jekyll) with a hero image in the first viewport.
  • A build-time image step that can emit multiple widths and formats — see Image Optimization Pipelines in Astro for the Astro path or the Hugo equivalent.
  • Lighthouse or WebPageTest and a deployed URL to measure against.
Hero image optimization flow A large source image passes through four checkpoints: generate responsive widths, encode to AVIF and WebP, set explicit dimensions, and preload with eager loading, producing a small fast hero that paints quickly. From 1.4 MB source to a fast hero paint hero.jpg 1.4 MB Resize 800 / 1200 / 2400 Encode AVIF + WebP Dimensions width/height eager + preload Paint LCP 1.6s Each checkpoint cuts bytes or moves the request earlier; the lazy-load trap is the one to avoid in between.
Four checkpoints turn a heavy source into a fast hero: resize to real widths, encode to AVIF/WebP, set explicit dimensions, then load eagerly and preload.

The Recipe

1. Generate the widths the layout actually uses

A hero rendered at 1200px CSS pixels needs an 800px width for phones and a 2400px variant for 2x screens — not a single 2400px file served to everyone. Generate the real widths and let the browser pick with srcset/sizes:

<picture>
  <source type="image/avif"
          srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2400.avif 2400w"
          sizes="(max-width: 800px) 100vw, 1200px" />
  <source type="image/webp"
          srcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-2400.webp 2400w"
          sizes="(max-width: 800px) 100vw, 1200px" />
  <img src="/hero-1200.jpg" alt="Analytics dashboard"
       width="1200" height="600"
       fetchpriority="high" loading="eager" decoding="async" />
</picture>

The sizes attribute tells the browser the rendered width before layout, so the preload scanner downloads the right candidate on the first try instead of guessing. Get sizes wrong — for example leaving it at the default 100vw when the hero actually renders in a 1200px column — and the browser picks the 2400px file on a wide screen and hands you a slower LCP than you would have had with no srcset at all. Match each entry in sizes to the CSS breakpoints your layout really uses. The device-pixel-ratio math is the browser's job: on a 2x phone at 400 CSS px it wants roughly 800 device pixels, so the 800w candidate is chosen automatically — you only supply real widths and honest sizes, never per-device conditionals.

Keep the candidate list short and purposeful. Three widths (a phone size, the desktop render size, and a 2x desktop size) cover almost every layout; adding a dozen intermediate widths just bloats the build for gains the eye cannot see. These widths are the exact output your image step should emit — see Building an Image CDN Pipeline for Static Sites for wiring the generation into the build.

2. Encode to a modern format

AVIF is typically 20-30% smaller than WebP at matched quality, and WebP is 25-50% smaller than JPEG. Emit both with the original JPEG as a final fallback, and order the <source> elements from most to least efficient — the browser takes the first type it can decode, so AVIF must come before WebP. quality=80 is the sweet spot for photographic heroes; below 60 you start to see banding on gradients and blocking around high-contrast edges. AVIF's encoder is slow, which matters at build time rather than at request time on a static site: encode once during the build, cache the artifacts, and the visitor only ever downloads the finished file. The Hugo path to the same output, without a plugin, is in Optimizing WebP Images in Hugo Without Plugins.

One caveat: for flat illustrations, logos, or screenshots with large uniform regions, a well-optimized PNG or SVG can beat AVIF. The recipe here assumes a photographic hero; measure your actual asset before assuming AVIF wins.

3. Set explicit dimensions

Always include width and height (or an aspect-ratio in CSS). This reserves layout space so the hero does not push content down when it arrives, keeping Cumulative Layout Shift near zero. The numbers are the image's intrinsic pixel dimensions, not the rendered CSS size — the browser derives the aspect ratio from them and holds the box open while the file downloads. Astro's <Image> component requires dimensions for exactly this reason and will fail the build if you omit them. Without reserved space the hero arrives, expands, and shoves everything below it down the page — a layout shift that lands right in the LCP window and is doubly visible because the hero is large.

4. Load it eagerly and preload it

Never put loading="lazy" on the hero — that defers the LCP element on purpose. Lazy loading is a below-the-fold optimization; on the LCP element it is a regression. Use loading="eager" (the default for <img>, but state it so a template refactor cannot silently flip it) and fetchpriority="high" so the request jumps ahead of scripts and other images in the browser's fetch queue.

For an <img> or <picture> element the preload scanner finds the hero during HTML parse, so fetchpriority="high" is usually enough. Add an explicit preload when the hero is discovered late — a CSS background-image, an image injected by a framework component, or one that sits behind a lazy-hydrated island. The responsive form carries the same srcset/sizes so the preload fetches the identical candidate the <img> would have:

<link rel="preload" as="image"
      imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2400.avif 2400w"
      imagesizes="(max-width: 800px) 100vw, 1200px"
      type="image/avif" />

A preload that names the wrong candidate double-downloads the hero, so keep imagesrcset/imagesizes in lock-step with the markup. The Astro shorthand that emits all of eager loading, the priority hint, and the preload in one line is the priority prop, covered in Optimizing LCP on Astro with Priority Hints. Whichever generator you use, make sure the hero is served with a long-lived immutable cache header so repeat views paint instantly — see Setting Cache-Control Headers on Cloudflare Pages.

Measured Impact

Measured on a marketing landing page, throttled mobile profile (4x CPU, ~1.6 Mbps), median of five Lighthouse runs:

Hero variantDelivered bytes (phone)LCPCLS
1.4 MB JPEG, lazy-loaded1.4 MB3.4s0.08
1.4 MB JPEG, eager + preload1.4 MB2.6s0.00
Responsive AVIF, eager + preload96 KB (800w)1.6s0.00

Removing loading="lazy" and adding the preload alone cut LCP from 3.4s to 2.6s — a fix that touches two attributes and ships no new bytes. Switching to a responsive AVIF dropped the phone download from 1.4 MB to 96 KB and brought LCP to 1.6s. Setting explicit dimensions took CLS from 0.08 to 0.00. WebPageTest's filmstrip confirmed the hero now paints in the first frame after first byte, and the Chrome DevTools Performance panel attributed the remaining LCP time to network transfer rather than render delay — the signal that the element itself is no longer the bottleneck.

LCP by hero variant on a throttled mobile profile A horizontal bar chart of three hero variants measured on throttled mobile. A lazy-loaded 1.4 MB JPEG has an LCP of 3.4 seconds. The same JPEG loaded eagerly with a preload drops to 2.6 seconds. A responsive AVIF delivering 96 kilobytes to the phone reaches 1.6 seconds. A dashed line marks the 2.5-second "good" LCP threshold, which only the responsive AVIF clears. LCP shrinks as bytes drop and the request starts earlier Good LCP ≤ 2.5 s 0 1 s 2 s 3 s JPEG lazy-loaded 1.4 MB to phone 3.4 s JPEG eager + preload 1.4 MB to phone 2.6 s AVIF responsive, eager 96 KB to phone 1.6 s Eager + preload moves the request start earlier; the responsive AVIF shortens the download.
LCP for the three hero variants from the table. Preloading and eager loading cut 0.8 s with no new bytes; the responsive AVIF then clears the 2.5 s "good" threshold on a 96 KB download.

The two levers act on different parts of the LCP timeline: dropping loading="lazy" and preloading moves the request start earlier, while the AVIF and responsive widths shorten the download duration. Both are needed — a preloaded 1.4 MB file still spends most of the LCP window on the wire, and a tiny AVIF discovered late still waits behind the stylesheet.

Pitfalls & Rollback

  • Lazy-loading the hero: the single most common LCP regression, and easy to introduce by accident when a global loading="lazy" default or a CMS image component gets applied to every image. Keep lazy loading for below-the-fold images only.
  • One giant width for all devices: a 2400px file on a phone wastes bandwidth and slows LCP where it hurts most. Always provide smaller candidates and a sizes attribute.
  • A wrong sizes value: sizes that overstates the rendered width makes the browser pick a bigger candidate than it needs, quietly undoing the responsive win. When in doubt, read the resolved value in DevTools' Elements panel (currentSrc) and confirm the phone is actually getting the 800w file.
  • Missing dimensions: no width/height means the browser cannot reserve space, so CLS spikes when the hero loads.
  • Preloading the wrong candidate: a <link rel="preload"> whose imagesrcset/imagesizes drift out of sync with the <img> fetches one file for the preload and a different one for the element — two hero downloads and a worse LCP. Generate both from the same source of truth.
  • Quality too low to save bytes: dropping below quality=60 produces visible banding. Reach for a smaller width instead of crushing quality.
  • Rollback: revert the <picture> block to the original <img> and redeploy. Because the optimized variants are build artifacts, no cache state needs clearing — the next build regenerates or removes them. If a bad sizes value shipped, it is a one-line edit and a redeploy; there is no runtime state to unwind.
Where the hero image budget goes Ranked bars for the same hero: an unoptimised 1600 pixel JPEG is 1.4 megabytes, resizing to the rendered width gives 310 kilobytes, converting to AVIF gives 128 kilobytes, and serving a responsive srcset gives 74 kilobytes to a mobile viewport. Where the hero image budget goes Original JPEG no resize, no format change 1.4 MB Resized to rendered width 1600 → 960 px 310 KB Converted to AVIF quality 55 128 KB Responsive srcset mobile picks 640w 74 KB Shared linear scale · same source image, same rendered appearance at every step
Resizing is the largest single step, and the one most often skipped because the page looks correct without it.

Conclusion

The hero is usually your LCP element, so it deserves the most attention: generate real widths, encode to AVIF/WebP, set explicit dimensions, and load it eagerly with a preload. On the example page these steps moved LCP from 3.4s to 1.6s and CLS from 0.08 to zero. Combine this with the priority hints in Optimizing LCP on Astro with Priority Hints and the render-delay work in Eliminating Render-Blocking CSS on Static Sites.

FAQ

Why is the hero image so often the LCP element?

The hero is usually the largest visible element in the first viewport, which is exactly what Largest Contentful Paint measures. If it is big and downloads slowly, your LCP is its paint time, so shrinking and prioritizing it has the most direct effect on the metric.

Should I ever lazy-load a hero image?

No. loading="lazy" tells the browser to defer the image until layout determines it is near the viewport, which delays the LCP element on purpose. Use loading="eager" for anything above the fold and reserve lazy loading for images below it.

What size should I generate the hero at?

Generate the widths your layout actually renders, plus a 2x variant for high-density screens, and let srcset and sizes pick. Shipping a 2400px source into a 1200px slot doubles the download for no visible gain. Match the largest width to the largest rendered size.

Does a responsive srcset help LCP or just bandwidth?

Both. By serving a phone a smaller file than a desktop, srcset cuts the download span of LCP on mobile, where networks are slowest and LCP problems are worst. It also avoids wasting bandwidth on pixels the device cannot show.

My hero is a CSS background image — does any of this still apply?

Yes, but CSS backgrounds are discovered late because the browser must download and parse the stylesheet before it learns the URL, so preload matters even more. Add a <link rel="preload" as="image"> with the correct imagesrcset and imagesizes so the request starts during HTML parse, and prefer an <img> or <picture> element for the hero wherever the design allows, since elements are found by the preload scanner immediately.