next/font/google downloads woff2 files from fonts.gstatic.com at build time. When Google rotated a file hash, my deploy started failing with 404s I could not reproduce locally, because the same fetch had already succeeded on my machine in an earlier second. Fix: vendor the fonts and use next/font/local, so the bytes are an input you own.
The symptom
A deploy failed twice. Nothing had changed in the app — same commit, same lockfile, same Node.
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'That last line appeared 21 times. The 404 came from fonts.gstatic.com.
What I assumed
I assumed I had broken something in the deploy container. Wrong dependency resolution, a bad Docker layer, a proxy eating requests. Because the same command on my machine — next build, Next.js 16.2.10 — built fine. Twice. Green.
That is the trap. "It builds locally" felt like evidence the container was broken. It was evidence of nothing.
Why that was wrong
next/font/google is not a runtime CDN link. It is a build-time downloader. Next's own docs, shipped in the package at node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13, say it plainly:
You can also conveniently use all Google Fonts. CSS and font files are downloaded at build time and self-hosted with the rest of your static assets. No requests are sent to Google by the browser.
No requests are sent by the browser. Requests are sent by your build. Every one of them is a chance for the build to fail for reasons that have nothing to do with your code.
Here is the actual mechanism, from the loader that ships in node_modules/next/dist/compiled/@next/font/dist/google/:
get-google-fonts-url.jsbuilds acss2?family=...URL from your options.fetch-css-from-google-fonts.jsGETs that CSS throughfetch-resource.js, which hardcodes a Chrome 104 user agent so Google answers with woff2 rather than ttf.find-font-files-in-css.jsextracts thesrc: url(...)entries — fully-hashed, versionedfonts.gstatic.compaths.fetch-font-file.jsdownloads each one and the build emits it into.next/static/media.
Step 3 is where the hermeticity dies. Those URLs are not stable inputs you control. They are whatever Google put in the CSS response at the moment your build ran.
And fetch-resource.js has exactly one policy for a non-200:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}Wrapped in retry(fn, 3), which is async-retry, so it's the initial attempt plus three: four requests against a URL that will 404 four times.
The rotation window
The failing request was for .../inter/v20/UcCB3Fwr...woff2. When I fetched the css2 endpoint for the same family myself, Google was handing back .../inter/v20/UcCO3Fwr...woff2.
UcCB vs UcCO. One character. A hash rotation mid-flight: the CSS I had was pointing at a file path that no longer existed.
Then why did it build on my machine?
My first theory was a stale cache: my .next predated the rotation, so the build was replaying a good answer from disk while the container hit the live endpoint. Obvious, tidy, and — on this setup — wrong.
The loader's caches are two in-memory Maps created at the top of loader.js (cssCache, fontCache). They exist to stop the client and server compilers from fetching the same URL twice, and they die with the process. Turbopack's on-disk cache would survive a process, but experimental.turbopackFileSystemCacheForBuild is opt-in and off by default in Next 16 (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204), and this project doesn't set it. What .next/cache actually holds here: .tsbuildinfo, two small info files, and exactly one fetch-cache entry — 191,476 bytes, content-type: application/octet-stream, wasm magic bytes, and a url field of data:application/octet-stream;base64,…. A data URI. Not a font, and not even a network call. There was no cached Google response on my disk to replay.
So both machines really did ask Google. They asked in different seconds, up to four times each, and the host's attempts happened to land on a good response.
I did rename .next and rebuild — and the host reproduced the failure, which felt like proof. It wasn't: the bad window was still open, and the same clean build passed an hour later. Rename instead of deleting anyway, it costs nothing. But one reproduction does not sell you a mechanism; check that the cache you're blaming exists before you blame it.
The nastier part: two severities for one fault
On the host, 404s printed and next build still exited 0. Inside the container, the same 404s became hard Turbopack module-resolution errors and the build died.
The exit-0 case is retry.js talking, not a tolerated failure. Every retry logs the error before trying again:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}Which means a log full of font 404s can mean "recovered on attempt 3" or "died on attempt 4", and the lines look identical either way. Read the exit code, not the scary text.
What the loader will not do in a production build is degrade quietly — its catch branches on environment, and only dev gets a fallback face:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}That branch is the real trap for local work. In next dev the same dead URL gets you a local(...) system face with override metrics, so the fault that kills your deploy shows up on your machine as typography that is only slightly off.
Next.js 16 makes Turbopack the default bundler for next build (node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116), and the Turbopack path turns the thrown error into unresolvable @vercel/turbopack-next/internal/font/google/font modules — 21 of them in my log.
The diagnostic sequence
Steal this, it took the guessing out of it:
curl -Ithe exact woff2 URL from the error. 404 → the file is gone, not your network.curlthecss2?family=...endpoint for the same family with a modern-Chrome user agent. 200 with a different hash insrc: url(...)→ confirmed rotation, not an outage.mv .next .next-badand rebuild locally. A failure here is a live reproduction — but before you credit the cache, check that there was one: Turbopack's build cache is opt-in, so a stale.nextis usually innocent.- Run that clean build on the host as well, twice, a while apart. This is the one people skip, and it's the one that separates "the file is gone" from "the file was gone for ten minutes".
An hour later, a clean build passed with zero 404s. That is the shape of this bug: a window, not a state — which is exactly why one green build, on any machine, is not an answer.
The fix, and its honest cost
Vendor the woff2 files into the repo and switch to next/font/local. Then the font bytes are an input you own, checked into git, and your build makes zero network calls for typography.
The cost is real, and I'm not going to pretend otherwise:
- You own updates. No more free upgrades when Google reships a family. That is the entire point — but it is now your job.
- You must subset — and you are probably already shipping more than you think.
subsets: ["latin"]does not filter what gets downloaded. It never reaches thecss2URL;loader.jspasses it tofindFontFilesInCssassubsetsToPreload, so it only decides which files get a preload hint. Every@font-facein Google's response is fetched and emitted. Launch declaressubsets: ["latin"]on all 8 families and still emits 35 woff2 files, of which 9 carry the.ppreload marker in the filename. So vendoring is a chance to actually cut charsets (pyftsubsetfrom fonttools) — but if you copy the woff2 files straight out of.next/static/mediayou have changed nothing about the weight. - Repo weight. This is the part that scales badly for me. Across the five templates I sell, the loaders declare 51 Google families:
folio-template/src/app/layout.tsx12,lens-template/src/app/fonts.ts6,levain-template/src/app/fonts.ts8,launch-template/src/lib/fonts.ts8,booking-template/src/lib/fonts.ts17. Those expand hard once weights, styles and subsets are combined: a clean build of the launch template emits 35 woff2 files into.next/static/media, and booking emits 92 (ls .next/static/media | grep -c woff2).
So the honest version is: vendor the fonts you actually render, and delete the rest. A theme switcher that offers 17 families is 17 build-time HTTP dependencies, and any one of them can 404 your deploy on a Tuesday.
The build that failed here was for Launch — live demo at https://launch.violettadev.com.
How to catch it next time
- Local-green is not a signal. When your machine passes and CI fails on the same commit, the default explanation is that you sampled the network at two different moments — not that one machine is broken. Re-run the failing build before you go hunting for a cause.
- Grep your build log for font 404s even on exit 0. They mean the retries won this time. Same fetch, same fragility, one attempt away from a red deploy.
- Treat every build-time fetch as a dependency. Fonts, wasm blobs, remote schemas, CMS pulls at build time. Write down which network calls your build makes. Most people cannot answer that question about their own project, and I couldn't either until my build told me at 404 volume.