Skip to content

2.1 — Images

Module 2 · Lesson 1 · 🟢 Foundational · ~35 min

What you'll learn

  • The five image bugs that account for most commerce LCP problems, in diagnosis order
  • next/image correctly — especially sizes, which is wrong on most sites
  • Format strategy (AVIF/WebP/JPEG) and what it actually saves
  • Art direction, placeholders, galleries, and the 2.4M‑SKU pipeline economics

On a commerce site, images are typically 60–75% of page weight and the LCP element on nearly every template. This is the highest‑yield lesson in Module 2.


Diagnose in this order

Before changing anything, answer these five questions about your LCP image. Each maps to a specific fix, and they're ordered by how often they're the problem.

# Question If wrong Fix
1 Is the LCP image in the initial HTML? Preload scanner can't find it → +500–2000 ms Server‑render it
2 Is sizes correct? Downloading a 2200px image for a 390px slot → +400–1500 ms Fix sizes
3 Does it have priority / fetchpriority="high"? Queued behind other resources → +200–600 ms Prioritize
4 Is it AVIF/WebP? 30–60% more bytes than needed Format
5 Is it served from a CDN, close to the user, cached? +100–400 ms Pipeline

Bug 1 — The LCP image isn't in the HTML

The most expensive image bug in React commerce apps. If the URL is computed on the client, the preload scanner never sees it, and the request doesn't start until the JS bundle has downloaded, parsed, executed, and hydrated.

// ❌ Aurora's "before": the hero is inside a client-side carousel.
// Image request starts ~1.6s after HTML arrives.
'use client';
export function ProductGallery({ images }: { images: Img[] }) {
  const [active, setActive] = useState(0);
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);      // ← and it doesn't even render until mounted
  if (!mounted) return <div className="aspect-[3/4] bg-neutral-100" />;
  return <img src={images[active].url} alt={images[active].alt} />;
}
// ✅ After: first image is a Server Component, in the HTML, prioritized.
// The interactive layer hydrates on top of it.
import Image from 'next/image';

export function ProductGallery({ images }: { images: Img[] }) {
  return (
    <div className="relative aspect-[3/4]">
      {/* Server-rendered, discoverable by the preload scanner */}
      <Image
        src={images[0].url}
        alt={images[0].alt}
        fill
        priority
        sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 640px"
        className="object-cover"
      />
      {/* Client interactivity layered on; hydration doesn't gate the paint */}
      <GalleryControls images={images} />
    </div>
  );
}

Other common versions of this bug:

  • CSS background-image set from a JS variable — same problem.
  • Image gated behind a consent banner or A/B assignment — the image waits for a network round trip to a third party.
  • next/dynamic with ssr: false around the hero — guarantees the image is invisible to the preload scanner.
  • useEffect‑driven "is mobile?" checks that choose the image source. Use CSS or <picture> instead; the browser already knows the viewport.

Verify: view source (not the inspector — actual view-source: or curl) and search for your image URL. If it isn't there, that's your bug.

curl -s https://www.auroramarket.com/p/wool-overshirt-navy | grep -o 'images\.auroramarket[^"]*' | head

Bug 2 — The sizes attribute is wrong

sizes tells the browser how wide the image will be rendered, so it can pick the right candidate from srcsetbefore layout, before CSS is applied. Get it wrong and the browser downloads a 2200px image for a 390px slot. This is silent: nothing breaks, the page just weighs four times what it should.

// ❌ No sizes with `fill`: Next.js defaults to 100vw, so mobile downloads
//    a full-viewport-width image even though the tile is a third of the screen
<Image src={product.image} alt={product.name} fill />

// ❌ Lying: says 100vw but the grid renders 4 columns on desktop
<Image src={product.image} alt={product.name} fill sizes="100vw" />

// ✅ Describes the real layout at each breakpoint
<Image
  src={product.image}
  alt={product.name}
  fill
  sizes="(max-width: 640px) 50vw,
         (max-width: 1024px) 33vw,
         (max-width: 1536px) 25vw,
         360px"
/>

How to get sizes right, mechanically:

  1. Open the page at each breakpoint.
  2. Inspect the image element, read its rendered CSS width (in the Computed panel).
  3. Express that as a vw percentage or a fixed px at that breakpoint.
  4. Round up slightly — under‑requesting causes a blurry upscale, which is worse than a few extra KB.

Verify you fixed it: in DevTools → Network → Img, hover an image. Compare "Intrinsic size" (what you downloaded) against "Rendered size" (what's displayed), accounting for DPR.

Intrinsic: 2200 × 2933   Rendered: 390 × 520  DPR 2 → needed 780px
→ downloading 2.8× more pixels than needed  ❌

A one‑line audit you can run in the console on any page:

// Paste in the console: lists images downloading far more pixels than they render
[...document.images]
  .filter(img => img.naturalWidth > 0)
  .map(img => ({
    src: img.currentSrc.split('/').pop().slice(0, 40),
    intrinsic: img.naturalWidth,
    needed: Math.round(img.getBoundingClientRect().width * devicePixelRatio),
    waste: +(img.naturalWidth / (img.getBoundingClientRect().width * devicePixelRatio)).toFixed(2),
  }))
  .filter(r => r.waste > 1.5)
  .sort((a, b) => b.waste - a.waste);

Aurora's PLP was shipping sizes="100vw" on a 4‑column grid. Fixing the one attribute cut PLP image bytes by 68% and p75 LCP by 0.9 s. Two‑line diff.


Bug 3 — The LCP image is lazy or low priority

Browsers assign a low initial priority to images. priority in next/image sets fetchpriority="high" and adds a <link rel="preload">, moving it to the front of the queue.

// ✅ Exactly one priority image per viewport — the LCP element
<Image src={hero.url} alt={hero.alt} priority sizes="100vw" width={1600} height={900} />

// ✅ Everything below the fold: default lazy loading (next/image does this by default)
<Image src={p.image} alt={p.name} sizes="..." width={400} height={533} />

Rules:

  • One priority image per page. Marking six images priority is the same as marking none — they compete for bandwidth and the real LCP element arrives later.
  • Never lazy‑load above the fold. loading="lazy" on the hero costs 200–600 ms.
  • On a PLP, the first row is above the fold on desktop, the first 2 tiles on mobile. Prioritize by index, not by a fixed guess:
{products.map((p, i) => (
  <ProductCard
    key={p.id}
    product={p}
    // First 2 tiles are above the fold on mobile; first 4 on desktop.
    // Being slightly generous is fine; being wrong on the LCP element is not.
    priority={i < 4}
  />
))}
  • Carousels: slide 1 is priority, slide 2 is eager (no priority), slides 3+ are lazy.

Format strategy

Format Size vs JPEG q80 Support Use for
AVIF −45% to −55% All modern browsers Default for photography
WebP −25% to −35% Universal Fallback, and where AVIF encode cost is prohibitive
JPEG baseline Universal Final fallback
PNG Much larger Universal Only for images needing lossless/sharp edges
SVG Tiny Universal Icons, logos, flat illustration — always
// next.config.ts
import type { NextConfig } from 'next';

const config: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],   // AVIF first, WebP fallback
    // Only the widths your layouts actually request — every extra width is
    // another variant to generate, store, and cache-miss on
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [64, 128, 256, 384],
    minimumCacheTTL: 60 * 60 * 24 * 365,     // hashed/immutable product images
    remotePatterns: [
      { protocol: 'https', hostname: 'images.auroramarket.com', pathname: '/catalog/**' },
    ],
  },
};
export default config;

AVIF caveats worth knowing before you flip it on:

  • Encoding is 5–10× slower than WebP. With on‑demand optimization and 2.4M SKUs, the first request for each image pays that cost. Pre‑generate, or accept a slow first hit with a long cache TTL.
  • Below roughly 20 KB the advantage narrows, and AVIF can occasionally be larger for small flat graphics. Use SVG for those anyway.
  • Quality settings aren't comparable across codecs — AVIF q50 ≈ JPEG q75 visually. Don't port the quality number over.

next/image — the parts people get wrong

import Image from 'next/image';

// 1. Static import: dimensions and blur placeholder inferred at build time. Best for
//    fixed assets (logos, editorial). Not usable for a dynamic catalog.
import heroImg from '@/public/hero.jpg';
<Image src={heroImg} alt="Autumn collection" priority placeholder="blur" />

// 2. Remote with known dimensions: always supply width/height to reserve space (prevents CLS)
<Image
  src={product.imageUrl}
  alt={product.name}
  width={800}
  height={1067}
  sizes="(max-width: 768px) 100vw, 50vw"
/>

// 3. `fill` when the container defines the box — container MUST be positioned
<div className="relative aspect-[3/4]">
  <Image src={product.imageUrl} alt={product.name} fill sizes="..." className="object-cover" />
</div>

// 4. `unoptimized` when your image CDN already handles transforms —
//    don't pay for optimization twice
<Image src={cdnUrl} alt="" width={800} height={1067} unoptimized />

A custom loader is usually the right answer for a large catalog, because your image CDN is better at this than a Node process next to your app server:

// lib/image-loader.ts
export default function auroraLoader({
  src, width, quality,
}: { src: string; width: number; quality?: number }) {
  const params = new URLSearchParams({
    w: String(width),
    q: String(quality ?? 72),      // 72 is plenty for product photography in AVIF
    auto: 'format,compress',
    fit: 'crop',
  });
  return `https://images.auroramarket.com${src}?${params}`;
}
// next.config.ts
images: { loader: 'custom', loaderFile: './lib/image-loader.ts' }

Self‑hosting note: the built‑in optimizer runs sharp in your Node process. At Aurora's traffic that's real CPU competing with rendering, and the cache is per‑instance unless you configure shared storage. For a large catalog, use an image CDN with a custom loader.


Placeholders and CLS

Every image needs reserved space. Three approaches, in order of preference:

// 1. aspect-ratio on the container — works with `fill`, zero JS, no shift
<div className="relative aspect-[3/4]">
  <Image fill sizes="..." src={p.image} alt={p.name} />
</div>

// 2. Explicit width/height — next/image emits the aspect ratio into the style
<Image src={p.image} alt={p.name} width={800} height={1067} sizes="..." />

// 3. Blur placeholder for perceived quality (adds ~1KB of inline base64 per image —
//    fine for a hero, wasteful for 48 grid tiles)
<Image src={p.image} alt={p.name} width={800} height={1067}
       placeholder="blur" blurDataURL={p.blurHash} />

For a catalog, generate the tiny placeholder once at ingest and store it on the product record:

// scripts/generate-blur.ts — run at catalog ingest, not at request time
import sharp from 'sharp';

export async function generateBlurDataUrl(imageBuffer: Buffer): Promise<string> {
  const tiny = await sharp(imageBuffer)
    .resize(10, null, { fit: 'inside' })
    .webp({ quality: 20 })
    .toBuffer();
  return `data:image/webp;base64,${tiny.toString('base64')}`;
}

Don't put blur placeholders on 48 PLP tiles. 48 × 1 KB of inline base64 is 48 KB added to your HTML, which delays the LCP element it's meant to help. Use a flat neutral background for grids and blur only for the hero.


Art direction

sizes/srcset handles resolution. When mobile needs a genuinely different crop (a square lifestyle shot on mobile, a wide banner on desktop), you need <picture>:

<picture>
  <source
    media="(min-width: 1024px)"
    srcSet="/api/img/hero-wide.avif?w=1920 1920w, /api/img/hero-wide.avif?w=1280 1280w"
    sizes="100vw"
    type="image/avif"
  />
  <source
    media="(min-width: 1024px)"
    srcSet="/api/img/hero-wide.webp?w=1920 1920w, /api/img/hero-wide.webp?w=1280 1280w"
    sizes="100vw"
  />
  <img
    src="/api/img/hero-square.webp?w=828"
    srcSet="/api/img/hero-square.webp?w=640 640w, /api/img/hero-square.webp?w=828 828w"
    sizes="100vw"
    alt="Autumn collection"
    width={828} height={828}
    fetchPriority="high"
    decoding="async"
  />
</picture>

This is one of the few places to drop out of next/image — it doesn't do art direction. Keep the width/height on the <img> for CLS, and fetchPriority="high" for LCP.


The pipeline at 2.4M-SKU scale

Aurora has 2.4M SKUs × ~6 images × ~6 widths × 3 formats ≈ 259M possible variants. You cannot pre‑generate that. The working architecture:

Ingest ──▶ Origin store (S3-style)      one high-res master per image, ~2MB
       Image CDN with on-demand transform
              │  ?w=828&q=72&auto=format
              ├── cache HIT (99.3%)  → served in ~20ms from edge
              └── cache MISS → transform (~180ms) → store at edge, 1y TTL
       Browser (AVIF for supporting UAs via Accept negotiation)

Cost and correctness controls that matter at this scale:

  1. Restrict the width allowlist. An open ?w= parameter lets anyone generate infinite variants, blowing out your transform bill and your cache hit ratio. Allowlist your deviceSizes and reject the rest.
  2. Long TTLs with content‑addressed URLs. Put a hash or version in the path (/catalog/v3/sku-1234/main.jpg) so you can cache for a year and invalidate by changing the URL, never by purging.
  3. Cache negative results. A missing image that 404s on every request at 38M sessions/month is a surprising amount of origin traffic.
  4. Vary on Accept carefully. Vary: Accept fragments the cache. Most image CDNs normalize this to a small number of buckets — verify yours does, or your hit ratio quietly drops.
  5. Watch the p99 transform time. A cold cache during a catalog refresh (new season = 200K new images) can produce a transform stampede. Pre‑warm the top‑selling SKUs' primary image at the 6 most common widths — that's ~50K transforms, and it covers most traffic.

Aurora's numbers after the pipeline work:

Before After
Avg image bytes per PDP 1.42 MB 384 KB
LCP image bytes 340 KB (JPEG 2200px) 78 KB (AVIF 828px)
CDN image egress cost $41K/mo $12K/mo
p75 LCP contribution 1,270 ms 290 ms

Common mistakes

Mistake Cost
No sizes, or sizes="100vw" on a grid 2–4× the necessary bytes
priority on many images Bandwidth contention; the real LCP arrives later
Lazy‑loading above the fold +200–600 ms LCP
LCP image inside a 'use client' carousel +500–2000 ms LCP
Blur placeholders on every grid tile Bloated HTML, slower LCP
PNG for photography 3–5× the bytes
Optimizing an image that's already CDN‑optimized Double transform, wasted CPU
No width/height or aspect-ratio CLS
Open‑ended transform parameters Cache hit ratio collapse, cost spike

Lab 2.1 — Image audit

  1. Run the console waste snippet above on your PDP, PLP, and homepage. List every image with waste > 1.5×.
  2. curl your PDP and confirm the LCP image URL appears in the HTML. If not, fix that first.
  3. In DevTools → Network, sort images by size. Anything over 200 KB above the fold is a bug.
  4. Check priority: exactly one per page, on the actual LCP element (confirm the element via the LCP marker in the Performance panel).
  5. Enable AVIF and re‑measure the LCP image's bytes.
  6. Record before/after LCP with scripts/perf-compare.sh from 1.4.

Expected outcome on a site that's never done this: LCP −0.8 to −1.5 s, page weight −40 to −60%.


Checklist

  • LCP image server‑rendered and present in the initial HTML
  • sizes matches actual rendered width at every breakpoint (verified, not guessed)
  • Exactly one priority image per page, on the real LCP element
  • Everything below the fold is lazy
  • AVIF with WebP fallback enabled
  • Every image has reserved space (aspect-ratio or width/height)
  • Transform width allowlist enforced at the CDN
  • Blur placeholders only where they earn their bytes
  • Image CDN cache hit ratio monitored (target > 98%)

Next: 2.2 Fonts