Images and fonts under the hood
Read how Next.js 16 checks /_next/image requests, keys and stores resized photos, downloads Google fonts at build time and sizes their fallbacks.
- 01Follow an image requestSay which check refuses a bad url, w or q, when the optimizer fetches and resizes, and what each response header comes from.
- 02Read the image cacheWork out a request's cache key, find its folder in .next/cache/images, and read the parts of the file's name.
- 03Explain next/font's build workTell what the build downloads from Google, what -s and .p mean in a font file's name, and how the fallback's size-adjust is computed.
A photo's trip through /_next/image
Optimizing images showed next/image from the outside: an <img> whose files come from the image optimizer, the part of the Next.js server that answers at /_next/image, resizes a photo and keeps the result. Optimizing fonts showed next/font: a font downloaded while the app builds, with a fallback font sized to match. Inside Next.js 16.4.3, both are code you can read. The optimizer works when a request arrives; next/font works while next build runs.
Three words first. A hash is a short code worked out from some data: the same data always gives the same code, and different data almost always a different one. SHA-256 is the hash the optimizer uses. sharp is an npm package that resizes and converts images; the optimizer calls it for the actual work.
Here is the tea shop with a photo of green tea in public/teas. It's a real project, built with next build and run with next start. The recording asked /_next/image for photos seven times, the way a browser asks for the files of a srcset, then asked /image-cache, a Route Handler made only for this recording, what the optimizer had written to the disk:
app/image-cache/route.tsimport { readdir, stat } from "node:fs/promises";import { join } from "node:path";import { connection } from "next/server"; const folder = join(process.cwd(), ".next", "cache", "images"); export async function GET() { await connection(); const keys = (await readdir(folder).catch(() => [])).sort(); const entries = []; for (const key of keys) { const [name] = await readdir(join(folder, key)); const [maxAge, expireAt, etag, upstreamEtag, extension] = name.split(".", 5); const file = await stat(join(folder, key, name)); const upstream = Buffer.from(upstreamEtag, "base64url").toString(); entries.push({ folder: key, maxAge, expireAt: `saved + ${Math.round((Number(expireAt) - file.mtimeMs) / 1000)} s`, etag, upstreamEtag: upstream.replace(/-[0-9a-f]+"$/, '-<date>"'), extension, bytes: file.size, }); } return Response.json(entries);}GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75200
- Sent with the request
acceptimage/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
- Response headers
VaryAcceptCache-Controlpublic, max-age=14400, must-revalidateETagEZK2arlVzqj1ThoigZJXuLMi1O5l_FxhMfo5ByNc9h0Content-Typeimage/webpContent-Dispositionattachment; filename="green-tea.webp"Content-Security-Policyscript-src 'none'; frame-src 'none'; sandbox;X-Nextjs-CacheMISS
The body that arrived is an image, image/webp: 640 × 427 pixels, 3.9 kB
The Network tab opens on the first request: image/webp, 640 pixels wide, X-Nextjs-Cache: MISS. Pick the others. The same request again is a HIT. Without an Accept header the answer is a JPEG. w=3840 came back 1600 pixels wide. /teas/oolong.jpg came back as image/png. /logo.svg got 400. The last request, /image-cache, lists five folders on the disk.
In app/image-cache/route.ts, shown first, line 5 is the folder the optimizer writes to, and line 13 splits each file name into its parts. Reading .next from your own code is never a good idea: everything in it is this version's internals. Here it's a way to watch the optimizer, not a way to write an app.
A library's copy desk takes slips. The clerk first checks each slip against the rules on the wall: only these sizes, only these books. A good slip gets a ticket number made from what it asks for, and the clerk looks for a drawer with that number. If one is there and its label is still good, the copy goes out at once. If not, the clerk fetches the book, checks it really is a book, makes the copy and files it. Some books are bought from the publisher long before the library opens.
- In real life: The request slip, checked against the desk's rules
- In Next.js: validateParams: url, w and q against the images config
- In real life: A ticket number made from what the slip asks for
- In Next.js: The cache key: a hash of the address, width, quality and format
- In real life: A drawer with that number, and a label that says until when
- In Next.js: A folder in .next/cache/images, and a file named with its expiry
- In real life: The clerk checks the book is really a book, then copies it
- In Next.js: The first bytes say what the file is; sharp makes the copy
- In real life: Books ordered from the publisher before the library opens
- In Next.js: Fonts downloaded from Google during next build
The optimizer checks every request, turns a good one into a key, and only does work when the cache needs a new entry: on a miss, or to refresh a stale one. Then it fetches the original, reads its type from its first bytes, resizes it with sharp (unless an unchanged original lets it reuse the old copy), and writes it to .next/cache/images. next/font does its work earlier: during the build, Turbopack downloads the font from Google, names each file with marks the server reads later, and computes the fallback's size-adjust from measurements Next.js ships.
Checking url, w and q
A request to /_next/image reaches handleNextImageRequest in next-server.ts (lines 995 to 1144). Before anything else, it checks the query:
const paramsResult = ImageOptimizerCache.validateParams(
req.originalRequest,
parsedUrl.query,
this.nextConfig,
!!this.dev
)
if ('errorMessage' in paramsResult) {
res.statusCode = 400
res.body(paramsResult.errorMessage).send()
return true
}
const cacheKey = ImageOptimizerCache.getCacheKey(paramsResult)Lines 1 to 6 hand the query and the config to validateParams. Lines 8 to 12: if it answers with an errorMessage, the server sends 400 with that message as the whole body, and stops. Line 14 makes the cache key. Before line 1, two other cases are answered: a project with output: "export" gets 400 Bad Request, and one with a custom loader or unoptimized gets a 404 page (lines 1008 to 1062).
validateParams (image-optimizer.ts, lines 178 to 338) checks the three query values in order: url, then w, then q. The url first:
if (!url) {
return { errorMessage: '"url" parameter is required' }
} else if (Array.isArray(url)) {
return { errorMessage: '"url" parameter cannot be an array' }
}
// …
if (url.startsWith('/')) {
href = url
isAbsolute = false
if (
/\/_next\/image($|\/)/.test(
decodeURIComponent(parseUrl(url)?.pathname ?? '')
)
) {
return {
errorMessage: '"url" parameter cannot be recursive',
}
}
if (!hasLocalMatch(localPatterns, url)) {
return { errorMessage: '"url" parameter is not allowed' }
}
} else {
// …
if (!['http:', 'https:'].includes(hrefParsed.protocol)) {
return { errorMessage: '"url" parameter is invalid' }
}
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
return { errorMessage: '"url" parameter is not allowed' }
}
}- Lines 1 to 5: no
url, or several, is refused. (Left out: an address longer than 3,072 characters, or one starting with//.) - Lines 7 to 21: an address starting with
/is on this site. Lines 10 to 18 refuse the optimizer's own address, so a request can't ask it to optimize itself. Lines 19 to 21 checklocalPatterns; with none set, every local path is allowed. - Lines 22 to 31: anything else must parse as an address, use
httporhttps(lines 24 to 26), and match one ofremotePatterns(lines 28 to 30). The default list is empty, so no remote photo is allowed until you add one.
Then the width and the quality:
const sizes = [...(deviceSizes || []), ...(imageSizes || [])]
if (isDev) {
sizes.push(BLUR_IMG_SIZE)
}
const isValidSize =
sizes.includes(width) || (isDev && width <= BLUR_IMG_SIZE)
if (!isValidSize) {
return {
errorMessage: `"w" parameter (width) of ${width} is not allowed`,
}
}
const quality = parseInt(q, 10)
if (isNaN(quality) || quality < 1 || quality > 100) {
return {
errorMessage:
'"q" parameter (quality) must be an integer between 1 and 100',
}
}
if (
qualities &&
!qualities.includes(quality) &&
!(isDev && quality === BLUR_QUALITY)
) {
return {
errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
}
}Line 1 joins deviceSizes and imageSizes: 640 to 3840 and 32 to 384 by default (image-config.ts, lines 237 to 262). Lines 7 to 14 refuse a width that isn't in the list; lines 3 to 5 and the end of line 8 only matter in next dev, for the tiny blurred placeholder. Lines 16 to 23 refuse a quality outside 1 to 100, and lines 25 to 33 one that isn't in qualities, which is [75] by default since Next.js 16. The widths are a closed list on purpose: without it, anyone could make your server resize a photo to every width from 1 to 4000 and fill its disk with copies.
The last check isn't a refusal. It picks the format to send:
function getSupportedMimeType(options: string[], accept = ''): string {
const mimeType = mediaType(accept, options)
return accept.includes(mimeType) ? mimeType : ''
}Line 2 asks mediaType, from the @hapi/accept package Next.js bundles, which of the allowed formats (image/webp by default) the Accept header prefers. Line 3 keeps it only if the header contains that exact text. Chrome's header names image/webp; no header at all gives "", "keep the photo's own type". Here are refused requests, recorded with localPatterns and remotePatterns set:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, images: { localPatterns: [{ pathname: "/teas/**", search: "" }], remotePatterns: [new URL("https://images.teashop.example/teas/**")], },}; export default nextConfig;GET /_next/image?w=640&q=75400
- Response headers
The body that arrived, as it arrived
"url" parameter is requiredLines 7 to 10 of next.config.ts allow local paths under /teas/ only, with no query, and remote photos from one host. Pick each request: every refusal is 400 with one of the messages above, word for word. /logo.svg is refused here by localPatterns, before anything is fetched. The last request is a good one, for comparison. Now build requests of your own:
GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Hashed: "4/teas/green-tea.jpg64075image/webp"
Key: …
Folder: .next/cache/images/…/Working out the key… The recording asked exactly this: 200, image/webp, 640 × 427 pixels, X-Nextjs-Cache: MISS.
Fetching the original, then sharp
When the cache needs a new entry, on a miss or to refresh a stale entry, the optimizer needs the original photo, the upstream image. next-server's imageOptimizer picks one of two ways to get it (next-server.ts, lines 814 to 832). A local address is asked for inside the server itself:
const mocked = {
req: new MockedRequest({
url: href,
method,
headers: {},
socket: _req.socket,
}),
res: new MockedResponse({ maximumResponseBody }),
}
await handleRequest(mocked.req, mocked.res, parseReqUrl(href))
await mocked.res.hasStreamedLines 1 to 9 make a pretend request and response in memory, and line 11 hands them to handleRequest, which is the router server's own handler (next-server.ts, lines 779 to 793). So /teas/green-tea.jpg is answered exactly as a browser's request for it would be, through the steps From next dev to a response followed, without leaving the process. A remote address is fetched over the network, with one more check first:
if (!dangerouslyAllowLocalIP) {
const literalFamily = isIP(hostname)
let addresses: LookupAddress[]
if (literalFamily > 0) {
// A literal address is connected to as-is, without any DNS resolution
addresses = [{ address: hostname, family: literalFamily }]
} else {
addresses = await lookupAsSocketWould(hostname)
pinnedLookup = createPinnedLookup(hostname, addresses)
}
// …
const privateIps = addresses
.map((record) => record.address)
.filter((ip) => isPrivateIp(ip))
if (privateIps.length > 0) {
Log.error(
'upstream image',
href,
'hostname resolved to private IP',
JSON.stringify(privateIps),
'If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.'
)
throw new ImageError(400, '"url" parameter is not allowed')
}Line 1: unless you set dangerouslyAllowLocalIP. Lines 5 to 11 find the server's addresses with a DNS lookup, and line 10 makes the connection use exactly those, so a DNS answer that changes a moment later can't send it somewhere else. Lines 13 to 26 refuse a private address, like your own computer or your company's network, with 400; the images lesson recorded that log line. The rest of fetchExternalImage (lines 788 to 936) follows up to 3 redirects, checking each new address the same way, gives up after 7 seconds (504), and stops reading past 50 MB (413).
Now the optimizer has bytes. A file's name, or the server's Content-Type, can say anything, so it reads the bytes themselves. Most file formats begin with a fixed sequence of bytes, a signature:
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
return JPEG
}
if (
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every(
(b, i) => buffer[i] === b
)
) {
return PNG
}
// …
if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) {
return SVG
}
if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) {
return SVG
}Lines 1 to 3: a JPEG begins with the bytes ff d8 ff. Lines 4 to 10: a PNG with 89 50 4e 47 and four more: a marker byte, the letters PNG, then line endings. Lines 12 to 17: text starting with <?xml or <svg is taken for an SVG. The shop's oolong.jpg is really a PNG with a JPEG's name, and the recording shows it: asked for without an Accept header, it came back as image/png, named oolong.png. Then the type decides what happens:
const maxAge = Math.max(
nextConfig.images.minimumCacheTTL,
getMaxAge(imageUpstream.cacheControl)
)
const upstreamType = await detectContentType(upstreamBuffer)
if (
!upstreamType ||
!upstreamType.startsWith('image/') ||
upstreamType.includes(',')
) {
opts.logger?.error(
"The requested resource isn't a valid image for",
href,
'received',
upstreamType
)
throw new ImageError(400, "The requested resource isn't a valid image.")
}
if (
upstreamType.startsWith('image/svg') &&
!nextConfig.images.dangerouslyAllowSVG
) {
opts.logger?.error(
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled. Consider adding the "unoptimized" property to the <Image>.`
)
throw new ImageError(
400,
'"url" parameter is valid but image type is not allowed'
)
}
// …
if (BYPASS_TYPES.includes(upstreamType)) {
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge,
etag: upstreamEtag,
upstreamEtag,
}
}
let contentType: string
if (mimeType) {
contentType = mimeType
} else if (upstreamType === WEBP || upstreamType === AVIF) {
// Downlevel WebP and AVIF when the client does not advertise support.
contentType = JPEG
} else {
contentType = upstreamType
}- Lines 1 to 4: how long the result may be kept,
maxAge, is the larger ofminimumCacheTTL(4 hours by default) and the original's ownmax-age. - Lines 7 to 19: something that isn't an image is refused, with
The requested resource isn't a valid image. - Lines 20 to 31: an SVG is refused unless
dangerouslyAllowSVGis on. An SVG is a text file that can hold scripts, and resizing it gains nothing. - Lines 33 to 41: some types are sent back as they are, never resized (
BYPASS_TYPES: SVG, ICO, ICNS, BMP, JPEG XL and HEIC). Left out at line 32: an animated GIF, PNG or WebP is sent as it is too. - Lines 43 to 52: the format to write. The one the Accept header allowed; else a WebP or AVIF original becomes a JPEG, for an old browser; else the original's own type, like the oolong PNG.
Here is the SVG both ways:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true,}; export default nextConfig;GET /_next/image?url=%2Flogo.svg&w=640&q=75400
- Sent with the request
acceptimage/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
- Response headers
The body that arrived, as it arrived
"url" parameter is valid but image type is not allowedThe url, w and q pass the checks, so the optimizer fetches /logo.svg. Its first bytes are <svg, the type is image/svg+xml, and the answer is 400: "url" parameter is valid but image type is not allowed. The Terminal tab shows the error next start printed.
The allowed SVG was safe to open only because two of 16.4.3's defaults stayed in place: contentDispositionType: "attachment", so opening its address downloads the file instead of showing it, and contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", so nothing in it may run (image-config.ts, lines 254 to 256). If you turn on dangerouslyAllowSVG, keep both. Never set "inline" or a weaker policy for SVGs you didn't make yourself: an SVG shown on its own can run the scripts inside it.
Last, sharp. getSharp (transform.ts, lines 88 to 130) loads it once with require('sharp'), allows only the image formats it needs, and halves the number of threads sharp may use, to use less memory. In 16.4.3 there's no other image library to fall back on: Next.js 15 removed the old squoosh one, so without sharp the optimizer throws Module `sharp` not found. Please run `npm install --cpu=wasm32 sharp` to install it.
const sharp = getSharp(concurrency, operationCache)
const transformer = sharp(buffer, {
limitInputPixels,
sequentialRead: sequentialRead ?? undefined,
})
.timeout({
seconds: timeoutInSeconds ?? 7,
})
.rotate()
if (height) {
transformer.resize(width, height)
} else {
transformer.resize(width, undefined, {
withoutEnlargement: true,
})
}
if (contentType === AVIF) {
transformer.avif({
// Scale the quality to try and match webp. This ratio was derived
// from sharp's default 80 (webp) and 50 (avif), and then verified
// using dssim and ssimulacra2 visual quality tests.
quality: Math.max(Math.round(quality * (50 / 80)), 1),
effort: 3,
})
} else if (contentType === WEBP) {
transformer.webp({ quality })
} else if (contentType === PNG) {
transformer.png({ quality })
} else if (contentType === JPEG) {
transformer.jpeg({ quality, mozjpeg })
}
const optimizedBuffer = await transformer.toBuffer()Lines 2 to 9 open the image, give up after 7 seconds, and turn it upright from its camera data (rotate() with no angle). Lines 11 to 17 resize it; a /_next/image request has no height, so line 14 runs, with withoutEnlargement: true: never bigger than the original. The recorded w=3840 answer is 1600 by 1067, the photo's own size. Lines 19 to 26 lower the quality for AVIF, whose numbers mean something else than WebP's; lines 27 to 33 pass the quality to the other formats. Line 35 gets the bytes.
Cache keys and .next/cache/images
Back to line 14 of the first excerpt. The key is made like this:
static getCacheKey({
href,
width,
quality,
mimeType,
}: {
href: string
width: number
quality: number
mimeType: string
}): string {
return getHash([CACHE_VERSION, href, width, quality, mimeType])
}Line 12 hashes five values with getHash: CACHE_VERSION, which is 4 (line 65), the address, the width, the quality and the format. getHash feeds them one after another into one SHA-256 and writes the result as base64url, letters, digits, - and _, safe in a folder name (extract-etag.ts, lines 3 to 13). With the version in the key, a release that changes how entries are stored can change it and get new keys, instead of misreading old files.
The key holds what the request asks for, and nothing about who asks: not the cookies, not the visitor. Two requests that differ only in their Accept header get two keys, because mimeType differs. The original's bytes aren't in it either: a new photo at the same address keeps its key, and replaces the old copy only once that copy is stale. A new entry is written like this:
async function writeToCacheDir(
cacheDir: string,
cacheKey: string,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,
etag: string,
upstreamEtag: string
) {
if (buffer.byteLength === 0) {
throw new Error(
'Invariant: cannot write an empty buffer to the image cache'
)
}
const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)
const filename = join(
/* turbopackIgnore: true */
dir,
`${maxAge}.${expireAt}.${etag}.${upstreamEtag}.${extension}`
)
await promises.rm(dir, { recursive: true, force: true }).catch(() => {})
await promises.mkdir(dir, { recursive: true })
await promises.writeFile(filename, buffer)Line 17: one folder per key. Lines 18 to 22: one file in it, whose name holds five parts joined by dots. Line 24 empties the folder first, so it never holds two files; line 26 makes it again, and line 27 writes the image's bytes into the file. The probe in the overview read that folder after the recording's requests:
[
{
"folder": "23QgcPR3itmiboMFr_pB2MCxpJnN0c3S6jcr5HAAWK0",
"maxAge": "14400",
"expireAt": "saved + 14400 s",
"etag": "Jqig1gGJ8SGTKpZwvSqtTaCDkDt5Gl5azyU5_BihFE0",
"upstreamEtag": "W/\"166e7-<date>\"",
"extension": "webp",
"bytes": 20264
},
{
"folder": "7M2d0nGeHsnUsXrkpYgSU9FhOsK3F74Eiv2HRUBboMc",
"maxAge": "14400",
"expireAt": "saved + 14400 s",
"etag": "EZK2arlVzqj1ThoigZJXuLMi1O5l_FxhMfo5ByNc9h0",
"upstreamEtag": "W/\"166e7-<date>\"",
"extension": "webp",
"bytes": 3942
},
{
"folder": "DThO3IpWWi0pOMe52JuWvJevJMsOyFnlxlZgx-4OqHM",
"maxAge": "14400",
"expireAt": "saved + 14400 s",
"etag": "rHLZoqrsyf13NVcHcqAIl1lSf5xQs_yFDIXl5kYAzBw",
"upstreamEtag": "W/\"166e7-<date>\"",
"extension": "jpeg",
"bytes": 7178
},
{
"folder": "gyx9B3WhhcDpHjZhmHTHxO7236AgQ74zzvpATuXdsc4",
"maxAge": "14400",
"expireAt": "saved + 14400 s",
"etag": "_vQUAv93T3vehrMbDEBHpd8smaJfNvOTNUJQkLGx3xg",
"upstreamEtag": "W/\"1f6f2-<date>\"",
"extension": "png",
"bytes": 17257
},
{
"folder": "urUNOS3TOP9o2HQWTepWrmqDIGcLMhWT2JYwpQCqayE",
"maxAge": "14400",
"expireAt": "saved + 14400 s",
"etag": "2J6IdLYokawUnnW4JDRlburzXh74MP7pjCzSAIJH_zk",
"upstreamEtag": "W/\"166e7-<date>\"",
"extension": "webp",
"bytes": 5496
}
]Five folders for six good requests: the again request found its key. The folder 7M2d0nGe… is the first request's, the WebP 640 pixels wide; the JPEG of the same photo and width is DThO3IpW…. The parts of each name:
maxAge,14400: four hours, from lines 1 to 4 of the transform excerpt.expireAt: the time, in milliseconds, after which the entry is old: the moment it was saved plusmaxAge(lines 507 to 509). The probe shows the difference, because the time itself changes on every run.etag: the SHA-256 of the file written. It's also the answer'sETagheader: the first request's wasEZK2arlVzqj1ThoigZJXuLMi1O5l_FxhMfo5ByNc9h0, the same as theetagof folder7M2d0nGe….upstreamEtag: the original's own ETag, in base64url. For a file inpublic, that's a weak ETag the static file server makes from the file's size and date:W/"166e7-…", and166e7in hexadecimal is 91,879, the size in bytes ofgreen-tea.jpg. It's what lets a stale entry's refresh skip sharp when the original hasn't changed.extension:webp,jpegorpng, so the server can send the rightContent-Typewithout reading the file.
Reading an entry back is get (lines 383 to 456): it splits the name the same way, and marks the entry stale when the time is past expireAt (line 450). The server's response cache, the same kind of cache that serves prerendered pages (Caching under the hood), decides what to send:
const cacheEntry = await this.imageResponseCache.get(
cacheKey,
async ({ previousCacheEntry }) => {
const { buffer, contentType, maxAge, upstreamEtag, etag } =
await this.imageOptimizer(
req,
res,
paramsResult,
previousCacheEntry
)
// …
sendResponse(
req.originalRequest,
res.originalResponse,
paramsResult.href,
cacheEntry.value.extension,
cacheEntry.value.buffer,
cacheEntry.value.etag,
paramsResult.isStatic,
cacheEntry.isMiss ? 'MISS' : cacheEntry.isStale ? 'STALE' : 'HIT',
imagesConfig,
cacheEntry.cacheControl?.revalidate || 0,
Boolean(this.dev)
)Lines 1 to 10: imageResponseCache.get takes the key and a function that makes the entry, which calls the optimizer. It only calls that function on a miss, or, for a stale entry, after it has already sent the old file (response-cache/index.ts, lines 389 to 401): the new entry is made in the background, and a later request gets it once that has finished. Many requests for the same key at the same moment share one call. Line 20 turns what happened into X-Nextjs-Cache: MISS, STALE or HIT.
A miss and a stale refresh both make a new entry, but not quite the same way. On a miss, there's nothing to reuse: the optimizer fetches the original, reads its type, calls sharp and writes the file. On a stale refresh, the old entry is handed to the function as previousCacheEntry (line 3). The optimizer still fetches the original and reads its first bytes; then, if the original's ETag equals the upstreamEtag stored in the old file's name, and the old file was really resized, it takes the old bytes (image-optimizer.ts, lines 1081 to 1096, and get-previously-cached-image-or-null.ts, lines 7 to 22) and returns them before sharp is called (transform.ts, lines 268 to 276). sharp only runs on a refresh when the old output can't be reused: the original's ETag changed, or the old file was the original itself, sent unresized because sharp failed on it. Either way, the refreshed entry is written again, with a new expireAt (response-cache/index.ts, lines 549 to 554). The recording has no stale entry: making one means waiting four hours, so this part is read from the source. The answer's other headers are set here:
res.setHeader('Vary', 'Accept')
res.setHeader(
'Cache-Control',
isStatic
? 'public, max-age=315360000, immutable'
: `public, max-age=${isDev ? 0 : maxAge}, must-revalidate`
)
if (sendEtagResponse(req, res, etag)) {
// already called res.end() so we're finished
return { finished: true }
}
if (contentType) {
res.setHeader('Content-Type', contentType)
}
// …
const fileName = getFileNameWithExtension(url, contentType)
res.setHeader(
'Content-Disposition',
contentDisposition(fileName, { type: imagesConfig.contentDispositionType })
)
res.setHeader('Content-Security-Policy', imagesConfig.contentSecurityPolicy)
res.setHeader('X-Nextjs-Cache', xCache)Each row is one header from the recording, and the line of this excerpt that set it:
| Header | Set by | What it means |
|---|---|---|
Vary: Accept | Line 1, on every answer. | The answer depends on the Accept header, so a cache in between keeps one copy per Accept. |
Cache-Control: public, max-age=14400, must-revalidate | Lines 2 to 7; 14400 is the larger of minimumCacheTTL and the original's own max-age. | A browser may keep it 4 hours. A static import's photo gets immutable and ten years instead. |
ETag: EZK2arlV… | Line 8, through sendEtagResponse. | The SHA-256 of the file sent. A request whose If-None-Match has it gets 304 and no body. |
Content-Type: image/webp | Lines 12 to 14. | The format sharp wrote, from mimeType, or the original's own type. |
Content-Disposition: attachment; filename="green-tea.webp" | Lines 16 to 20. | Opened on its own, the address downloads a file, named after the original with the new extension. |
Content-Security-Policy and X-Nextjs-Cache | Lines 22 and 23. | No script or frame may run in it; and whether the file came from the disk (HIT), was made now (MISS) or was old (STALE). |
The folder doesn't grow forever. The disk cache keeps a list of its entries, least recently used last, and deletes from the end when the total passes images.maximumDiskCacheSize; left unset, that's half of the free disk space when the server starts (disk-lru-cache.external.ts, lines 28 to 36). 0 turns the disk cache off. With images.customCacheHandler: true, the entries go to your cacheHandler instead, one store for every instance, as Self-hosting Next.js set up.
The cache's folder, its key's inputs, the file name's parts, the font file marks and the manifests are Next.js 16.4.3's internals, from these recorded builds. They can change in any release, and CACHE_VERSION exists for that. What the docs promise is the config: deviceSizes, imageSizes, qualities, formats, the patterns, minimumCacheTTL, maximumDiskCacheSize, and next/font's options. Never read or write .next/cache from your app's code.
Downloading fonts at build time
Now the fonts. Lora({ subsets: ["latin"] }) looks like a function call, but SWC, the compiler (SWC and Turbopack), turns it into an import of a special address with your options written into it, like next/font/google/target.css?{"path": …, "import": "Lora", …} (google/request.rs, lines 4 to 6). With Turbopack, the default bundler, that import is answered by Rust code in Next.js's crates/next-core/src/next_font/google folder, compiled into the @next/swc package that next installs. It makes two modules that exist only in memory: a JavaScript one that exports className and style, and a CSS module with the @font-face rules (google/mod.rs, lines 196 to 305). For the CSS, it asks Google:
pub const GOOGLE_FONTS_STYLESHEET_URL: &str = "https://fonts.googleapis.com/css2";
// Always sending this user agent ensures consistent results from Google Fonts.
// Google Fonts will vary responses based on user agent, e.g. only returning
// references to certain font types for certain browsers.
pub const USER_AGENT_FOR_GOOGLE_FONTS: RcStr = rcstr!(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/104.0.0.0 Safari/537.36"
);Line 1 is Google Fonts' CSS address. Lines 5 to 8 are the user agent, the header that says which browser is asking: always Chrome 104, because Google sends different CSS to different browsers, and Chrome's gets WOFF2 files. get_stylesheet_url adds the family, the weights and display (google/util.rs, lines 149 to 287). The recording proves both the address and the timing with a build that can't reach Google. Pick the last version:
app/layout.tsximport { Lora } from "next/font/google";import "./globals.css"; const font = Lora({ subsets: ["latin"],}); export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body className={font.className}>{children}</body> </html> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○ (Static) prerendered as static content # What .next/static/media holds: 7 files, 158.2 kB (deeper folders added up).next/static/media/02ee680d6f7b72e4-s.2936i88_6qsfd.woff2 17.3 kB.next/static/media/1854c6960aa5e9fd-s.2qdkzeru_ecot.woff2 20.2 kB.next/static/media/2c6bb81ef2959eb3-s.2fpqrm51ez0iq.woff2 21.3 kB.next/static/media/80aaaae4466959af-s.15gdzqknx46iu.woff2 8.9 kB.next/static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2 37.8 kB.next/static/media/c713a0aab7a8ca36-s.0mybutugvu-lq.woff2 23.6 kB.next/static/media/e929b158eb81aae9-s.02frcczqg7k-8.woff2 29.2 kBSeven files in .next/static/media, one per subset Google has for Lora. Every name has -s; only the latin file has .p, and it's the one the page's Link header preloads.
In Lora, with Google out of reach, the build's connections must go through a proxy address that never connects them anywhere. The Terminal tab shows: There was an issue establishing a connection while requesting https://fonts.googleapis.com/css2?family=Lora:wght@400..700&display=swap, then Failed to fetch Lora from Google Fonts. next build stops (next_font/issue.rs, lines 56 to 107; next dev only warns and uses the fallback font). The module names in the trace, lora_a52a04ae.js and lora_a52a04ae.module.css, are the two in-memory modules, named after the font and a hash of your options. Turbopack expects Google's CSS to give each subset its own @font-face, after a comment naming it, like /* latin */, and reads it line by line:
fn find_font_files_in_css(css: &str, subsets_to_preload: &[RcStr]) -> Vec<FontFile> {
let mut font_files: Vec<FontFile> = Vec::new();
let mut current_subset = "";
for line in css.lines() {
static COMMENT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"/\* (.+?) \*/"#).unwrap());
if let Some(new_subset) = COMMENT_RE.captures(line).and_then(|c| c.get(1)) {
current_subset = new_subset.as_str();
continue;
}
static FONT_URL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"src: url\((.+?)\)"#).unwrap());
let Some(font_url) = FONT_URL_RE.captures(line).and_then(|c| c.get(1)) else {
continue;
};
let font_url = font_url.as_str();
// …
font_files.push(FontFile {
font_url: font_url.to_string(),
ext: ext.to_string(),
preload: subsets_to_preload
.iter()
.any(|subset| subset == current_subset),
});Lines 6 to 11: a comment like /* latin */ sets the current subset. Lines 13 to 18 take each src: url(…), a font file's address. Lines 20 to 26 keep the file, marked to preload when its subset is one you listed in subsets. Each address in the CSS is then replaced by an import of the file, and each file is downloaded and named:
// remove dashes and dots as they might be used for the markers below.
let mut name = format!("{:016x}", hash_xxh3_hash64(url.as_bytes()));
if size_adjust {
name.push_str("-s")
}
if preload {
name.push_str(".p")
}Line 2 starts the name with a 16-character hash of the file's Google address (xxh3, a fast hash). Lines 3 to 5 add -s when the fallback font is size-adjusted; lines 6 to 8 add .p when the file is to be preloaded. When Turbopack writes it to .next/static/media, it adds 13 characters of a hash of the file's content before the extension (chunking_context.rs, lines 794 to 826). Lora's build wrote 7 files; only c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2 has .p. The marks are read back when the build writes the font manifest:
let has_fonts = !font_paths.is_empty();
let using_size_adjust = font_paths.iter().any(|path| path.contains("-s"));
let font_paths = font_paths
.into_iter()
.filter(|path| path.contains(".p."))
.map(RcStr::from)
.collect::<Vec<_>>();Line 2: any file with -s means the app uses a size-adjusted fallback. Lines 4 to 8 keep only the files with .p.. The build wrote:
{
"app": {
"[project]/app/_not-found/page": [
"static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2"
],
"[project]/app/page": [
"static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2"
]
},
"appUsingSizeAdjust": true,
"pages": {},
"pagesUsingSizeAdjust": false
}For each page that uses the font, the one file to preload. When the server renders a page, it looks the page up here (get-preloadable-fonts.tsx) and asks React to preload the file (get-layer-assets.tsx), which React sends as a header. The recorded home page has it: link: </_next/static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2". appUsingSizeAdjust: true adds <meta name="next-size-adjust"> to the page's head too.
-s for a size-adjusted fallback, .p for a file to preload. The manifest finds the files to preload by that mark.With next build --webpack, the same work is done in JavaScript, by the loader in packages/font/src/google/loader.ts, with the same marks in the names. Both only run while building: a visitor's browser never asks Google.
Computing size-adjust fallbacks
Until the font file arrives, the browser draws the text with the fallback font. Optimizing fonts recorded the rule next/font writes for Lora: Times New Roman, at size-adjust: 115.2%. The numbers come from a file Next.js ships, next/dist/server/capsize-font-metrics.json: the measurements of Google's fonts and common system fonts, copied from the @capsizecss/metrics package when Next.js itself is built (taskfile.js, lines 189 to 199). Each entry has the font's category, its ascent and descent (how far letters reach above and below the line), lineGap, unitsPerEm (the grid the font is drawn on) and xWidthAvg, the average width of a letter in ordinary text. Lora's: 1006, -274, 0, 1000 and 468. Times New Roman's xWidthAvg is 832, on a grid of 2048.
let fallback = if metrics.category == "serif" {
&DEFAULT_SERIF_FONT
} else {
&DEFAULT_SANS_SERIF_FONT
};
let metrics = if adjust {
// …
let main_font_avg_width = metrics.x_width_avg as f64 / metrics.units_per_em as f64;
let fallback_metrics = font_metrics_map.0.get(&fallback.capsize_key).unwrap();
let fallback_font_avg_width =
fallback_metrics.x_width_avg as f64 / fallback.units_per_em as f64;
let size_adjust = main_font_avg_width / fallback_font_avg_width;
let ascent = metrics.ascent as f64 / (metrics.units_per_em as f64 * size_adjust);
let descent = metrics.descent as f64 / (metrics.units_per_em as f64 * size_adjust);
let line_gap = metrics.line_gap as f64 / (metrics.units_per_em as f64 * size_adjust);
Some(FontAdjustment {
ascent,
descent,
line_gap,
size_adjust,
})
} else {
None
};- Lines 1 to 5: a
seriffont gets Times New Roman; every other category, evenhandwriting, gets Arial. - Lines 9 to 13:
size_adjustis the font's average letter width divided by the fallback's, each measured as a share of its own grid. For Lora: 468 / 1000 = 0.468, and 832 / 2048 = 0.406, so 1.152. - Lines 15 to 17: the room above and below the letters, and between lines, is Lora's, divided by the same number, because the fallback is drawn that much bigger: 1006 / (1000 × 1.152) = 0.8733.
let override_properties = match &fallback.adjustment {
None => "".to_owned(),
Some(adjustment) => formatdoc!(
r#"
ascent-override: {}%;
descent-override: {}%;
line-gap-override: {}%;
size-adjust: {}%;
"#,
format_fixed_percentage(adjustment.ascent),
format_fixed_percentage(adjustment.descent.abs()),
format_fixed_percentage(adjustment.line_gap),
format_fixed_percentage(adjustment.size_adjust)
),
};
res.push_str(&formatdoc!(
r#"
@font-face {{
font-family: '{}';
src: local("{}");
{}
}}
"#,
fallback.scoped_font_family,
fallback.local_font_family,
override_properties
));
// …
fn format_fixed_percentage(value: f64) -> String {
format!("{:.2}", value * 100.0)
}Lines 1 to 15 write the four numbers as percentages with two decimals (lines 30 to 32), and lines 17 to 28 the @font-face rule: a font named Lora Fallback whose file is local("Times New Roman"), the one already on the visitor's computer. The class then lists Lora first and Lora Fallback second. This is what the build wrote, laid out to read; the minifier shortened 115.20% to 115.2%:
@font-face {
font-family:Lora;
font-style:normal;
font-weight:400 700;
font-display:swap;
src:url(../media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2)format("woff2");
unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
}
@font-face {
font-family:Lora Fallback;
src:local(Times New Roman);
ascent-override:87.33%;
descent-override:23.78%;
line-gap-override:0.0%;
size-adjust:115.2%
}
.KWfbtq_className {
font-family:Lora,Lora Fallback;
font-style:normal
}Pick other fonts. The page works the numbers out with the same formula, from their entries in the same file:
@font-face {
font-family: 'Lora Fallback';
src: local("Times New Roman");
ascent-override: 87.33%;
descent-override: 23.78%;
line-gap-override: 0.00%;
size-adjust: 115.20%;
}@font-face {
font-family:Lora Fallback;
src:local(Times New Roman);
ascent-override:87.33%;
descent-override:23.78%;
line-gap-override:0.0%;
size-adjust:115.2%
}Lora's average letter is 468 of 1000 units wide; Times New Roman's is 832 of 2048. size-adjust is the first divided by the second. A build of this font was recorded: its rule is below the computed one, with the same numbers.
Two options change this, and the source shows how each one reaches this code:
fallback: ["Georgia", "serif"]makesget_font_fallbackskip the metrics entirely and use your list (google/font_fallback.rs, lines 67 and 68), which is why Optimizing fonts found noLora Fallbackrule with it.adjustFontFallback: falseshould leave the fallback without the four numbers. In the recorded Turbopack build it changed nothing: Lora, adjustFontFallback: false in the font playground has the samesize-adjustrule and the same-snames. The struct the options are read into explains it:
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct NextFontRequest {
pub import: RcStr,
pub arguments: Vec<NextFontRequestArguments>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(super) struct NextFontRequestArguments {
pub weight: Option<OneOrManyStrings>,
pub subsets: Option<Vec<RcStr>>,
pub style: Option<OneOrManyStrings>,
pub display: Option<RcStr>,
pub preload: Option<bool>,
pub axes: Option<Vec<RcStr>>,
pub fallback: Option<Vec<RcStr>>,
pub adjust_font_fallback: Option<bool>,
pub variable: Option<RcStr>,
}Line 2 tells the reader of the outer object that its JSON names are written camelCase. The struct for your options, from line 8, has no such line, so its fields are matched by their own names: adjust_font_fallback on line 17 waits for a key spelled with underscores, and the adjustFontFallback you write never fills it. One-word options like subsets and fallback match either way. An empty field becomes true (options.rs, line 196). Next.js's issue tracker has an open report of the same behavior (issue 74134). The webpack loader reads the option correctly. Treat this as a fact about 16.4.3 with Turbopack, not as how the option is meant to work.
A font of your own, from next/font/local, has no entry in the metrics file. Turbopack measures the file itself instead, with the allsorts font library: the average width of a sample of letters and spaces, weighted like English text (line 31), and the ascent and descent the file states (local/font_fallback.rs, lines 92 to 166).
Reading your own build's images and fonts
In a Next.js 16.4.3 project, you can check all of this on your own server. Read it to learn and to debug, never from your app's code:
- Open a photo's
/_next/imageaddress in a tab and read the headers:X-Nextjs-Cachesays whether it was made now, and a400's body says which check refused it. - After
next starthas served some photos, list.next/cache/images: one folder per key, one file per folder. Deleting the folder empties the cache; the next request for each key is aMISS. - After
next build, list.next/static/mediafor your fonts, and read.next/server/next-font-manifest.jsonto see which files each page preloads. - To check a fallback, search the built CSS in
.next/static/chunksforFallback, and compare itssize-adjustwith the playground's. - A build machine that can't reach Google fails at next/font. The error itself names the ways out:
next/font/localwith the files in your project, orHTTP_PROXY/HTTPS_PROXYpointing at a proxy that can.
Sort these: when does each one happen?
- Lora's woff2 files are downloaded from Google
size-adjust: 115.2%is worked out.pis added to the latin file's namew=500is refused with 400- The cache key is hashed
- The first bytes show
oolong.jpgis a PNG - sharp resizes the photo to 640 pixels
- A file named
14400.<expireAt>.…webpis written
Sort each step: while next build runs, on every /_next/image request, or only when the cache needs a new entry.
Common mix-ups
- "The optimizer resizes a photo on every request." No. Only when the cache needs a new entry: on a miss, and on a stale entry's refresh if the old copy can't be reused. The recorded
againrequest was aHIT, sent from.next/cache/images. - "The optimizer trusts a file's name." No. It reads the first bytes: the PNG named
oolong.jpgcame back asimage/png. - "The cache key changes when the photo changes." No. It's the address, width, quality and format. The original's ETag is kept in the file's name, and an entry is only made again once it's stale.
- "A big
wmakes a big image." No. sharp never enlarges:w=3840of a 1,600-pixel photo is 1,600 pixels wide. - "next/font fetches the font from Google when the page loads." No. The build fetches it; without Google the build stops, and the recorded page preloads a file from its own site.
- "
size-adjustis measured in the browser." No. It's computed during the build from the metrics file Next.js ships, and written into the CSS.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
With the default config, what is the body of the answer to /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=90?
const sizes = [...(deviceSizes || []), ...(imageSizes || [])]
if (isDev) {
sizes.push(BLUR_IMG_SIZE)
}
const isValidSize =
sizes.includes(width) || (isDev && width <= BLUR_IMG_SIZE)
if (!isValidSize) {
return {
errorMessage: `"w" parameter (width) of ${width} is not allowed`,
}
}
const quality = parseInt(q, 10)
if (isNaN(quality) || quality < 1 || quality > 100) {
return {
errorMessage:
'"q" parameter (quality) must be an integer between 1 and 100',
}
}
if (
qualities &&
!qualities.includes(quality) &&
!(isDev && quality === BLUR_QUALITY)
) {
return {
errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
}
}"q" parameter (quality) of 90 is not allowed, with status 400, from lines 25 to 33 (image-optimizer.ts, lines 310 to 318). The refusals example recorded exactly this answer.
Which mark in a font file's name makes Next.js preload it?
let has_fonts = !font_paths.is_empty();
let using_size_adjust = font_paths.iter().any(|path| path.contains("-s"));
let font_paths = font_paths
.into_iter()
.filter(|path| path.contains(".p."))
.map(RcStr::from)
.collect::<Vec<_>>();.p (tested as .p., on line 6 of font.rs's excerpt, lines 68 to 75 in the file). The file replacer adds it to the files of the subsets you asked to preload (google/mod.rs, lines 408 to 410), and only those reach next-font-manifest.json.
Both requests are misses. How many folders do they add to .next/cache/images?
GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75
Accept: image/avif,image/webp,…
GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75
(no Accept header)Two. mimeType is image/webp for the first and "" for the second, so the keys differ (image-optimizer.ts, line 351). The recording has both: DThO3IpW… holds the JPEG and 7M2d0nGe… the WebP.
public/teas/oolong.jpg is really a PNG. Asked for with no Accept header, what Content-Type comes back?
const maxAge = Math.max(
nextConfig.images.minimumCacheTTL,
getMaxAge(imageUpstream.cacheControl)
)
const upstreamType = await detectContentType(upstreamBuffer)
if (
!upstreamType ||
!upstreamType.startsWith('image/') ||
upstreamType.includes(',')
) {
opts.logger?.error(
"The requested resource isn't a valid image for",
href,
'received',
upstreamType
)
throw new ImageError(400, "The requested resource isn't a valid image.")
}
if (
upstreamType.startsWith('image/svg') &&
!nextConfig.images.dangerouslyAllowSVG
) {
opts.logger?.error(
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled. Consider adding the "unoptimized" property to the <Image>.`
)
throw new ImageError(
400,
'"url" parameter is valid but image type is not allowed'
)
}
// …
if (BYPASS_TYPES.includes(upstreamType)) {
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge,
etag: upstreamEtag,
upstreamEtag,
}
}
let contentType: string
if (mimeType) {
contentType = mimeType
} else if (upstreamType === WEBP || upstreamType === AVIF) {
// Downlevel WebP and AVIF when the client does not advertise support.
contentType = JPEG
} else {
contentType = upstreamType
}image/png. detectContentType reads the PNG signature, and with no format from the Accept header, line 51 keeps the original's type. The recording's answer is image/png, named oolong.png (transform.ts, lines 257 to 266).
Which field of these structs never receives adjustFontFallback: false?
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct NextFontRequest {
pub import: RcStr,
pub arguments: Vec<NextFontRequestArguments>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(super) struct NextFontRequestArguments {
pub weight: Option<OneOrManyStrings>,
pub subsets: Option<Vec<RcStr>>,
pub style: Option<OneOrManyStrings>,
pub display: Option<RcStr>,
pub preload: Option<bool>,
pub axes: Option<Vec<RcStr>>,
pub fallback: Option<Vec<RcStr>>,
pub adjust_font_fallback: Option<bool>,
pub variable: Option<RcStr>,
}adjust_font_fallback. NextFontRequestArguments (request.rs, lines 14 to 25) has no rename_all = "camelCase", so the JSON key adjustFontFallback never matches it; it stays empty, and options.rs, line 196, makes it true. The recorded build with adjustFontFallback: false kept its size-adjusted fallback.
What size-adjust does next/font write for Inter's fallback?
Inter: category sans-serif, unitsPerEm 2048, xWidthAvg 978
Arial: unitsPerEm 2048, xWidthAvg 913
Times New Roman: unitsPerEm 2048, xWidthAvg 832107.12%. Inter is sans-serif, so Arial: (978 / 2048) / (913 / 2048) = 1.0712 (google/font_fallback.rs, lines 149 to 176). The recorded Inter build wrote size-adjust:107.12%.
Check your understanding
For each question, ask when it happens: during the build, on every request, or only when the cache needs a new entry.
Question 1 of 7A request asks
/_next/imageforw=500. Where does the400come from?Choose an answer to see the explanation.
Question 2 of 7Which of these is not part of the image cache key?
Choose an answer to see the explanation.
Question 3 of 7
/logo.svgpasses the url, w and q checks. Why does it still get400with the default config?Choose an answer to see the explanation.
Question 4 of 7When does Next.js download Lora's font files from Google?
Choose an answer to see the explanation.
Question 5 of 7What does
.pinc3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2tell Next.js?Choose an answer to see the explanation.
Question 6 of 7Where does
size-adjust: 115.2%for Lora come from?Choose an answer to see the explanation.
Question 7 of 7The recording asked for the green tea photo at
w=3840. The original is 1,600 pixels wide. What came back?Choose an answer to see the explanation.
Key takeaways
validateParamschecksurl,wandqon every request, against the patterns,deviceSizes,imageSizesandqualities, and a refusal is400with its message.- A good request's key is the SHA-256 of the cache version, address, width, quality and format; the entry is a folder named after it in
.next/cache/images. - Only a new entry, for a miss or a stale entry's refresh, fetches the original (through the server itself, or over the network after a private-address check) and reads its type from its bytes; sharp, which never enlarges, runs unless an unchanged original lets a refresh reuse the old copy.
- The file's name holds
maxAge,expireAt, the ETag, the original's ETag and the extension; a stale entry is sent once more while a new one is made. - next/font/google fetches Google's CSS and files during
next build, as Chrome 104, and marks file names with-sand.p; the font manifest preloads the.pfiles. size-adjustis the font's average letter width over the fallback's, from the metrics Next.js ships; in 16.4.3 with Turbopack,adjustFontFallback: falsedoesn't reach that code.
Remember the one-liner.
Images are checked, keyed and resized when a request misses the disk cache; fonts are downloaded, marked and measured while the app builds.
Coming next: Adapters and deployment outputs. It reads required-server-files.json, what an adapter receives, and how deployment ids keep old pages and new servers apart.