next/image: the resize pipeline, srcset, and what every variant costs
next/image renders an img with a srcset of on-demand AVIF/WebP variants from /_next/image, lazy-loads below the fold, and requires dimensions so layout never shifts. priority marks the LCP image; remotePatterns gates the optimizer; every variant costs CPU or per-image billing.
A retailer relaunches its storefront on Next.js and celebrates — until someone runs Lighthouse on a mid-range Android over 4G: LCP 5.9 seconds. The hero is a 2.4 MB, 4000-pixel-wide JPEG, served identically to a 360-pixel phone and a 4K monitor, and it’s lazy-loaded because a <img loading="lazy"> snippet got copy-pasted everywhere “for performance”. The fix takes an afternoon: swap to next/image, put priority on the hero, let the optimizer serve a 640-pixel AVIF that weighs 38 KB instead of 2.4 MB. LCP lands at 1.8 s and conversion on mobile ticks up measurably. Three weeks later finance asks about a new line on the Vercel invoice: the optimizer has been transforming all 40,000 catalog photos, in several widths each, and image optimization is metered per source image. Nobody had read the part where the magic component is also a billing surface. next/image is both stories at once — this lesson is about exactly what it does, and what each variant costs.
What the component actually renders: srcset plus an optimizer endpoint
next/image is not a smarter <img> at runtime — it renders a plain <img>, but with a generated srcset whose every candidate points at the built-in optimizer route: /_next/image?url=<source>&w=<width>&q=75. The widths come from config: deviceSizes (defaults 640, 750, 828, 1080, 1200, 1920, 2048, 3840) for full-width images, imageSizes (defaults 16 through 384) for small fixed ones. The browser — not Next — then picks a candidate using the sizes attribute you provide, which is why a fill-width image without sizes quietly downloads a desktop-sized variant on a phone: the default assumption is 100vw of the largest viewport.
The first request for a given (url, width, quality) triple does real work: the optimizer (sharp, on a self-hosted server) fetches the source, resizes it, and re-encodes it into the best format the browser advertises in its Accept header — AVIF first, then WebP, then the original format. The result is cached on disk under .next/cache/images and re-served until minimumCacheTTL (or the upstream Cache-Control) expires it. The format negotiation is where most of the byte savings live: WebP is typically 25–35% smaller than an equivalent-quality JPEG, and AVIF another ~20–30% below WebP — that 2.4 MB hero becoming 38 KB is resize and format together.
import Image from "next/image";
import hero from "./hero.jpg"; // static import: width/height inferred, blurDataURL generated
export default function Home() {
return (
<>
<Image src={hero} alt="Spring collection" priority sizes="100vw" />
<Image
src="https://cdn.example.com/sku/8841.jpg" // remote: dimensions are YOUR job
alt="Linen shirt"
width={400}
height={500}
sizes="(max-width: 768px) 50vw, 400px"
/>
</>
);
}Tradeoff: on-demand transformation means you never pre-generate variants you don’t need, but it also means the first visitor to each variant pays the encode latency (AVIF encoding of a large source can take hundreds of milliseconds of CPU), and a cleared cache — say, a fresh deploy that doesn’t persist .next/cache — makes everyone a first visitor again.
Dimensions kill CLS; priority saves LCP
The component refuses to render without width and height (or fill inside a positioned, sized parent) — and that’s not bureaucracy, it’s the CLS mechanism. With dimensions known up front, the browser reserves the box via aspect-ratio before a single image byte arrives, so the text below never jumps when the image lands. Cumulative Layout Shift above 0.1 fails Core Web Vitals at p75, and unsized images are historically its top cause. The classic fill failure: a parent without position: relative and a real height collapses to zero or lets the image escape the layout — the prop moved the sizing responsibility to your CSS, and your CSS didn’t take the call.
Loading behavior is the second contract. Every next/image is loading="lazy" by default — correct for the 30 product cards below the fold, catastrophic for the hero. A lazy LCP image waits for layout, then for the intersection observer, then fetches at default priority; that chain routinely adds a second or more to LCP, whose “good” threshold is 2.5 s. The priority prop flips all of it: eager loading, fetchpriority="high", and a preload hint so the fetch starts with the document. The rule senior teams enforce in review: the LCP element on every templated page gets priority; nothing else does — preloading five images means prioritizing none of them.
▸Why this works
Why does Next make lazy the default when it burns LCP on heroes? Because statistically most images on a page are below the fold, and eagerly fetching all of them competes for bandwidth with the one image that matters. The default optimizes the many at the cost of making the one — your LCP element — an explicit, deliberate annotation. Next even tries to help you find mistakes: in development it warns when an image that was visible at load lacks priority. The honest mental model: lazy is the safe default for the herd, priority is the manual override you owe to exactly one image per viewport.
A hero image renders via next/image with correct width/height but no other props. Field data shows LCP at 4.6 s on mobile; the image itself is only 45 KB. What's the most likely cause?
remotePatterns and the bill: the optimizer is an attack and cost surface
Any remote src must match an entry in images.remotePatterns (protocol, hostname, optional port and pathname) or the request 400s — Next refuses to optimize hosts you didn’t allowlist. That strictness is not pedantry. Your /_next/image endpoint fetches arbitrary URLs, runs CPU-heavy encoding on the result, and caches the output: an open allowlist (hostname: "**") turns it into a free image-processing proxy for the entire internet. Anyone can point your endpoint at any image anywhere, and you pay — in CPU on a self-hosted box, in metered transformations on Vercel. Scope patterns to exact hosts and path prefixes (/sku/**), and treat a wildcard hostname in review the way you’d treat cors: * on an authenticated API.
The cost model differs by deployment and changes your architecture. On Vercel, optimization is metered per source image transformed (each plan includes a quota; a 40,000-SKU catalog blows through the included thousands fast, and every new source image after that is billable). Self-hosted, the costs are CPU and disk: sharp resizing a 4000-pixel JPEG takes on the order of 100–300 ms of CPU per variant, cache lives in .next/cache/images, and you must persist that directory across deploys or re-pay the encode for your whole catalog every release. The third option exits the pipeline entirely: a custom loader rewrites URLs to a dedicated image CDN (Cloudinary, imgix, Thumbor), keeping the next/image markup benefits — srcset, dimensions, lazy/priority — while moving transformation to infrastructure built for it.
Failure mode from production: a team self-hosts, deploys daily from a fresh container, and never mounts a volume for .next/cache/images. Every deploy, p95 image latency spikes to ~900 ms and the node’s CPU pegs for twenty minutes while sharp re-encodes the long tail of the catalog — then everything settles until the next deploy. Monitoring shows it as a daily sawtooth nobody can explain, because the regression isn’t in any diff.
To unblock a marketing team pasting image URLs from anywhere, a developer sets remotePatterns to hostname '**'. What did the site just sign up for?
- 01Walk the path from <Image> in JSX to bytes on the wire — what is generated, who chooses, what runs on first request?
- 02Why are width/height required, why does the hero need priority, and what exactly does a wildcard remotePatterns cost you?
next/image is a markup generator plus an optimization service. The component renders a plain img whose srcset candidates all point at /_next/image with a url, a width from deviceSizes or imageSizes, and a quality (default 75); the browser selects among them using your sizes attribute, which is why omitting sizes on a viewport-width image silently downloads desktop bytes on phones. The optimizer does its work on the first request per variant: remotePatterns check, source fetch, sharp resize, and re-encode into the best format the Accept header offers — AVIF then WebP, compounding 25–35% and a further 20–30% savings over JPEG — then caches to .next/cache/images, a directory that must survive deploys or your whole catalog re-encodes every release as a daily CPU sawtooth. Dimensions are mandatory because they are the CLS mechanism: width and height (or fill inside a positioned, sized parent) let the browser reserve the box before bytes arrive, keeping you under the 0.1 threshold. Lazy loading is the default and right for everything below the fold, but the LCP image must carry priority — eager fetch, fetchpriority high, preload — or it waits behind layout and intersection detection and drags LCP past the 2.5 s line. Finally, the optimizer is a cost and attack surface: scope remotePatterns to exact hosts and paths because a wildcard turns your endpoint into a public image-processing proxy, and know your cost model — metered per source image on Vercel, CPU and persistent disk self-hosted, or a custom loader that keeps the markup benefits while delegating transformation to a dedicated image CDN. Now when you see a Lighthouse LCP above 2.5 s on a Next.js site, the first thing to check is whether the hero carries priority — and the first thing to check on an unexpected Vercel bill is what remotePatterns allows.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.