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.
- 01Choose when images are madeSay what making every file at build time costs, and what making each one on request costs.
- 02Explain how a pipeline picksRead the Accept header and the client hints a browser sends, and say which file a pipeline picks from them.
- 03Keep 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 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 bytesandRavi'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:
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
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:
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 toformat, 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:
// 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 printsFor 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:
// 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 visitsandOn 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.
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:
| Question | At build time | On 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:
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/avifis 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:
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 listsimage/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 printswebp. - Line 9 passes
*/*, MDN's header for Firefox 47 to 63. It printsjpeg.
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:
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."
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:
- pickFormat() returns"avif"
- Content-Typeimage/avif
- Bytes24,073

The header lists image/avif, so the pipeline makes its smallest format.
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:
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:
<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 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 × 3andRavi's desktop: Sec-CH-Width 600, from 600 CSS px × 1.
Each hint has one job:
Sec-CH-DPRis the screen's pixel ratio: device pixels across one CSS pixel, from Responsive images.Sec-CH-Viewport-Widthis the window's width, in CSS pixels.Sec-CH-Widthis how wide the photo will be shown, in device pixels. Chrome works it out fromsizes: 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:
Now the pipeline can choose the width. It rounds the hint up to one of its six widths:
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
1200for Asha's hint, and line 8 prints800for Ravi's. Line 9 prints2400: nothing on the list covers 5000.
Those are the same files the srcset version picked. Try other hints:
- widthFor() returns1200
- Cache key/img/osprey.jpg 1200 avif
- The AVIF's bytes40,953
1200 px is the smallest width on the list that covers 1170 px. Every hint up to 1200 shares this file.
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
sizesattribute, the browser doesn't know how wide the photo will be. The responsive image hints spec says not to sendSec-CH-Widththen. - 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
localhostcount 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:
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
selfand tohttps://images.exampletoo. The policies are calledch-dpr,ch-widthandch-viewport-width.
// 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 wideandwith 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 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 msandRavi, 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:
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:
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.
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:
- Address/img/osprey.jpg?w=1200
- Acceptimage/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
- Client hintsnone
- AnswerAVIF, 1200 px wide, 40,953 bytes
- X-CacheMISS
- VaryAccept
- The pipeline's time146.8 ms
- The page's wait for the first byte147.2 ms
Nobody had asked for this width and format yet. The pipeline made the file from the original: a miss.
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:
<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:
<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:urlis the original,wthe width, andqthe 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:
// 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, HITandno Accept header: image/jpeg, 7178 bytes, MISS.
Its settings live in next.config.js, under images. Next.js 16's source sets these defaults:
formatsis['image/webp']. That's why Chrome got WebP, not AVIF. With'image/avif'added, the same request got a 3,259-byte AVIF.wmust come fromdeviceSizesorimageSizes. Anything else is refused, as above.minimumCacheTTLis 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 inpublic/, saidpublic, 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:
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
srcandwidth, and returns Ravi's address. - Lines 5 and 6 print
/img/osprey.jpg?w=400and/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:
// 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
NgOptimizedImagecomes 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:
| Pipeline | The 800 px photo's address | How the format is picked |
|---|---|---|
| Ravi's pipeline | /img/osprey.jpg?w=800 | From the Accept header, every time. |
| Next.js's optimizer | /_next/image?url=%2Fosprey.jpg&w=828&q=75 | From the Accept header, among images.formats. w must be one of its sizes, so 828. |
| Cloudinary | https://res.cloudinary.com/<cloud>/image/upload/w_800,f_auto/osprey.jpg | f_auto asks it to pick. |
| imgix | https://<source>.imgix.net/osprey.jpg?w=800&auto=format | auto=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?
- 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
Sort each image by when its files should be made.
Common mix-ups
- "The .jpg in the address means the file is a JPEG." No.
Content-Typesays 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
formatslist has WebP alone. Add AVIF toimages.formatsto get it.
Practice exercises
Answer each one first, then open the hints if you need them.
This is the pipeline's format picker, with another Accept header. What does the last line print?
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"));jpeg. MDN lists this header for Firefox before version 47. It names neither image/avif nor image/webp, so pickFormat reaches its last line and sends JPEG, which that old Firefox can show.
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?
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)}`);Ana's tablet: 2000. 1600 is just short of 1640, so the first width that covers it is 2000.
A phone sends a width hint of 1500. How many bytes is the AVIF that Ravi's pipeline sends?
60,542 bytes. 1500 rounds up to 1600, and the 1600 px AVIF the course built was 60,542 bytes. Every hint from 1201 to 1600 gets that same file.
This page shows the osprey photo the pipeline made for Asha. Does Chrome's request for that file carry a Sec-CH-DPR header?
No. This site never sends Accept-CH, so Chrome doesn't add Sec-CH-DPR to its requests. In Firefox and Safari, the answer is no as well, because they don't send these hints at all.
This pipeline keeps what it picks in a cache. Chrome asks first, then an older Firefox. What does the last line print?
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,*/*"));const key = `${path} ${width} ${pickFormat(accept)}`;avif, which this browser can't show. The key leaves out the format, so the WebP browser's request finds Chrome's AVIF under the same key. Put the format in the key, and each format gets its own copy.
Next.js 16 changed the default of images.minimumCacheTTL to 4 hours. What was the default before?
60 seconds. The guide says the default "has changed from 60 seconds to 4 hours (14400 seconds)". Photos without their own Cache-Control were being checked again every minute.
Check your understanding
For each question, ask what the request said, and what the pipeline already had.
Question 1 of 8In this example, what does the second line print?
Read the code, then predictconst 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.
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.
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.
Question 4 of 8What does Vary: Accept tell a cache?
Choose an answer to see the explanation.
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.
Question 6 of 8Which browsers send Sec-CH-Width with image requests?
Choose an answer to see the explanation.
Question 7 of 8Why does a pipeline refuse widths that aren't on its list?
Choose an answer to see the explanation.
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-Typesays 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:
- Use image CDNs to optimize images, web.dev.
- Output options: toFormat, sharp.
- transform.ts, optimizeImage, Next.js.
- Images, Astro.
- List of default Accept values, MDN.
- RFC 9110: HTTP Semantics, Accept and Vary, the IETF.
- HTTP Client hints, MDN.
- Sec-CH-Width header, MDN.
- Responsive Image Client Hints, WICG.
- Client Hints Infrastructure, WICG.
- Secure contexts, MDN.
- Vary header, MDN.
- image-optimizer.ts, the widths and the cache key, Next.js.
- image-config.ts, the defaults, Next.js.
- Image component, Next.js.
- How to upgrade to version 16, Next.js.
- get-img-props.ts, getWidths, Next.js.
- Getting started with NgOptimizedImage, Angular.
- Image optimization, Cloudinary.
- Automatic, imgix.
Coming next: SVG and icons, also in Loading content fast: LCP. Optimize SVG, choose between inline, sprites, and files, and drop icon fonts.