React Interview Prep
Performance Optimization

Image & Asset Optimization

Stop Shipping Megabytes of Unoptimized Images

LinkedIn Hook

The average web page ships 1.8MB of images. Half of them are off-screen. Most are full resolution on a 320px phone. And none of them reserve layout space before loading — so every image causes a visible jump that destroys your CLS score.

Image optimization is not optional in production React apps. It is the single largest performance lever most teams ignore. When a user visits your page on a slow connection, unoptimized images are the reason they stare at a blank screen for 10 seconds while perfectly good HTML and CSS sit painted behind invisible image loads.

Yet in interviews, candidates rarely discuss image strategy. They talk about memoization and code splitting but cannot explain why they should use WebP over PNG, how srcSet delivers the right resolution to every device, or why lazy loading images below the fold cuts initial page weight by 40-60%.

Interviewers will ask: "Your e-commerce product page loads 30 high-resolution images. PageSpeed Insights scores it at 35. How do you fix it?" They want to hear you talk about lazy loading with Intersection Observer, responsive images with srcSet and sizes, modern formats like WebP and AVIF, skeleton loaders that hold layout space, and the specific techniques that eliminate Cumulative Layout Shift.

In this lesson, I break down every piece: native lazy loading and Intersection Observer, responsive images with srcSet, modern image formats and their tradeoffs, skeleton loaders and placeholder patterns, and the strategies that keep your CLS score near zero while images load progressively.

If your React app ships full-resolution PNGs to every device with no lazy loading — this lesson will transform your image strategy.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #Performance #ImageOptimization #WebDevelopment #CodingInterview #100DaysOfCode


Image & Asset Optimization thumbnail


What You'll Learn

  • How to lazy load images so off-screen images never block the initial page render
  • How srcSet and the sizes attribute deliver the right image resolution to every screen size
  • Why modern formats like WebP and AVIF cut image weight by 30-80% compared to PNG and JPEG
  • How skeleton loaders and placeholder patterns improve perceived performance
  • How to eliminate Cumulative Layout Shift (CLS) caused by images loading without reserved space

The Concept — Deliver the Right Image, at the Right Time, at the Right Size

Imagine an art gallery with 200 paintings. On opening day, the curator decides to hang every painting at once before unlocking the doors. Giant oil paintings, tiny sketches, massive murals — all hauled out of storage, unwrapped, and mounted. The doors stay locked until the last painting is hung. Visitors wait outside for an hour. When they finally walk in, they stand in the entrance hall looking at five paintings — the other 195 are in rooms they may never visit.

That is what an unoptimized React app does. It downloads every image on the page — including the ones 4000 pixels below the viewport — before the user sees anything meaningful.

Now imagine a smarter curator. She hangs the five entrance-hall paintings first and opens the doors immediately. As visitors walk deeper into the gallery, an assistant quickly hangs paintings in the next room just before they arrive. For each wall, the assistant picks the right painting size — a small print for a narrow hallway, a large canvas for the grand hall. Instead of oil paintings shipped from overseas (PNG at 3MB), she uses high-quality reproductions (WebP at 80KB) that look identical from viewing distance. And every wall has a labeled frame mounted before the painting arrives, so the room layout never shifts when a painting is placed.

That is image optimization in React. Lazy loading is the assistant hanging paintings only when visitors approach. srcSet is picking the right size for the wall. WebP and AVIF are the efficient reproductions. Skeleton loaders are the pre-mounted frames. And reserving dimensions is what prevents the room from rearranging itself every time a painting appears.


Lazy Loading Images — Load Only What the User Can See

The simplest and highest-impact optimization is lazy loading. Images below the viewport should not download until the user scrolls near them. HTML5 provides a native loading="lazy" attribute, and the Intersection Observer API gives you precise control in React.

Code Example 1: Native Lazy Loading vs Intersection Observer

// APPROACH 1: Native HTML lazy loading — simplest, works in all modern browsers
// The browser handles everything: it skips off-screen images and loads them on scroll
function ProductGrid({ products }) {
  return (
    <div className="product-grid">
      {products.map((product) => (
        <div key={product.id} className="product-card">
          {/* loading="lazy" tells the browser to defer this image */}
          {/* width and height MUST be set to prevent layout shift (CLS) */}
          <img
            src={product.imageUrl}
            alt={product.name}
            width={400}
            height={300}
            loading="lazy"
          />
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  );
}

// APPROACH 2: Intersection Observer — full control over when images load
// Use this when you need custom thresholds, placeholder transitions, or analytics
import { useState, useEffect, useRef } from "react";

function LazyImage({ src, alt, width, height, placeholderSrc }) {
  // Track whether the image has entered the viewport
  const [isVisible, setIsVisible] = useState(false);
  // Track whether the full image has finished loading
  const [isLoaded, setIsLoaded] = useState(false);
  const imgRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        // When the image placeholder scrolls into view (or within 200px of it)
        if (entry.isIntersecting) {
          setIsVisible(true);
          // Stop observing — we only need to trigger the load once
          observer.unobserve(entry.target);
        }
      },
      {
        // Start loading 200px before the image enters the viewport
        // This gives the image a head start so it appears loaded when scrolled to
        rootMargin: "200px",
      }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    // Cleanup: disconnect the observer when the component unmounts
    return () => observer.disconnect();
  }, []);

  return (
    <div
      ref={imgRef}
      style={{ width, height, position: "relative", overflow: "hidden" }}
    >
      {/* Low-quality placeholder is always loaded (tiny file, ~1-2KB) */}
      {!isLoaded && (
        <img
          src={placeholderSrc}
          alt=""
          style={{
            width: "100%",
            height: "100%",
            objectFit: "cover",
            filter: "blur(10px)",
            transform: "scale(1.1)",
          }}
        />
      )}

      {/* Full image only starts downloading once isVisible becomes true */}
      {isVisible && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setIsLoaded(true)}
          style={{
            width: "100%",
            height: "100%",
            objectFit: "cover",
            opacity: isLoaded ? 1 : 0,
            transition: "opacity 0.3s ease-in-out",
            position: isLoaded ? "relative" : "absolute",
            top: 0,
            left: 0,
          }}
        />
      )}
    </div>
  );
}

// Usage:
// <LazyImage
//   src="/images/hero-full.jpg"
//   placeholderSrc="/images/hero-tiny.jpg"    // 20x15px blurred version, ~1KB
//   alt="Product hero"
//   width={800}
//   height={600}
// />

// Output when page loads with 30 product images:
// Native lazy: Browser downloads ~5 visible images immediately, defers the rest
// IntersectionObserver: Only placeholder thumbnails load (~30KB total vs ~9MB)
// As user scrolls: images load 200px before entering viewport
// Perceived performance: user sees content immediately, images appear seamlessly

Key point: Always set explicit width and height on images. Without dimensions, the browser allocates zero space for the image, then suddenly shifts all content down when it loads. That shift is Cumulative Layout Shift — and it destroys your Core Web Vitals score.


Responsive Images with srcSet — One Image Does Not Fit All Screens

A 2000px wide hero image looks great on a desktop monitor. But shipping that same 2000px image to a 320px phone wastes 80% of the downloaded bytes. The srcSet attribute lets you provide multiple image sizes, and the browser picks the best one for the device.

Code Example 2: srcSet and Sizes for Responsive Delivery

// A responsive image component that serves the right resolution to every device
function ResponsiveImage({ basePath, alt, sizes, widths, aspectRatio }) {
  // Generate srcSet string from the list of available widths
  // Each entry tells the browser: "this URL provides an image that is Xw pixels wide"
  const srcSet = widths
    .map((w) => `${basePath}-${w}w.webp ${w}w`)
    .join(", ");

  // Calculate height from aspect ratio to reserve layout space
  // This prevents CLS — the browser knows the exact space before the image loads
  const paddingTop = `${(1 / aspectRatio) * 100}%`;

  return (
    <div style={{ position: "relative", width: "100%", paddingTop }}>
      <img
        srcSet={srcSet}
        sizes={sizes}
        src={`${basePath}-800w.webp`}
        alt={alt}
        loading="lazy"
        decoding="async"
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: "100%",
          height: "100%",
          objectFit: "cover",
        }}
      />
    </div>
  );
}

// Usage in a product page:
function ProductPage({ product }) {
  return (
    <article>
      <h1>{product.name}</h1>

      {/* Hero image: full width on mobile, 50% on desktop */}
      <ResponsiveImage
        basePath={`/images/products/${product.id}`}
        alt={product.name}
        widths={[320, 640, 960, 1200, 1800]}
        aspectRatio={16 / 9}
        sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 50vw"
      />

      {/* The sizes attribute tells the browser:
          - On screens up to 640px wide: the image fills 100% of viewport width
          - On screens up to 1024px wide: the image fills 75% of viewport width
          - On larger screens: the image fills 50% of viewport width

          The browser combines sizes with srcSet to pick the best image:
          - iPhone SE (320px viewport, 2x DPR): needs 640px image -> picks 640w
          - iPad (768px viewport, 2x DPR): needs 1152px image -> picks 1200w
          - Desktop (1440px viewport, 1x DPR): needs 720px image -> picks 960w
      */}

      <p>{product.description}</p>
    </article>
  );
}

// Output on different devices:
// iPhone SE: downloads product-640w.webp (~45KB) instead of 1800w (~320KB)
// iPad: downloads product-1200w.webp (~120KB) — right size for retina display
// Desktop: downloads product-960w.webp (~85KB) — image only fills half the screen
// Savings: 60-80% bandwidth reduction on mobile devices

Key point: The sizes attribute is what makes srcSet work correctly. Without sizes, the browser assumes the image fills 100% of the viewport and may download a larger file than needed. Always specify how wide the image actually appears at each breakpoint.


Modern Image Formats — WebP and AVIF

PNG and JPEG were designed decades ago. Modern formats compress dramatically better at the same visual quality. WebP typically saves 25-35% over JPEG and supports transparency. AVIF saves 40-50% over JPEG with even better quality at low file sizes.

Code Example 3: Picture Element with Format Fallbacks

// A component that serves the best format each browser supports
// The browser picks the FIRST source it can render, then falls back to img
function OptimizedPicture({
  baseName,
  alt,
  width,
  height,
  sizes,
  widths,
  loading = "lazy",
  priority = false,
}) {
  // Generate srcSet for a given format
  const buildSrcSet = (format) =>
    widths.map((w) => `${baseName}-${w}w.${format} ${w}w`).join(", ");

  return (
    <picture>
      {/* AVIF: smallest files, best compression, growing browser support */}
      {/* Chrome 85+, Firefox 93+, Safari 16.4+ */}
      <source type="image/avif" srcSet={buildSrcSet("avif")} sizes={sizes} />

      {/* WebP: excellent compression, nearly universal browser support */}
      {/* Chrome 32+, Firefox 65+, Safari 14+, Edge 18+ */}
      <source type="image/webp" srcSet={buildSrcSet("webp")} sizes={sizes} />

      {/* JPEG fallback: for the rare browsers that support neither */}
      {/* The img tag is required — it is what actually renders */}
      <img
        src={`${baseName}-800w.jpg`}
        srcSet={buildSrcSet("jpg")}
        sizes={sizes}
        alt={alt}
        width={width}
        height={height}
        loading={priority ? "eager" : loading}
        decoding={priority ? "sync" : "async"}
        // fetchPriority hints the browser to prioritize above-the-fold images
        fetchpriority={priority ? "high" : "auto"}
      />
    </picture>
  );
}

// Usage:
function BlogPost({ post }) {
  return (
    <article>
      {/* Hero image: priority loading, no lazy load (above the fold) */}
      <OptimizedPicture
        baseName={`/images/blog/${post.slug}/hero`}
        alt={post.heroAlt}
        width={1200}
        height={630}
        widths={[400, 800, 1200, 1600]}
        sizes="(max-width: 768px) 100vw, 800px"
        priority={true}
      />

      <div className="content">
        {post.sections.map((section) => (
          <div key={section.id}>
            <p>{section.text}</p>

            {/* Inline images: lazy loaded (below the fold) */}
            {section.image && (
              <OptimizedPicture
                baseName={`/images/blog/${post.slug}/${section.image}`}
                alt={section.imageAlt}
                width={800}
                height={450}
                widths={[400, 800]}
                sizes="(max-width: 768px) 100vw, 700px"
              />
            )}
          </div>
        ))}
      </div>
    </article>
  );
}

// Output comparison for a single image at 800px wide:
// PNG:  hero-800w.png  — 850KB
// JPEG: hero-800w.jpg  — 180KB
// WebP: hero-800w.webp — 120KB  (33% smaller than JPEG)
// AVIF: hero-800w.avif —  78KB  (57% smaller than JPEG)
//
// Across a page with 15 images:
// JPEG total: ~2.7MB
// WebP total: ~1.8MB  (saves 900KB)
// AVIF total: ~1.2MB  (saves 1.5MB)
//
// On 3G (750 Kbps): AVIF saves ~16 seconds of download time per page

Key point: Use the <picture> element with <source> tags ordered from smallest format to largest. The browser picks the first format it supports. Always include a JPEG or PNG <img> fallback as the last child. For above-the-fold images, set priority={true} to disable lazy loading and signal high fetch priority.


Skeleton Loaders and Placeholder Patterns — Perceived Performance

While images download, users see either nothing (bad), a sudden pop-in (worse), or a smooth transition from placeholder to content (good). Skeleton loaders and blur-up placeholders communicate progress and eliminate the jarring experience of images appearing from nowhere.

Code Example 4: Skeleton Loader and Blur-Up Placeholder

import { useState } from "react";

// A skeleton loader that pulses while the image loads
// It matches the exact dimensions of the final image to prevent layout shift
function ImageWithSkeleton({ src, alt, width, height }) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div style={{ position: "relative", width, height }}>
      {/* Skeleton: shows a pulsing gray box until the image loads */}
      {/* The skeleton has the SAME dimensions as the image — zero CLS */}
      {!loaded && (
        <div
          style={{
            width: "100%",
            height: "100%",
            backgroundColor: "#21262d",
            borderRadius: "8px",
            animation: "pulse 1.5s ease-in-out infinite",
          }}
        />
      )}

      {/* The actual image loads in the background */}
      {/* opacity transitions from 0 to 1 when onLoad fires */}
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        onLoad={() => setLoaded(true)}
        style={{
          position: loaded ? "relative" : "absolute",
          top: 0,
          left: 0,
          width: "100%",
          height: "100%",
          objectFit: "cover",
          borderRadius: "8px",
          opacity: loaded ? 1 : 0,
          transition: "opacity 0.4s ease",
        }}
      />

      {/* CSS animation for the skeleton pulse */}
      <style>{`
        @keyframes pulse {
          0%, 100% { opacity: 1; }
          50% { opacity: 0.4; }
        }
      `}</style>
    </div>
  );
}

// Blur-Up pattern: show a tiny blurred thumbnail, then reveal the full image
// This is the technique used by Medium, Facebook, and Unsplash
function BlurUpImage({ src, tinySrc, alt, width, height }) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div
      style={{
        position: "relative",
        width,
        height,
        overflow: "hidden",
        borderRadius: "8px",
      }}
    >
      {/* Tiny placeholder: a 20x15 pixel version of the image (~500 bytes) */}
      {/* Scaled up and blurred — gives a color preview of the final image */}
      <img
        src={tinySrc}
        alt=""
        aria-hidden="true"
        style={{
          width: "100%",
          height: "100%",
          objectFit: "cover",
          filter: "blur(20px)",
          transform: "scale(1.1)",
          opacity: loaded ? 0 : 1,
          transition: "opacity 0.5s ease",
        }}
      />

      {/* Full image fades in over the blurred placeholder */}
      <img
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        onLoad={() => setLoaded(true)}
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: "100%",
          height: "100%",
          objectFit: "cover",
          opacity: loaded ? 1 : 0,
          transition: "opacity 0.5s ease",
        }}
      />
    </div>
  );
}

// Usage in a product listing:
function ProductList({ products }) {
  return (
    <div className="grid">
      {products.map((product) => (
        <div key={product.id}>
          {/* Skeleton approach: clean, no extra assets needed */}
          <ImageWithSkeleton
            src={product.imageUrl}
            alt={product.name}
            width={400}
            height={300}
          />

          {/* OR Blur-up approach: needs a tiny thumbnail URL */}
          {/* <BlurUpImage
            src={product.imageUrl}
            tinySrc={product.tinyImageUrl}
            alt={product.name}
            width={400}
            height={300}
          /> */}

          <h3>{product.name}</h3>
        </div>
      ))}
    </div>
  );
}

// Output — user experience timeline:
// 0ms: Page renders. Grid appears with gray pulsing skeleton boxes.
//      All content around images is immediately visible.
//      No layout shifts — skeletons hold the exact image space.
//
// 200ms-2s: Images load progressively (visible ones first).
//           Each image fades in smoothly (opacity transition).
//           Skeleton disappears as each image completes.
//
// Result: The page feels instant. No blank space. No content jumping.
// CLS score: 0.00 (skeletons reserve exact dimensions)

Image & Asset Optimization visual 1


Image & Asset Optimization visual 2


Common Mistakes

Mistake 1: Lazy loading above-the-fold images

// BAD: The hero image is the first thing users see — lazy loading DELAYS it
// The browser waits until layout to discover it needs this image
function HeroSection({ heroUrl }) {
  return (
    <img
      src={heroUrl}
      alt="Hero banner"
      loading="lazy"       // This HURTS performance for above-the-fold images
      width={1200}
      height={400}
    />
  );
}

// GOOD: Above-the-fold images should load eagerly with high priority
function HeroSection({ heroUrl }) {
  return (
    <img
      src={heroUrl}
      alt="Hero banner"
      loading="eager"
      fetchpriority="high"  // Tells the browser to prioritize this download
      decoding="sync"       // Decode immediately — do not defer
      width={1200}
      height={400}
    />
  );
}

// Rule: Only lazy load images that are BELOW the fold
// The first 1-2 images the user sees should always load eagerly

Mistake 2: Missing width and height — causing Cumulative Layout Shift

// BAD: No dimensions — browser allocates 0 height, then shifts content when image loads
function Avatar({ user }) {
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      {/* When the image loads, everything below this jumps down */}
      {/* CLS penalty: 0.1 - 0.3+ depending on image size */}
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
    </div>
  );
}

// GOOD: Always set width and height OR use CSS aspect-ratio
function Avatar({ user }) {
  return (
    <div>
      {/* Option A: explicit width and height attributes */}
      <img src={user.avatar} alt={user.name} width={80} height={80} />

      {/* Option B: CSS aspect-ratio (useful for responsive images) */}
      {/* <img
        src={user.avatar}
        alt={user.name}
        style={{ width: "100%", aspectRatio: "1 / 1" }}
      /> */}

      <h2>{user.name}</h2>
      <p>{user.bio}</p>
    </div>
  );
}

// The browser reads width and height BEFORE downloading the image
// It reserves the exact space in the layout — zero shift when the image loads

Mistake 3: Using srcSet without the sizes attribute

// BAD: srcSet without sizes — browser assumes image is 100vw (full viewport width)
// On a 1440px desktop, it downloads the 1600w image even if the image only shows at 400px
function Thumbnail({ imageBase }) {
  return (
    <img
      srcSet={`
        ${imageBase}-400w.webp 400w,
        ${imageBase}-800w.webp 800w,
        ${imageBase}-1600w.webp 1600w
      `}
      src={`${imageBase}-800w.webp`}
      alt="Product thumbnail"
    />
  );
}

// GOOD: sizes tells the browser the actual display width at each breakpoint
function Thumbnail({ imageBase }) {
  return (
    <img
      srcSet={`
        ${imageBase}-400w.webp 400w,
        ${imageBase}-800w.webp 800w,
        ${imageBase}-1600w.webp 1600w
      `}
      sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
      src={`${imageBase}-800w.webp`}
      alt="Product thumbnail"
    />
  );
  // Now on a 1440px desktop, the browser knows the image displays at 400px
  // With 1x DPR it picks 400w, with 2x DPR it picks 800w — not 1600w
}

Interview Questions

Q: How does lazy loading images improve performance, and what are the two main approaches in React?

Lazy loading defers downloading off-screen images until the user scrolls near them. This reduces initial page weight, speeds up Time to Interactive, and saves bandwidth for users who never scroll to the bottom. The two approaches are: (1) Native HTML loading="lazy" — a single attribute on the <img> tag, handled entirely by the browser with zero JavaScript. (2) Intersection Observer API — a JavaScript approach that gives full control over thresholds, root margins, placeholder transitions, and load analytics. Native lazy loading is simpler but offers no customization. Intersection Observer is more work but lets you implement blur-up effects, custom loading distances, and placeholder patterns.

Q: Explain how srcSet and sizes work together. Why is sizes critical?

srcSet provides the browser with a list of image URLs and their pixel widths (e.g., image-400w.webp 400w, image-800w.webp 800w). The sizes attribute tells the browser how wide the image will actually display at each viewport size (e.g., (max-width: 640px) 100vw, 50vw). The browser multiplies the display width by the device pixel ratio, then picks the smallest srcSet image that covers that requirement. Without sizes, the browser assumes the image fills the full viewport width and always picks a larger file than necessary. On a 1440px desktop showing a 400px-wide thumbnail, missing sizes causes the browser to download an image 3-4x larger than needed.

Q: What is Cumulative Layout Shift (CLS) and how do images cause it? How do you fix it?

CLS measures unexpected visual shifts in the page layout. Images cause CLS when they load without reserved space — the browser initially renders the page with zero height for the image, then pushes all content below it downward when the image arrives. A CLS score above 0.1 is considered poor and hurts Core Web Vitals rankings. The fix is reserving space before the image loads using any of these methods: (1) Set explicit width and height attributes on <img> — the browser calculates the aspect ratio and reserves space. (2) Use CSS aspect-ratio property on the image or its container. (3) Use the padding-top percentage trick for responsive containers. (4) Use skeleton loaders or placeholder elements with the same dimensions as the final image.

Q: When should you use WebP vs AVIF vs JPEG? What are the tradeoffs?

JPEG is the universal fallback — every browser supports it, and it works well for photographs at moderate compression. WebP offers 25-35% smaller files than JPEG at the same quality, supports transparency (replacing PNG), and has near-universal browser support (95%+ globally). AVIF offers 40-50% smaller files than JPEG with excellent quality retention, but encoding is slower and browser support is narrower (Chrome 85+, Firefox 93+, Safari 16.4+). The production strategy is to serve all three using the <picture> element: <source type="image/avif"> first, <source type="image/webp"> second, and <img src="fallback.jpg"> last. The browser picks the first format it supports.

Q: Your product listing page has 50 images and a PageSpeed score of 40. Walk through your optimization strategy.

First, I lazy load all images below the fold using loading="lazy", cutting initial downloads from 50 to roughly 5-8 visible images. Second, I add explicit width and height to every <img> tag to eliminate CLS. Third, I convert all images to WebP and AVIF formats and use the <picture> element for format selection — this typically reduces total image weight by 40-60%. Fourth, I implement srcSet with sizes to serve appropriately-sized images per device — mobile users should not download desktop-resolution images. Fifth, I add skeleton loaders to improve perceived performance while images load. Sixth, for the hero image or first visible product image, I set fetchpriority="high" and loading="eager" so it loads as fast as possible. Combined, these steps typically improve the PageSpeed score from 40 to 80+ and reduce total image payload by 70-80%.


Quick Reference — Cheat Sheet

IMAGE & ASSET OPTIMIZATION
============================

Lazy Loading:
  Native:       <img loading="lazy" />         // Browser handles it, zero JS
  Observer:     new IntersectionObserver(cb)    // Full control, custom thresholds
  rootMargin:   "200px"                         // Preload 200px before viewport
  Rule:         NEVER lazy load above-the-fold images

Responsive Images (srcSet):
  srcSet:       srcSet="img-400w.webp 400w, img-800w.webp 800w"
  sizes:        sizes="(max-width: 640px) 100vw, 50vw"
  How it works: Browser picks smallest image >= display width * device pixel ratio
  Always set:   sizes attribute — without it, browser assumes 100vw

Modern Formats:
  JPEG:   Universal, good for photos, baseline fallback
  WebP:   25-35% smaller than JPEG, near-universal support, supports transparency
  AVIF:   40-50% smaller than JPEG, best quality/size ratio, growing support
  Serve:  <picture> with <source> tags: AVIF -> WebP -> JPEG fallback

Preventing CLS (Cumulative Layout Shift):
  Method 1:   Set width and height on <img> — browser reserves space
  Method 2:   Use CSS aspect-ratio: 16 / 9 on the image
  Method 3:   Padding-top hack: paddingTop = (height/width) * 100%
  Method 4:   Skeleton loader with fixed dimensions
  Target:     CLS < 0.1 (Core Web Vitals "good" threshold)

Placeholder Patterns:
  Skeleton:     Gray pulsing box matching image dimensions (no extra assets)
  Blur-up:      Tiny thumbnail (~20px wide, ~500 bytes) scaled and blurred
  Dominant color: Solid background color extracted from the image
  LQIP:         Low Quality Image Placeholder (10-20px thumbnail, base64 inline)

Above-the-fold Images:
  loading:          "eager" (NOT "lazy")
  fetchpriority:    "high"
  decoding:         "sync"
  Preload:          <link rel="preload" as="image" href="hero.webp" />

+---------------------------+-----------------------------------------------+
| Technique                 | Impact                                        |
+---------------------------+-----------------------------------------------+
| Lazy loading              | 40-60% fewer initial image downloads          |
| srcSet + sizes            | 50-80% bandwidth savings on mobile            |
| WebP format               | 25-35% smaller than JPEG                      |
| AVIF format               | 40-50% smaller than JPEG                      |
| Width/height attributes   | CLS reduced to 0                              |
| Skeleton/blur-up loaders  | Perceived load time reduced 50%+              |
+---------------------------+-----------------------------------------------+

Previous: Lesson 8.3 — Code Splitting & Lazy Loading -> Next: Lesson 9.1 — Higher-Order Components (HOC) ->


This is Lesson 8.4 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page