I was recently brought in to consult on a marketing website built with Create React App that was getting genuinely poor performance scores. The user experience felt sluggish, and Lighthouse reports were consistently flagging the Largest Contentful Paint (LCP) as taking several seconds too long. After a brief profiling session, the diagnosis was immediate and unambiguous: a massive, unoptimized 2.8 MB PNG hero image was being loaded on every device, from a small phone to a 4K monitor.

This is a genuinely common failure pattern I see in React applications that don’t use a framework with built-in image optimization. Developers correctly fetch and display the image, but overlook the critical delivery optimization steps that make the experience fast. We walked through a series of fixes, and I’m sharing that exact process here as a practical case study.


The Initial State: Diagnosing the Actual Problem

Before writing any code, we used the browser’s DevTools Network tab (with caching disabled) and Lighthouse to get a baseline. The report confirmed our suspicion: the LCP element was the hero <img> tag, and its network load time was the primary bottleneck. The image itself was a 2400x1600px PNG, which is almost never the correct format for a photographic hero image due to its file size.

The initial React component was as simple as you’d expect, which is part of the problem — the browser was given no information to help it optimize the download.

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

This renders the same massive image for everyone, a guaranteed performance failure.


Step 1: Choosing the Right Format and Compression

Our first action provided the biggest single improvement. PNG is a lossless format excellent for logos or graphics with sharp lines and transparency, but it’s genuinely inefficient for photographic content.

We took the original image and ran it through Squoosh, an online tool that lets you compare different formats and compression levels.

  1. Format Change: Converting from PNG to JPEG at a quality setting of 80 immediately reduced the file size by over 75%.
  2. Modern Format: We also generated a WebP version. This format, developed by Google, offered an additional 20-30% reduction over the JPEG with visually identical quality. For now, WebP has near-universal browser support and is the most practical modern format to use.

This step alone, without any code changes besides updating the src attribute, made the page feel dramatically faster. But we were still sending a very large image to small screens.


Step 2: Implementing Responsive Images with srcset

The next problem was serving the right size. A mobile phone with a 390px wide screen doesn’t need a 2400px wide image. This is where the srcset and sizes attributes come in. They are standard HTML features, but they are critically important to use within your React components.

We created several versions of our hero image (e.g., 640w, 750w, 1200w, 2400w) and updated the component to give the browser this information.

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 provides the browser a list of available image files and their intrinsic widths. sizes gives the browser hints about how large the image will be rendered at different viewport sizes, allowing it to pick the most efficient image from the srcset before it even downloads. This step ensured mobile devices were now downloading a file that was just tens of kilobytes instead of megabytes.


Step 3: Deferring Offscreen Images with loading="lazy"

Further down the page was a gallery of smaller images. In the initial implementation, all of these images were being requested on the initial page load, competing for network bandwidth with the critical hero image.

This is another problem with a straightforward native solution: the loading="lazy" attribute. This tells the browser not to download the image until the user scrolls close to it.

It is critical to state directly: you should never apply loading="lazy" to your LCP image (the hero image in this case). That image is critical and needs to load immediately. This technique is exclusively for images that are “below the fold.”

function ImageGallery({ images }) {
  return (
    <div className="gallery">
      {images.map(image => (
        <img
          key={image.id}
          src={image.url}
          alt={image.alt}
          loading="lazy" // Defer loading until it's needed
          width="400"   // Important for preventing layout shift
          height="300"
        />
      ))}
    </div>
  );
}

Notice we also added width and height attributes. This is crucial for lazy-loaded images, as it allows the browser to reserve the correct amount of space in the layout before the image loads, preventing content from jumping around—a phenomenon known as Cumulative Layout Shift (CLS).


The Framework Solution: The next/image Component

The team was already considering a future migration to Next.js. I pointed out that its built-in Image component automates nearly all of these best practices out of the box. It handles format negotiation (serving AVIF or WebP if the browser supports it), generates srcset automatically, lazy-loads by default, and prevents layout shift.

The equivalent component in Next.js would look something like this, achieving all our manual optimizations with a much simpler developer experience.

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 // This disables lazy loading for this critical LCP image
        sizes="(max-width: 600px) 100vw, 50vw"
      />
    </div>
  );
}

This demonstrates that while you can (and should) implement these optimizations manually in any React app, modern frameworks often provide powerful abstractions that make performance the default.


A Quick Reference for Image Optimization Steps

Technique Primary Benefit When to Use
Modern Formats (WebP/AVIF) Drastically smaller file size at same quality Always, for all photographic images.
Responsive Images (srcset) Delivers appropriately sized images for different screens Always, for any image that changes size with the viewport.
Lazy Loading (loading="lazy") Defers loading of non-critical images Only for images below the fold. Never for LCP.
Explicit width/height Prevents layout shift (CLS) Always, but especially critical for lazy-loaded images.

The Final Result Was More Than Just Better Scores

By implementing these steps, we reduced the total weight of the hero image for a typical mobile user from 2.8MB to under 100KB. The Lighthouse score jumped from the 40s into the high 90s, and the LCP time dropped from over 5 seconds to just 1.4 seconds. More importantly, the entire site felt immediately responsive and professional. The team learned that image optimization isn’t an optional “nice-to-have” but a foundational part of building a high-quality user experience with React.

Are you struggling with a specific image performance problem in your React app? Detail the issue, what you’ve tried, and I can offer some targeted advice.