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/imagecorrectly — especiallysizes, 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-imageset 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/dynamicwithssr: falsearound 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 srcset — before 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:
- Open the page at each breakpoint.
- Inspect the image element, read its rendered CSS width (in the Computed panel).
- Express that as a
vwpercentage or a fixedpxat that breakpoint. - 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
priorityimage 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 (nopriority), 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}`;
}
Self‑hosting note: the built‑in optimizer runs
sharpin 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:
- Restrict the width allowlist. An open
?w=parameter lets anyone generate infinite variants, blowing out your transform bill and your cache hit ratio. Allowlist yourdeviceSizesand reject the rest. - 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. - Cache negative results. A missing image that 404s on every request at 38M sessions/month is a surprising amount of origin traffic.
- Vary on
Acceptcarefully.Vary: Acceptfragments the cache. Most image CDNs normalize this to a small number of buckets — verify yours does, or your hit ratio quietly drops. - 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¶
- Run the console waste snippet above on your PDP, PLP, and homepage. List every image with waste > 1.5×.
curlyour PDP and confirm the LCP image URL appears in the HTML. If not, fix that first.- In DevTools → Network, sort images by size. Anything over 200 KB above the fold is a bug.
- Check
priority: exactly one per page, on the actual LCP element (confirm the element via the LCP marker in the Performance panel). - Enable AVIF and re‑measure the LCP image's bytes.
- Record before/after LCP with
scripts/perf-compare.shfrom 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
-
sizesmatches actual rendered width at every breakpoint (verified, not guessed) - Exactly one
priorityimage per page, on the real LCP element - Everything below the fold is lazy
- AVIF with WebP fallback enabled
- Every image has reserved space (
aspect-ratioor 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