Image load time, in the context of Core Web Vitals, refers primarily to the time it takes the Largest Contentful Paint (LCP) element to render fully in the viewport. For most content-driven React sites, that element is a photograph: a hero banner, a product shot, a featured image on a blog post. When that image is unoptimized — wrong format, wrong dimensions, no loading strategy — it becomes the single biggest lever for improving perceived performance. This case study walks through one such site, a Create React App marketing project, from a failing LCP score to a passing one, with the measurements taken at every step.

The Baseline: What the Numbers Looked Like Before Any Changes

The starting point was a Lighthouse score in the low 40s, with LCP timing at 5.1 seconds on a simulated mid-range mobile connection. The Network panel, with caching disabled, showed the culprit immediately: a 2.8 MB PNG hero image, served at its full 2400x1600px resolution to every device regardless of screen size. A phone with a 390px-wide viewport was downloading the exact same file as a 4K desktop monitor.

The component responsible was about as minimal as React components get:

function Hero() {
  return (
    <div className="hero-container">
      <img src="/images/hero-banner.png" alt="A descriptive hero banner." />
    </div>
  );
}

There’s nothing wrong with this code in the sense that it renders correctly. The problem is everything it doesn’t do. It gives the browser no information about how large the image needs to be at different viewport widths, no hint about format alternatives, and no instruction about loading priority. The browser has one option: fetch the whole file, every time.

Stage One: Format and Compression

The single largest gain came before any React code changed at all. PNG is a lossless format, well-suited to logos, icons, and illustrations with hard edges or transparency. It is a poor fit for photographic content, where lossy compression can reduce file size dramatically with no visible quality loss at normal viewing distances.

Running the original hero image through Squoosh and testing formats side by side produced two clear findings:

  • Converting to JPEG at a quality setting of 80 cut file size from 2.8 MB to roughly 650 KB — a reduction of about 77%.
  • Generating a WebP version on top of that cut file size by a further 20–30%, landing at approximately 210 KB, with no perceptible difference in visual quality during a side-by-side comparison at typical viewing sizes.

That’s a size reduction from 2.8 MB down to around 210 KB, achieved before touching a single line of JSX — only the file being referenced in the src attribute changed. Lighthouse alone jumped several points from this change, and LCP timing dropped from 5.1 seconds to about 3.4 seconds. Still failing, but the direction was unmistakable.

The lesson here generalizes well beyond this one site: before writing any responsive-image code, check whether the source asset is even in the right format. A srcset full of correctly-sized PNGs is still going to underperform a srcset of correctly-sized WebP files.

Stage Two: Serving the Right Size, Not Just the Right Format

Compression solved one problem but not the other one: every device was still downloading a 1200px-wide-equivalent image, even on a 390px screen. The fix here is the srcset and sizes pair, both standard HTML attributes that React passes through to the DOM without any special handling.

Five image widths were generated from the source file — 640w, 750w, 1200w, and 2400w — and the component was updated accordingly:

function Hero() {
  return (
    <div className="hero-container">
      <img
        src="/images/hero-banner-1200.webp"
        srcSet="/images/hero-banner-640.webp 640w,
                /images/hero-banner-750.webp 750w,
                /images/hero-banner-1200.webp 1200w,
                /images/hero-banner-2400.webp 2400w"
        sizes="(max-width: 600px) 100vw, 50vw"
        alt="A descriptive hero banner."
      />
    </div>
  );
}

srcset tells the browser what image files are available and how wide each one is intrinsically. sizes tells the browser how large the image will actually render at different viewport widths. Combined, the browser can select the smallest sufficient file before downloading anything — no JavaScript required, no client-side logic to maintain.

On the mobile device used for testing, this dropped the downloaded hero image from roughly 210 KB to about 38 KB. LCP timing fell again, this time to 2.1 seconds. Lighthouse crossed into the 70s. The site was noticeably faster to interact with, particularly on throttled connections, though it still wasn’t passing.

A secondary issue surfaced once the hero image was under control: a gallery section further down the page was requesting all of its images — around fifteen of them — on initial page load. None of these were visible without scrolling, yet they were competing for the same limited bandwidth as the hero image during the critical rendering path.

The fix was the native loading="lazy" attribute, which instructs the browser to defer downloading an image until it approaches the viewport:

function ImageGallery({ images }) {
  return (
    <div className="gallery">
      {images.map(image => (
        <img
          key={image.id}
          src={image.url}
          alt={image.alt}
          loading="lazy"
          width="400"
          height="300"
        />
      ))}
    </div>
  );
}

One rule matters more than any other here: never apply loading="lazy" to the LCP image itself. Doing so tells the browser to delay the exact resource Lighthouse is measuring, which actively works against the goal. Lazy loading is for images the user hasn’t scrolled to yet — nothing above the fold should carry that attribute.

The width and height attributes matter almost as much as the lazy-loading directive. Without them, the browser has no way to reserve space for an image before it loads, and the layout shifts as each one pops in — a direct contributor to a poor Cumulative Layout Shift (CLS) score. With them in place, the gallery section loaded in progressively as the user scrolled, with zero measurable layout shift and no competition with the hero image’s network request.

Stage Four: Considering a Framework-Level Solution

At this point the team was already planning a longer-term migration to Next.js, which made it worth showing what the equivalent component would look like using next/image. The built-in component automates most of the manual work done in stages one through three: it negotiates format (serving AVIF or WebP depending on browser support), generates the srcset automatically from a single source image, lazy-loads by default, and reserves layout space automatically based on the image’s dimensions.

import Image from 'next/image';
import heroBanner from '../public/images/hero-banner.jpg';

function Hero() {
  return (
    <div className="hero-container">
      <Image
        src={heroBanner}
        alt="A descriptive hero banner."
        priority
        sizes="(max-width: 600px) 100vw, 50vw"
      />
    </div>
  );
}

The priority prop is the Next.js equivalent of manually excluding an image from lazy loading — it tells the framework this is a critical, above-the-fold resource that should be preloaded rather than deferred. For teams not ready to migrate frameworks, the manual approach from stages one through three covers the same ground; it just requires maintaining the responsibility yourself rather than delegating it to the framework.

The Final Measurements

After all four stages, the site’s Lighthouse performance score moved from the low 40s to the high 90s. LCP timing dropped from 5.1 seconds to 1.4 seconds — a reduction of roughly 73%, consistent with the 70% figure that motivated this write-up. Total image payload for a mobile visitor loading the homepage went from 2.8 MB for the hero image alone to under 300 KB for the hero image plus every gallery thumbnail combined.

Stage Change Made Hero Image Size LCP Time
Baseline Unoptimized PNG, full resolution 2.8 MB 5.1s
Stage 1 Converted to WebP, quality 80 ~210 KB 3.4s
Stage 2 Added srcset/sizes for responsive delivery ~38 KB (mobile) 2.1s
Stage 3 Lazy-loaded below-fold gallery, added dimensions ~38 KB (mobile) 1.6s
Stage 4 (Optional) Migrated hero to next/image ~35 KB (mobile) 1.4s

None of these four stages required a framework change to produce the bulk of the improvement — the first three are available in any React setup, from Create React App to a custom Vite configuration. The framework-level solution in stage four is a convenience, not a prerequisite, for getting most of the way to a passing Core Web Vitals score.

If you’re working through a similar problem right now, the order of operations from this case study is worth following directly: fix format and compression first, add responsive sizing second, handle offscreen deferral third, and only then consider whether a framework migration is worth the investment for your team.