completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Image CDNs and pipelines

Resize, convert and compress images automatically, at build time or on request: the Accept header, client hints, cached copies, and framework image components.

By the end, you can
  • 01
    Choose when images are madeSay what making every file at build time costs, and what making each one on request costs.
  • 02
    Explain how a pipeline picksRead the Accept header and the client hints a browser sends, and say which file a pipeline picks from them.
  • 03
    Keep the files it makesPick a cache key and a Vary header, and limit the widths, so each file is made once and reused.

One photo, made to order

Ravi has one photo of two ospreys on their nest. In Responsive images, he saved it at six widths, so each screen could get a file of the right size. In Image formats, he learned that AVIF and WebP make smaller files than JPEG.

Six widths in three formats is eighteen files. That's for one photo. His page in Loading images well has nineteen. And Ravi takes new photos every week.

Who makes all these files, and when?

The answer is an image pipeline: a program that turns one original into the file each visitor needs. It resizes the photo, converts it to a better format, and compresses it. An image CDN is a service that runs one for you. A CDN, a content delivery network, is a set of servers that keep copies of files close to visitors (CDNs and the edge). web.dev says an image CDN's address names "not only which image to load, but also parameters like size, format, and quality."

The course built a small pipeline and put Ravi's page on it. It uses sharp, the image library inside Next.js's own image optimizer, with the same settings. Ravi uploads one original. His page asks for each photo with an address like /img/osprey.jpg?w=1200: the photo, then the width. Asha opened the page on her phone, and Ravi on his desktop, in Chrome 157:

What one photo cost each visitorPop out in the code editor (opens in a new tab)JavaScript
// What one photo cost each visitor, in bytes, as recordedconst original = { width: 6720, bytes: 2220129 };const sent = { "Asha's phone": ["AVIF", 1200, 40953], "Ravi's desktop": ["AVIF", 800, 24073] }; console.log(`The original: ${original.width} px wide, ${original.bytes} bytes`);for (const [who, [format, width, bytes]] of Object.entries(sent)) console.log(`${who}: ${format}, ${width} px wide, ${bytes} bytes`);
  • Line 1 is a comment. Every size is in bytes, as the course recorded it.
  • Line 2 is the original: 6720 px wide and 2,220,129 bytes, about 2.2 MB.
  • Line 3 is what the pipeline sent each screen: the format, the width, and the bytes.
  • Lines 5 and 6 print The original: 6720 px wide, 2220129 bytes, Asha's phone: AVIF, 1200 px wide, 40953 bytes and Ravi's desktop: AVIF, 800 px wide, 24073 bytes.

Asha's photo is less than 2% of the original's bytes. Nobody saved that file by hand. Asha's request made it. Here is the whole trip:

One original photo, osprey.jpg, 6720 by 4480 pixels and 2,220,129 bytes, goes into the pipeline. Asha's phone asked for ?w=1200 and got a AVIF file 1200 pixels wide, 40,953 bytes, shown on its screen. Ravi's desktop asked for ?w=800 and got a AVIF file 800 pixels wide, 24,073 bytes, shown on its screen.The originalosprey.jpg6720 × 4480 px2,220,129 bytesthe pipelineTwo ospreys on a nest: the 1200 px AVIF fileAsha's phone?w=1200 → AVIF1200 px wide, 40,953 bytesTwo ospreys on a nest: the 800 px AVIF fileRavi's desktop?w=800 → AVIF800 px wide, 24,073 bytes
One original goes in. Each screen asked for a width to fit it, and got an AVIF made from the original, a small part of its size. The photos are the very files the pipeline sent.
Real-life analogyA photo lab that prints to order

You leave one negative at a photo lab. Customers come in and order prints of it: a postcard on glossy paper, a poster on matte. The lab prints each order from the negative. You never cut paper yourself.

In real life: The one negative you leave at the lab
In an image pipeline: The original photo, osprey.jpg
In real life: The order slip: the size and the paper
In an image pipeline: The address's width, ?w=1200, and the browser's Accept header
In real life: The lab
In an image pipeline: The image pipeline
In real life: The print a customer takes home
In an image pipeline: The file the visitor gets
The main idea

An image pipeline makes, from one original, the file each visitor needs: the right width, in the best format the browser can show. It can make every file before anyone visits, or each one the first time someone asks for it, and keep it.

The rest of the lesson answers five questions, one section each:

  • When is each file made? At build time, or on request.
  • Which format? The one the browser's Accept header allows.
  • Which width? The one the address names, or the one client hints ask for.
  • How is each file kept? In caches, under a cache key.
  • Who writes the addresses? Image components, in frameworks.

Build time or on request

There are two moments to make the files: before anyone visits, or when someone asks.

Making every file ahead of time

A build step is a script that runs before a site goes live, like npm run build. It can make every file at once. Here is such a script. It uses sharp, a Node.js library that resizes and converts images:

Every width in every format, at build timeJavaScript
import sharp from "sharp"; for (const width of [400, 800, 1200, 1600, 2000, 2400]) {  for (const format of ["avif", "webp", "jpeg"]) {    await sharp("osprey.jpg").resize(width).toFormat(format).toFile(`build/osprey-${width}.${format}`);  }}
  • Line 1 loads sharp.
  • Line 3 goes through the six widths. Line 4 goes through the three formats, for each width.
  • Line 5 opens the original, resizes it to width, converts it to format, and saves the file. It runs eighteen times.

The course did the same work with the pipeline's settings, one file after another, on an Apple M1 Pro:

What making them all costPop out in the code editor (opens in a new tab)JavaScript
// Making all the files of one photo ahead of time, as recordedconst build = { files: 18, ms: 4138.2, bytes: 1340280 };const photosOnThePage = 19; console.log(`${build.files} files in ${(build.ms / 1000).toFixed(1)} s, ${build.bytes} bytes`);console.log(`For the whole page: ${build.files * photosOnThePage} files`);
  • Line 2 holds what the build made: 18 files, in 4,138.2 ms, 1,340,280 bytes in all.
  • Line 3 is the number of photos on Ravi's page.
  • Line 5 prints 18 files in 4.1 s, 1340280 bytes. Line 6 prints For the whole page: 342 files.

Bigger files take longer: the three 2400 px files took more than half a second each. And the build makes every file, whether anyone asks for it or not. Astro's docs warn that making images during the build "may increase the build time of your project, especially if you have a large number of images."

Making each file on request

The other way is to wait. The pipeline makes a file when the first request for it arrives. web.dev puts it this way: image CDNs "create new versions of images as they're needed."

That moves the work. It doesn't remove it. Here is the 1200 px AVIF, made both ways:

The same file, made at two momentsPop out in the code editor (opens in a new tab)JavaScript
// Making the 1200 px AVIF, in ms, as recordedconst atBuildTime = 130.4;const onAshasRequest = 146.8; // the median of 3 runs console.log(`At build time: ${atBuildTime} ms, before anyone visits`);console.log(`On request: ${onAshasRequest} ms, while Asha waits`);
  • Line 2: at build time, it took 130.4 ms.
  • Line 3: on Asha's request, it took 146.8 ms, the median of three runs.
  • Lines 5 and 6 print At build time: 130.4 ms, before anyone visits and On request: 146.8 ms, while Asha waits.

It's about the same work. What changes is who waits. At build time, only the build waits. On request, the first visitor does: Asha's page waited 147.2 ms for the photo's first byte. The visitors after her don't, because the pipeline keeps the file. The section on caching shows how.

Real-life analogyBaking before opening, or cooking to order

A bakery bakes every loaf before it opens. Nobody waits at the counter, but loaves nobody buys still took oven time. A sandwich bar makes each sandwich when it's ordered. Nothing goes to waste, but the first customer waits while it's made.

In real life: Baking every loaf before the doors open
In an image pipeline: The build step, making every file
In real life: A loaf nobody buys
In an image pipeline: A file no visitor asks for
In real life: Making each sandwich when it's ordered
In an image pipeline: The pipeline making a file on request
In real life: The first customer's wait
In an image pipeline: The first request's wait, about 147 ms here

Read across a row to compare the two moments:

Build time or on request
QuestionAt build timeOn request
When is each file made?Before the site goes live, all at once.The first time a visitor asks for it.
Who waits for it?The build. One photo took 4.1 s for its 18 files.The first visitor. Asha waited about 147 ms for her photo.
Which files get made?Every width in every format, used or not.Only the ones visitors ask for.
What about a photo added later?It waits for the next build.It works at once.
Can it read the request?No. The files are made before any request.Yes. It can read the Accept header and client hints.

Why it matters: build time suits images you know before the site goes live, like a logo. On request suits photos that arrive later, like uploads. It also lets the pipeline read each request, which the next two sections need.

Format negotiation with Accept

Ravi's address ends in .jpg. Yet Chrome got an AVIF. How did the pipeline know Chrome could show AVIF?

Chrome told it. Every request carries headers: short lines of extra information. One of them, Accept, lists the kinds of file the browser can use. Here is the photo's request as the pipeline got it, and the start of its answer:

What Chrome asked, and what came backHTTP
GET /img/osprey.jpg?w=1200Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 HTTP/1.1 200 OKContent-Type: image/avifVary: AcceptX-Cache: MISS
  • Line 1 asks for the photo, 1200 px wide.
  • Line 2 is Chrome's Accept header for images. Each item is a media type, a kind of file's official name: image/avif is AVIF. image/* means any image, and */* means anything at all.
  • Line 4 starts the answer. Line 5, Content-Type, says what the file really is: AVIF.
  • Line 6, Vary: Accept, tells caches that the answer depends on the Accept header. Line 7 is the pipeline's own note: it made the file for this request. The section on caching comes back to both.

Look at the end of line 2: ;q=0.8. That's a weight from 0 to 1: how much the browser wants that item. RFC 9110, the HTTP standard, says an item with no weight counts as 1, and 0 means "not acceptable". So Chrome takes every image type on its list gladly, and anything else a little less.

Picking a file from the request's headers is called content negotiation. Here is how Ravi's pipeline does it. It's a small model written for the lesson, and the recorder ran this very function:

Picking the formatPop out in the code editor (opens in a new tab)JavaScript
function pickFormat(accept) {  if (accept.includes("image/avif")) return "avif";  if (accept.includes("image/webp")) return "webp";  return "jpeg";} console.log(pickFormat("image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"));console.log(pickFormat("image/webp,*/*"));console.log(pickFormat("*/*"));
  • Lines 1 to 5 are the function. Line 2 sends AVIF if the header lists image/avif. Line 3 sends WebP if it lists image/webp. Line 4 sends JPEG otherwise, because every browser shows JPEG.
  • Line 7 passes Chrome's header and prints avif.
  • Line 8 passes image/webp,*/*, the header MDN lists for Firefox 65 to 91. It prints webp.
  • Line 9 passes */*, MDN's header for Firefox 47 to 63. It prints jpeg.

Chrome's list starts with image/jxl: JPEG XL, which Chrome shows from version 155 (see Image formats). Ravi's pipeline can't make JPEG XL, so it sent the best format it can make. A pipeline can only choose among its own formats.

The course asked for the same address three times, with these three headers:

Three requests for the same address, /img/osprey.jpg?w=800. Chrome, whose Accept header lists image/avif, got AVIF: 24,073 bytes. Firefox 65 to 91, whose Accept header lists image/webp, got WebP: 30,662 bytes. Firefox 47 to 63, whose Accept header lists only */*, got JPEG: 34,637 bytes.GET /img/osprey.jpg?w=800Chromelists image/avifTwo ospreys on a nest, as AVIFAVIF24,073 bytesFirefox 65 to 91lists image/webpTwo ospreys on a nest, as WebPWebP30,662 bytesFirefox 47 to 63lists only */*Two ospreys on a nest, as JPEGJPEG34,637 bytes
The same address, asked three times with three Accept headers. The photos are the three files the pipeline sent. They look alike, but the AVIF, shaded, is the smallest.

Same address, same photo, three files. The AVIF is about 30% smaller than the JPEG, and an old browser still gets a JPEG it can show. Nobody changed the page.

So the address no longer tells you the format. Content-Type does. web.dev warns that this "can cause confusion if the file is saved to disk and used by another program that expects the format to match the file extension."

Real-life analogyTelling the waiter what you can eat

At a restaurant, you hand the waiter a card: "I can eat fish or chicken. Anything else, if I must." The kitchen cooks the best dish on your card that it knows how to make. The next table's card only says "anything", so they get the plain dish everyone can eat.

In real life: The card you hand the waiter
In an image pipeline: The Accept header
In real life: The dishes the kitchen knows how to make
In an image pipeline: The formats the pipeline makes
In real life: The best dish on both lists
In an image pipeline: The format it sends
In real life: "Anything else, if I must"
In an image pipeline: */*

Type any Accept header, and see what Ravi's pipeline sends:

Playground: one address, any Accept header
What comes back
  1. pickFormat() returns"avif"
  2. Content-Typeimage/avif
  3. Bytes24,073
Two ospreys on a nest: the 800 px AVIF file
Try it yourself
The pipeline sends AVIF

The header lists image/avif, so the pipeline makes its smallest format.

pickFormat() runs on what you type. The file and its bytes are what the pipeline really sent for /img/osprey.jpg?w=800 in that format. Recorded in Chrome 157 on an Apple M1 Pro, with sharp 0.34.3.

Why it matters: with content negotiation, one address gives each browser the smallest file it can show. Image CDNs offer the same: Cloudinary's f_auto and imgix's auto=format pick the format for you.

Client hints for images

The format came from a header. Could the width come from one too? Ravi's page lists six widths in srcset, and the browser picks one. That works in every browser. But the HTML has to list every width.

Client hints are request headers that describe the device, like its pixel ratio. The browser only sends them when the server asks. The server asks with a response header, Accept-CH. The course made a second version of Ravi's page whose answer asks for three:

The page asks for three hintsHTTP
HTTP/1.1 200 OKContent-Type: text/htmlAccept-CH: Sec-CH-DPR, Sec-CH-Width, Sec-CH-Viewport-Width
  • Lines 1 and 2 start an ordinary answer: the page's HTML.
  • Line 3 asks for three hints on the page's later requests.

That page's photo names no width:

A photo with sizes but no srcsetHTML
<img class="hero" src="/img/osprey.jpg" sizes="(min-width: 1000px) 600px, 100vw" alt="Two ospreys on a nest" width="2400" height="1600">

It has sizes but no srcset. The sizes attribute still tells the browser how wide the photo will be shown. Chrome sent three hints with the photo's request:

The hints Chrome sentPop out in the code editor (opens in a new tab)JavaScript
// The hints Chrome sent with the photo's request, as recordedconst hints = {  "Asha's phone": { dpr: 3, width: 1170, viewportWidth: 390 },  "Ravi's desktop": { dpr: 1, width: 600, viewportWidth: 1920 },}; for (const [who, hint] of Object.entries(hints)) console.log(`${who}: Sec-CH-Width ${hint.width}, from ${hint.width / hint.dpr} CSS px × ${hint.dpr}`);
  • Line 3 is Asha's phone: pixel ratio 3, a window 390 CSS px wide, and a width hint of 1170.
  • Line 4 is Ravi's desktop: 1, 1920 and 600.
  • Line 7 prints Asha's phone: Sec-CH-Width 1170, from 390 CSS px × 3 and Ravi's desktop: Sec-CH-Width 600, from 600 CSS px × 1.

Each hint has one job:

  • Sec-CH-DPR is the screen's pixel ratio: device pixels across one CSS pixel, from Responsive images.
  • Sec-CH-Viewport-Width is the window's width, in CSS pixels.
  • Sec-CH-Width is how wide the photo will be shown, in device pixels. Chrome works it out from sizes: 100vw is 390 CSS px, times 3. MDN says it's rounded up to a whole number.

Here is the whole conversation, from the top:

Asha's phone and the pipeline. First, GET /hints, with no hints. The answer has Accept-CH: Sec-CH-DPR, Sec-CH-Width, Sec-CH-Viewport-Width. Then GET /img/osprey.jpg with Sec-CH-DPR 3, Sec-CH-Width 1170 and Sec-CH-Viewport-Width 390. The answer: AVIF, 1200 pixels wide, 40,953 bytes.Two ospreys on a nest: the 1200 px file the pipeline pickedAsha's phonepipelineGET /hintsno hints yetAccept-CH: Sec-CH-DPR,Sec-CH-Width, Sec-CH-Viewport-WidthGET /img/osprey.jpgSec-CH-DPR: 3Sec-CH-Width: 1170Sec-CH-Viewport-Width: 390AVIF, 1200 px wide
Read from the top. The page's own request brought no hints. Its answer asked for three. The photo's request then brought them, and the pipeline picked the width from Sec-CH-Width.

Now the pipeline can choose the width. It rounds the hint up to one of its six widths:

Picking the widthPop out in the code editor (opens in a new tab)JavaScript
const widths = [400, 800, 1200, 1600, 2000, 2400]; function widthFor(hint) {  return widths.find((width) => width >= hint) ?? widths.at(-1);} console.log(widthFor(1170));console.log(widthFor(600));console.log(widthFor(5000));
  • Line 1 lists the six widths.
  • Lines 3 to 5 are the function. Line 4 finds the first width at least as big as the hint. If there is none, widths.at(-1) takes the last one, the largest.
  • Line 7 prints 1200 for Asha's hint, and line 8 prints 800 for Ravi's. Line 9 prints 2400: nothing on the list covers 5000.

Those are the same files the srcset version picked. Try other hints:

Playground: a width hint becomes a file
What the pipeline does
  1. widthFor() returns1200
  2. Cache key/img/osprey.jpg 1200 avif
  3. The AVIF's bytes40,953
Try it yourself
The pipeline sends the 1200 px file

1200 px is the smallest width on the list that covers 1170 px. Every hint up to 1200 shares this file.

widthFor() runs on what you type. The bytes are the AVIF of that width, made with the pipeline's settings when the course built every file. Recorded in Chrome 157 on an Apple M1 Pro, with sharp 0.34.3.
Real-life analogyTelling the shoe shop your size

In a shoe shop, you can read the size chart and take a box from the shelf yourself. Or you can tell the assistant your size, and they bring the right pair. Some shops put up a sign: "Tell us your size."

In real life: Reading the size chart and taking a box yourself
In client hints: srcset: the browser picks from a list
In real life: The sign saying "Tell us your size"
In client hints: Accept-CH
In real life: Telling the assistant your size
In client hints: Sending Sec-CH-Width
In real life: The assistant bringing one pair
In client hints: The pipeline picking the width

The rules client hints follow

Four rules matter for images:

  • Only after the server asks. The page's own request carried no hints. Only the requests after its answer did. MDN describes one more header, Critical-CH, that makes the browser send the page's request again, with the hints.
  • Only with sizes. Without a sizes attribute, the browser doesn't know how wide the photo will be. The responsive image hints spec says not to send Sec-CH-Width then.
  • Only to the page's own origin. An origin is the scheme, host and port of an address. Each hint is allowed for 'self' by default, the Client Hints Infrastructure spec says: the page's own origin.
  • Only over HTTPS. MDN marks these headers as secure-context only. Local test servers on localhost count as secure.

The third rule matters for image CDNs, which often live on another origin. The course moved the photo to a second origin and opened the page again. Then it did it once more, with the page passing the hints on. A page passes them on with Permissions-Policy:

Passing the hints to an image CDNHTTP
Accept-CH: Sec-CH-DPR, Sec-CH-Width, Sec-CH-Viewport-WidthPermissions-Policy: ch-dpr=(self "https://images.example"), ch-width=(self "https://images.example"), ch-viewport-width=(self "https://images.example")
  • Line 1 asks for the three hints.
  • Line 2 lets each hint go to self and to https://images.example too. The policies are called ch-dpr, ch-width and ch-viewport-width.
The photo on another originPop out in the code editor (opens in a new tab)JavaScript
// Asha's phone, with the photo on another origin, as recordedconst widthHint = { "without delegation": null, "with delegation": "1170" };const fileWidth = { "without delegation": 800, "with delegation": 1200 }; for (const page in widthHint) console.log(`${page}: Sec-CH-Width ${widthHint[page] ?? "not sent"}, a file ${fileWidth[page]} px wide`);
  • Line 2 holds the width hint Chrome sent each time. Line 3 holds the width of the file it got.
  • Line 5 prints without delegation: Sec-CH-Width not sent, a file 800 px wide and with delegation: Sec-CH-Width 1170, a file 1200 px wide.

Without the hints, the pipeline guessed its default width, 800 px, for Asha's 3x phone. Her photo came out softer than it could have been.

One more thing changes. With srcset, Chrome knew the 1200 px file was meant for a slot 390 CSS px wide: the photo's naturalWidth was 390. On the hints page, the same file's naturalWidth was 1200, because nothing told Chrome what the file was for. Ravi's CSS sets the photo's width, so it still showed at the right size. Give such photos a width in CSS too.

Why it matters: only Chrome and other Chromium browsers send these hints. Firefox and Safari don't. So hints can't replace srcset. A pipeline that reads them must also cope without them, as Ravi's does with its default width.

Caching transformed images

Back to Asha's first visit. The pipeline spent about 147 ms making her photo. What happened when the next visitor asked for it?

The course played the visits in order, three times, each time with the pipeline's cache empty at the start:

The same photo, three visitorsPop out in the code editor (opens in a new tab)JavaScript
// The pipeline's time for the photo, in ms, the median of 3 runsconst pipeline = { "Asha, first": 146.8, "Ana, next": 0.1, "Ravi, on his desktop": 129.7 }; for (const [visit, ms] of Object.entries(pipeline)) console.log(`${visit}: ${ms} ms`);
  • Line 2 holds the pipeline's time for each visit's photo, the median of three runs.
  • Line 4 prints Asha, first: 146.8 ms, Ana, next: 0.1 ms and Ravi, on his desktop: 129.7 ms.

Ana's phone is just like Asha's. She asked for the same file, and the pipeline already had it. Her page waited 0.5 ms for the first byte, against Asha's 147.2. Ravi asked for a width nobody had asked for yet, so the pipeline made a new file.

A pipeline files each copy it makes under a cache key: the parts of a request that make two requests "the same". Ravi's pipeline uses the photo, the width and the format:

Ravi's cache keyPop out in the code editor (opens in a new tab)JavaScript
function cacheKey(path, width, format) {  return `${path} ${width} ${format}`;} console.log(cacheKey("/img/osprey.jpg", 1200, "avif"));console.log(cacheKey("/img/osprey.jpg", 800, "avif"));
  • Lines 1 to 3 join the three into one string.
  • Line 5 prints /img/osprey.jpg 1200 avif: Asha's key, and then Ana's. Line 6 prints /img/osprey.jpg 800 avif, Ravi's.

When the key is new, the pipeline makes the file: a miss. When it already has the key, it sends its copy: a hit. Its X-Cache header said which, like Next.js's X-Nextjs-Cache. Here are five visits, in order:

Five visits in order. Asha, first: key 1200 avif, MISS, 146.8 ms. Asha, again: no request reached the pipeline; the photo came from her own browser's cache, 0 bytes transferred. Ana, next: key 1200 avif, HIT, 0.1 ms. Ravi, desktop: key 800 avif, MISS, 129.7 ms. Asha, with hints: key 1200 avif, HIT, 0 ms.WhoCache keyThe pipeline's time, median of 3 runsAsha, first1200 avifMISS, 146.8 msAsha, againno request: her own browser's cacheAna, next1200 avifHIT, 0.1 msRavi, desktop800 avifMISS, 129.7 msAsha, with hints1200 avifHIT, 0 ms
Each row is one visit, in order. The shaded bars are misses: the pipeline made the file. A key it had already seen was a hit, and Asha's second visit never reached the pipeline at all.

Two caches were at work:

  • The pipeline's cache served Ana. It served the hints page too: Asha's hint, 1170, became 1200, her key from before.
  • Asha's second visit never reached the pipeline. Her own browser had kept the file, because the answer said Cache-Control: public, max-age=31536000: keep it for a year (see HTTP caching in depth). Resource Timing showed 0 bytes over the network.
Real-life analogyThe lab's drawer of prints

Back at the photo lab, every print goes into a drawer, labelled with its size and paper. When the next customer orders the same size on the same paper, the clerk takes it from the drawer. A new size means a new print.

In real life: The drawer of finished prints
In an image pipeline: The pipeline's cache
In real life: The label on each print: its size and paper
In an image pipeline: The cache key: photo, width and format
In real life: Taking a print from the drawer
In an image pipeline: A hit
In real life: Printing a new one
In an image pipeline: A miss

Keeping the cache small

Each new key is a new file to make and keep. If the pipeline made any width it was asked for, anyone could ask for 801, 802 and 803 px, and fill it. So Ravi's pipeline allows only its six widths. Asked for ?w=801, it answered with a 400 error: Width 801 isn't allowed.

Next.js's optimizer does the same. Asked for a width outside its list, it answered "w" parameter (width) of 500 is not allowed. web.dev describes another guard: a security key in the address. Without it, nobody else can create new versions of your images. Rounding hints helps too: every hint from 801 to 1200 shares one file.

Caches between the pipeline and the browser

A CDN often sits in front of a pipeline and keeps copies too. It must keep one copy per format, or it could hand Chrome's AVIF to a browser that can't show AVIF. That's the job of Vary: Accept. MDN says Vary "ensures that responses are separately cached based on the headers listed". Next.js's docs add that a proxy or CDN in front of its optimizer must pass the Accept header on.

Hints need Vary too: the hints page's photo came with Vary: Accept, Sec-CH-Width. But MDN warns that varying on a header whose value changes a lot "effectively makes the resource uncacheable". A shared cache keeps one copy per value, and widths differ from phone to phone.

Step through every request the course sent, in order:

Playground: every request, one at a time
What happened
  1. Address/img/osprey.jpg?w=1200
  2. Acceptimage/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
  3. Client hintsnone
  4. AnswerAVIF, 1200 px wide, 40,953 bytes
  5. X-CacheMISS
  6. VaryAccept
  7. The pipeline's time146.8 ms
  8. The page's wait for the first byte147.2 ms
Try it yourself
Asha opens the page on her phone

Nobody had asked for this width and format yet. The pipeline made the file from the original: a miss.

Recorded in Chrome 157 on an Apple M1 Pro, with sharp 0.34.3. Times are medians of 3 runs, each starting with the pipeline's cache empty. Everything else was the same in every run.

Why it matters: only the first visitor pays for each new file. Everyone after gets it in a fraction of a millisecond. Keep the key small and the widths few, and almost every request is a hit.

Image components in frameworks

Most sites don't write their own pipeline. Their framework comes with an image component: a tag you write instead of <img>. It writes the srcset for you, and points each width at a pipeline.

next/image and its optimizer

In Next.js, the component is Image, from next/image. The course's Next.js lesson Optimizing images recorded this one, in Next.js 16:

A photo with next/imageJSX
<Image src="/teas/green-tea.jpg" alt="A cup of green tea on a saucer" width={400} height={267} />

Next.js wrote this tag for it:

The tag next/image wroteHTML
<img  alt="A cup of green tea on a saucer"  loading="lazy"  width="400"  height="267"  decoding="async"  data-nimg="1"  style="color:transparent"  srcset="/_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75 1x, /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=828&q=75 2x"  src="/_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=828&q=75">
  • Lines 4 and 5 are the width and height you gave, so the page keeps the photo's space.
  • Line 9 is the srcset. Each address goes to /_next/image, Next.js's own pipeline: url is the original, w the width, and q the quality. A photo shown 400 px wide gets two widths: 640 for 1x screens and 828 for 2x. They're the first widths on Next.js's list that cover 400 and 800.
  • Line 10 is the src, for browsers that don't read srcset.
  • Lines 3 and 6 turn on lazy loading and async decoding, from Loading images well.

The optimizer behind it does what Ravi's pipeline does:

What /_next/image sentPop out in the code editor (opens in a new tab)JavaScript
// next/image's optimizer, for a 400 px photo at w=640. Bytes, as recorded.const original = 91879; // green-tea.jpg, 1600 px wideconst sent = { "Chrome, first": ["image/webp", 3942, "MISS"], "Chrome, again": ["image/webp", 3942, "HIT"], "no Accept header": ["image/jpeg", 7178, "MISS"] }; for (const [request, [type, bytes, cache]] of Object.entries(sent)) console.log(`${request}: ${type}, ${bytes} bytes, ${cache}`);
  • Line 2 is the original: 91,879 bytes.
  • Line 3 holds three requests for the 640 px photo: the type that came back, its bytes, and the cache's answer.
  • Line 5 prints Chrome, first: image/webp, 3942 bytes, MISS, Chrome, again: image/webp, 3942 bytes, HIT and no Accept header: image/jpeg, 7178 bytes, MISS.

Its settings live in next.config.js, under images. Next.js 16's source sets these defaults:

  • formats is ['image/webp']. That's why Chrome got WebP, not AVIF. With 'image/avif' added, the same request got a 3,259-byte AVIF.
  • w must come from deviceSizes or imageSizes. Anything else is refused, as above.
  • minimumCacheTTL is 14400 seconds: 4 hours. It's the shortest time an optimized photo stays fresh in the optimizer's own cache. The recorded answers, for a photo in public/, said public, max-age=14400, must-revalidate. Next.js 16 raised this default.

Images and fonts under the hood follows one request through the optimizer's code.

Pointing a component at an image CDN

A component can use someone else's pipeline too. In Next.js, a loader is a function that turns a photo and a width into an address. Here is one for Ravi's pipeline:

A loader for Ravi's pipelinePop out in the code editor (opens in a new tab)JavaScript
function ravisLoader({ src, width }) {  return `/img${src}?w=${width}`;} console.log(ravisLoader({ src: "/osprey.jpg", width: 400 }));console.log(ravisLoader({ src: "/osprey.jpg", width: 800 }));
  • Lines 1 to 3 are the loader. It gets src and width, and returns Ravi's address.
  • Lines 5 and 6 print /img/osprey.jpg?w=400 and /img/osprey.jpg?w=800.

Next.js calls the loader once for each width it puts in the srcset. Those widths come from its own list. Left as it is, the list would ask Ravi's pipeline for 640 and 828, which it refuses. So set the list to the pipeline's widths:

Using the loader everywhereJavaScript
// next.config.jsmodule.exports = {  images: {    loader: "custom",    loaderFile: "./ravis-loader.js",    deviceSizes: [400, 800, 1200, 1600, 2000, 2400],  },};
  • Line 4 says a custom loader makes the addresses. Line 5 names the loader's file.
  • Line 6 sets the widths to the pipeline's six.

Other frameworks work the same way:

  • Astro's <Image /> makes its files during the build for pages built ahead, and on request for pages rendered on demand. Its default image service is sharp.
  • Angular's NgOptimizedImage comes with loaders for Cloudflare, Cloudinary, ImageKit, imgix and Netlify. With a loader, it writes the srcset for you.

Read across a row to see where the width and the format go in four pipelines' addresses:

The same photo, four pipelines
PipelineThe 800 px photo's addressHow the format is picked
Ravi's pipeline/img/osprey.jpg?w=800From the Accept header, every time.
Next.js's optimizer/_next/image?url=%2Fosprey.jpg&w=828&q=75From the Accept header, among images.formats. w must be one of its sizes, so 828.
Cloudinaryhttps://res.cloudinary.com/<cloud>/image/upload/w_800,f_auto/osprey.jpgf_auto asks it to pick.
imgixhttps://<source>.imgix.net/osprey.jpg?w=800&auto=formatauto=format asks it to pick: AVIF, then WebP, then JPEG or PNG.

Why it matters: with a component, you write one tag with the photo's real size. You get a srcset, widths the pipeline allows, and a format picked for each browser.

Where should each image be made?

Some images are known before the site goes live. Others arrive later, or depend on the request. For each one below, ask: can the build know it, and does it need the request?

Build time or on request?
  • The logo and icons that ship with the site's code
  • Photos sellers upload to a marketplace all day
  • A photo in a blog post, on a page built ahead
  • Profile photos people change whenever they like
  • Fifty thousand product photos, with a build that must stay quick
  • A photo whose width should follow the Sec-CH-Width hint
Try it yourself
0 of 6 correct

Sort each image by when its files should be made.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The .jpg in the address means the file is a JPEG." No. Content-Type says what came. Chrome got an AVIF from /img/osprey.jpg?w=1200.
  • "An image CDN makes every file when you upload a photo." No. It makes each file the first time someone asks for it, then keeps it. Asha's miss took about 147 ms.
  • "Client hints work in every browser." No. Only Chromium browsers send them. Keep srcset for Firefox and Safari.
  • "The browser sends client hints with every request." No. It sends them only after the server asks with Accept-CH, and only to the page's own origin unless the page passes them on.
  • "A pipeline should make any width it's asked for." No. Every width is a new file to make and keep. Ravi's pipeline refused 801, and Next.js refuses widths outside its lists.
  • "next/image sends AVIF to every browser that can show it." No. Its default formats list has WebP alone. Add AVIF to images.formats to get it.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upA very old Firefox

This is the pipeline's format picker, with another Accept header. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function pickFormat(accept) {
  if (accept.includes("image/avif")) return "avif";
  if (accept.includes("image/webp")) return "webp";
  return "jpeg";
}

console.log(pickFormat("image/png,image/*;q=0.8,*/*;q=0.5"));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upAna's tablet

    Ana opens the hints page on a tablet. The photo fills its 820 px window, at pixel ratio 2. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const widths = [400, 800, 1200, 1600, 2000, 2400];
    
    function widthFor(hint) {
      return widths.find((width) => width >= hint) ?? widths.at(-1);
    }
    
    console.log(`Ana's tablet: ${widthFor(820 * 2)}`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeA hint of 1500

      A phone sends a width hint of 1500. How many bytes is the AVIF that Ravi's pipeline sends?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeHints on this page

        This page shows the osprey photo the pipeline made for Asha. Does Chrome's request for that file carry a Sec-CH-DPR header?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA key that forgot something

          This pipeline keeps what it picks in a cache. Chrome asks first, then an older Firefox. What does the last line print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function pickFormat(accept) {
            if (accept.includes("image/avif")) return "avif";
            if (accept.includes("image/webp")) return "webp";
            return "jpeg";
          }
          
          const cache = new Map();
          function answer(path, width, accept) {
            const key = `${path} ${width}`;
            if (!cache.has(key)) cache.set(key, pickFormat(accept));
            return cache.get(key);
          }
          
          console.log(answer("/img/osprey.jpg", 800, "image/avif,image/webp,*/*"));
          console.log(answer("/img/osprey.jpg", 800, "image/webp,*/*"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe old cache time

            Next.js 16 changed the default of images.minimumCacheTTL to 4 hours. What was the default before?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what the request said, and what the pipeline already had.

              Image CDNs and pipelines quiz · 8 questionsScore: first tries count
              1. Question 1 of 8In this example, what does the second line print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const widths = [400, 800, 1200, 1600, 2000, 2400];
                
                function widthFor(hint) {
                  return widths.find((width) => width >= hint) ?? widths.at(-1);
                }
                
                console.log(widthFor(800));
                console.log(widthFor(801));

                Choose an answer to see the explanation.

              2. Question 2 of 8Chrome's Accept header lists image/jxl first. Why did Ravi's pipeline still send AVIF?

                Choose an answer to see the explanation.

              3. Question 3 of 8In the recording, why did Ana wait far less than Asha for the same photo?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does Vary: Accept tell a cache?

                Choose an answer to see the explanation.

              5. Question 5 of 8In the recording, Asha opened a page whose photo was on another origin, with no delegation. What happened?

                Choose an answer to see the explanation.

              6. Question 6 of 8Which browsers send Sec-CH-Width with image requests?

                Choose an answer to see the explanation.

              7. Question 7 of 8Why does a pipeline refuse widths that aren't on its list?

                Choose an answer to see the explanation.

              8. Question 8 of 8With Next.js's default settings, which format does next/image send to Chrome?

                Choose an answer to see the explanation.

              Key takeaways

              • An image pipeline turns one original into the file each visitor needs: resized, converted and compressed.
              • At build time, every file is made before anyone visits. On request, each one is made the first time it's asked for, and the first visitor waits.
              • The Accept header says which formats the browser can show. Content-Type says what came. The address doesn't.
              • Client hints come only from Chromium browsers, only after Accept-CH, and only to the page's own origin unless the page passes them on.
              • Key each file by photo, width and format. Allow only a few widths, and send Vary: Accept.
              • Image components write the srcset and point it at a pipeline: the framework's own, or an image CDN's.

              Remember the one-liner.
              One original in. The right width, in the best format each browser accepts, made once and kept.

              Sources

              The facts on this page come from these docs:

              Coming next: SVG and icons, also in Loading content fast: LCP. Optimize SVG, choose between inline, sprites, and files, and drop icon fonts.

              CompleteFrontend Clear concepts. Working examples.