Check Web Font Delivery with an Origin Header, Too: How a Fallback Font Went Unnoticed on My Site for 13 Days
For 13 days, the Latin web font on my own site kept returning 404 and every page was drawn in a fallback face. A plain curl returned 200; only the browser's request, which carries an Origin header, received the stale 404. Here is a script that compares three kinds of request, a setting that keeps long cache lifetimes off error responses, and how to pick a purge that reaches every variant.
On the evening of September 25, I was going over the pages of my own portfolio site and opened document.fonts in the DevTools console. Of the faces listed, exactly one, the Latin font I serve myself, showed status: "error".
Yet when I fetched the same font URL with curl from my machine, it came back 200. The file was on the server, and the byte count matched. The server was right and only the browser was failing. It took me 13 days to notice that gap at all.
As an indie developer who runs his own sites, I tend to stop checking at "curl says 200, so we're fine." I certainly did. What follows is what was happening, how I changed the way I check, and the settings I added so I don't step into the same hole twice.
The short version
A 200 from a plain curl does not prove what the browser receives when it loads a font.
Browsers fetch web fonts with CORS, so the request carries an Origin header. In my setup, the CDN (Cloudflare) kept the response to Origin requests separately from the response to plain requests, and the old 404 survived only on the Origin side. A plain curl sends no Origin, so it only ever saw the healthy copy.
Check with three requests side by side: plain, with Origin, and cache-busting. If you take one thing away, take that.
What happened over those 13 days
Here is the timeline, taken from my own work notes.
Date
What happened
What I could see
Sep 12
Switched the Latin face from Google Fonts to a self-hosted subset
Nothing broke; I took the switch as done
Sep 12 onward
Every page rendered the OS fallback (Helvetica) instead of that face
The difference was small enough that I missed it
Sep 25
Found status: "error" in document.fonts
Plain curl: 200 with the right byte count
Sep 25
Saw 404 from curl with Origin; cleared it with a prefix purge
All three requests returned 200
I had a metric-matched fallback face in place, so the layout never shifted and the text looked almost right. I think that is exactly why the problem lived so long. A bug that does not break the page outlives one that does.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Right after publishing a new font or image, you can line up a plain request, an Origin request, and a cache-busting request in under a minute and see what the browser actually receives
✦You can prevent the accident where a 404 for a not-yet-uploaded file is kept for a year, with a single setting that stops error responses from being stored
✦When a purge does not fix things, you can decide whether a URL purge, a URL-with-headers purge, or a prefix purge will reach every cached variant
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first was that error responses carried the same long cache lifetime as the files. My server attaches Cache-Control: max-age=31536000 (one year) by file extension. Because the rule keys on the extension, a 404 for a font that does not exist yet gets the same year. If a single request reaches that URL before the upload, the CDN is told it may keep the 404 for a year.
I had actually met this trap once before, in July, with a different face: a request reached the font URL before the file was in place, and that one 404 was stored. I worked around it by renaming the file and added "upload new assets first" to my checklist. It wasn't enough.
The second was that Origin responses were stored separately. During the September switch, the old 404 for plain requests had been purged, which is why curl said 200. The browser's request carried Origin, though, and the CDN held that response as a different object. A URL purge from the dashboard cleared only the plain variant; the Origin variant kept its 404.
I'll leave the internals to Cloudflare's own documentation on CORS and caching and on single-file purges. Everything here is what I observed in my own setup, and the behavior may differ with your plan or configuration, so please run the script below against your own URLs rather than taking my word for it.
A check script that lines up three requests
I put the check into one script so I can run it every time a new font or image goes live. It fetches the same URL three ways and prints the status, the CDN cache status, and the bytes actually received. If they disagree, it exits with code 1.
#!/usr/bin/env bash# check_variants.sh - fetch one URL as a plain request, an Origin request, and a cache-busting request, then compare# usage: ./check_variants.sh https://example.com/assets/fonts/body-latin.woff2 https://example.comset -uURL="${1:?font or asset URL}"ORIGIN="${2:?page origin, e.g. https://example.com}"hdr() { # print the value of header $2 from header text $1 (case-insensitive) printf '%s' "$1" | tr -d '\r' | awk -v k="$2" -F': ' 'tolower($1)==k{print $2; exit}'}probe() { # $1=label $2=target URL, the rest are extra curl arguments local label="$1" target="$2"; shift 2 local out # discard the body; keep headers and the number of bytes actually received out=$(curl -sS -o /dev/null -D - -w 'X-Bytes: %{size_download}\n' "$@" "$target") \ || { printf '%-12s curl failed\n' "$label" >&2; echo "ERR"; return; } local code; code=$(printf '%s' "$out" | tr -d '\r' | awk '/^HTTP\//{c=$2} END{print c}') printf '%-12s status=%s cf-cache-status=%s bytes=%s\n cache-control=%s acao=%s\n' \ "$label" "$code" "$(hdr "$out" cf-cache-status)" "$(hdr "$out" x-bytes)" \ "$(hdr "$out" cache-control)" "$(hdr "$out" access-control-allow-origin)" >&2 echo "$code"}plain=$(probe "plain" "$URL")cors=$(probe "with-Origin" "$URL" -H "Origin: ${ORIGIN}")# a query string changes the CDN cache key, so this is close to what the origin serves right nowfresh=$(probe "cache-bust" "${URL}?cb=${RANDOM}${RANDOM}" -H "Origin: ${ORIGIN}")if [ "$plain" != "$cors" ] || [ "$cors" != "$fresh" ]; then echo "NG: responses disagree (plain=$plain with-Origin=$cors cache-bust=$fresh)" >&2 exit 1fi# agreeing is not enough: anything other than 200 fails (all 404, or all curl failures)if [ "$plain" != "200" ]; then echo "NG: all three returned status=$plain" >&2 exit 1fiecho "OK: all three returned status=200" >&2
Four details I was careful about:
Human-readable lines go to stderr; only the status goes to stdout. That lets $(probe ...) capture just the status while the readable lines stay on screen. Mix them and the compared strings include the display text, so the check always reports a mismatch.
The status comes from the last HTTP/ line. Behind a proxy, curl can print HTTP/1.1 200 Connection established first, and reading the first line would mislead you.
Agreement alone is not a pass; anything other than 200 fails. My first version only looked for disagreement, so when the connection failed for all three it happily printed OK. I caught that gap by pointing the script at a dead address.
The cache-busting request also sends Origin. With a plain request there, you would learn that the origin is fine but miss whether the CORS header (access-control-allow-origin) comes back.
To test the script itself, I ran it against a tiny local server that returns 404 only to Origin requests. That is a model for checking the script's logic, not a reproduction of the CDN's internals:
What I saw in production on September 25 had the same shape: plain and cache-busting requests returned 200, and only the Origin request got 404.
Checking from inside the browser
curl only shows you the CDN's answer. The browser's own cache can also hold a 404, so with the page open I run this in the DevTools console:
// paste into the DevTools console with the page open(async () => { await document.fonts.ready; // 1) list faces that failed to load const faces = [...document.fonts].map(f => ({ family: f.family, weight: f.weight, status: f.status })); console.table(faces); // 2) collect @font-face URLs from same-origin stylesheets (cross-origin sheets are unreadable, so skip them) const urls = new Set(); for (const sheet of document.styleSheets) { let rules; try { rules = sheet.cssRules; } catch { continue; } for (const r of rules) { if (!(r instanceof CSSFontFaceRule)) continue; const src = r.style.getPropertyValue('src'); for (const m of src.matchAll(/url\(["']?([^"')]+)["']?\)/g)) { urls.add(new URL(m[1], sheet.href || location.href).href); } } } // 3) compare a fetch that may use the browser cache with one that bypasses it // 'reload' overwrites the cache, so always do 'force-cache' first for (const u of urls) { const cached = await fetch(u, { cache: 'force-cache', mode: 'cors' }).then(r => r.status).catch(() => 'ERR'); const fresh = await fetch(u, { cache: 'reload', mode: 'cors' }).then(r => r.status).catch(() => 'ERR'); console.log(cached === fresh ? 'same' : 'DIFF', { url: u, forceCache: cached, reload: fresh }); }})();
In the July incident, this comparison split: force-cache gave 404 and reload gave 200. "The server is fixed, but my own browser still holds the old 404" is a state you can only tell apart this way.
Two caveats. This code does not pick up @font-face rules nested inside @media; walk r.cssRules if you need them. And the moment reload runs, the browser cache is replaced with the new response, so you only get one look at the "before" state. Note the result down right away.
Keep long cache lifetimes off error responses
The real fix is to stop 404s and 500s from being stored for long. My server is Apache on shared hosting with .htaccess, so I added one line:
<IfModule mod_headers.c> # stop the per-extension long cache from applying to 404 and 5xx responses # the expr= condition needs Apache 2.4.10 or later Header always set Cache-Control "no-store" "expr=%{REQUEST_STATUS} >= 400"</IfModule>
The word that matters is always. Plain Header set may not apply to error responses, and error responses are exactly what you want to catch here. Leaving out always is the same as having no fix.
After adding it, request one URL that does not exist:
# request a path that does not exist and see whether the 404 carries no-storecurl -sS -o /dev/null -D - "https://example.com/assets/fonts/does-not-exist-$RANDOM.woff2" \ | tr -d '\r' | grep -iE '^(HTTP/|cache-control:)'
If you see cache-control: no-store, it works. On Nginx or Cloudflare Workers the syntax is completely different; carry over only the idea that anything with status 400 or above must not be stored.
Choose a purge by which variants it reaches
This is where I lost the most time in September. I purged by URL, saw 200 from curl, and called it fixed.
Here are the options, ordered by how far they reach:
Purge method
Origin variant
When to use it
URL (dashboard)
Did not reach it in my setup
Files only ever requested without Origin
URL plus headers (API)
Reaches the variant for the Origin you specify
You know the variants and want the smallest scope
Prefix
Clears every variant under the path
A bounded directory such as your fonts folder
On September 25 I used a prefix purge from the dashboard, scoped to the fonts directory, then ran the check script right away and confirmed all three requests returned 200.
If you script it, the shape that follows Cloudflare's purge API documentation looks like this. I did not run these calls in my own setup; they are here for shape only. Pass the token in through an environment variable and never write it into the script.
# URL purge listing both the plain variant and the Origin variant (Cloudflare API)curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json" \ --data '{"files":[ {"url":"https://example.com/assets/fonts/body-latin.woff2"}, {"url":"https://example.com/assets/fonts/body-latin.woff2","headers":{"Origin":"https://example.com"}} ]}'# prefix purge (no scheme)curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json" \ --data '{"prefixes":["example.com/assets/fonts/"]}'
I don't judge a purge by "success": true in the API response. That only tells you the request was accepted. The judgment is whether the three requests in the check script agree.
For a poisoned URL, renaming is the surest fix
Sometimes none of the above is enough. A CDN purge never reaches a visitor whose browser already holds the 404, and that browser may wait a year before asking again.
So once a URL has been poisoned by a 404, the surest fix is to rename the file: body-latin.woff2 becomes body-latin-v2.woff2, and you update both the @font-face rule and the preload link. Change only one and the file fetched by preload goes unused while the browser fetches the other one as well.
In July I renamed; in September a prefix purge was enough. The difference was whether a browser was holding the 404. In July my own browser returned 404 for force-cache, so renaming was the only way out.
Pitfalls
Things I stepped in, or nearly did:
Trusting a 200 from plain curl. Fonts, and images fetched with CORS (<img crossorigin> or fetch), should all be checked with Origin as well.
Dropping crossorigin from a font preload. Font preloads need crossorigin even on the same origin. Without it, the preloaded response and the real fetch are treated as different, and the font is downloaded twice.
A fallback face so good it hides the failure. Metric-matched fallbacks prevent layout shift, and they also hide failures. I added one step after each release: confirm no error in document.fonts.
Treating success from the purge API as done. Accepted is not applied.
Publishing the reference first. If HTML or CSS that points at a new asset goes live first, the minutes before the file arrives are enough for a 404 to be stored. The order is asset, then check script, then whatever references it.
When you hand the check to an agent
If you let an Antigravity agent run the post-deploy check and only say "make sure the fonts load," it will likely see a 200 from plain curl and report success. I'd suggest writing these three points into the instruction:
Post-deploy check:1. Run ./check_variants.sh <font URL> <site origin> for every font and image that was added or changed2. If any file exits with code 1, do not purge; report its three output lines as-is (I will decide)3. Even if everything exits with 0, include the list of URLs you checked in the report
Point 2 says "do not purge" because the right purge depends on whether browsers hold the 404 and how many variants exist. The machine does the checking; I decide the scope. With that split, an agent can't helpfully purge everything and wipe out the evidence along with the cause.
Where I draw the line now
My definition of done is not "it returned 200" but "it returned 200 to the same request a browser sends." That is the one line I took from 13 days of fallback rendering.
Start by picking one font URL you serve today and fetching it with curl -H "Origin: <your site origin>". If it matches the plain request, you're done. If it doesn't, you may have found a bug nobody has noticed yet.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.