Edge-Caching Fonts for Faster First Paint

This guide is part of the Font CDN & Edge Delivery section, itself under the Font Loading & Delivery Strategies area.

A font request that has to travel from a browser in Sydney to an origin server in Virginia pays round-trip latency twice over: once for the TLS handshake, once for the actual GET. On a typical broadband connection that round trip alone is 220–260ms — before the origin has sent a single byte of the WOFF2 file. Edge caching removes that trip by serving the font from a point of presence (POP) physically close to the visitor, so the browser's first byte arrives in single-digit-to-low-double-digit milliseconds instead of hundreds. For any page where a web font sits on the critical rendering path, that latency reduction shows up directly in First Contentful Paint and, when the font is applied to the largest above-the-fold text block, in Largest Contentful Paint too.

Edge caching for fonts is a distinct problem from browser caching. Browser caching (covered in Browser Font Caching: HTTP Headers & Invalidation) only helps repeat visitors on the same device. Edge caching helps every visitor, including first-time ones, because the CDN's cache is shared across all clients hitting that POP. Getting it right means choosing edge TTLs deliberately, layering stale-while-revalidate so cache misses never turn into a synchronous origin round trip, and understanding how POP topology and cache-key design affect hit ratio.

Prerequisites

  • Font files served through a CDN or edge network (Cloudflare, Fastly, CloudFront, Akamai, or a framework's built-in edge network).
  • Fonts already fingerprinted with a content hash in the filename (for example inter-latin-a1b2c3.woff2), as described in Serving Fonts from a CDN with Correct CORS and the immutable-caching pattern in Cache-Control: immutable for Long-Lived Font Files.
  • Access to your CDN's edge cache-control surface (Cache-Control headers, or a provider-specific edge TTL override such as Cloudflare's Cache Rules or Fastly's VCL).

Implementation

The core configuration has two layers: the origin response headers (what the CDN reads to decide how long to keep the object) and, optionally, an edge-specific override (because some CDNs cache more aggressively than the browser should). Start with the origin response:

GET /fonts/inter-latin-a1b2c3.woff2 HTTP/1.1
Host: assets.example.com
HTTP/1.1 200 OK
Content-Type: font/woff2
Cache-Control: public, max-age=31536000, immutable
CDN-Cache-Control: max-age=31536000, stale-while-revalidate=86400
Access-Control-Allow-Origin: https://example.com
Content-Encoding: br

Line by line:

  • Cache-Control: public, max-age=31536000, immutable is the header the browser obeys. public allows shared caches (including the CDN itself) to store the response. max-age=31536000 is one year in seconds. immutable tells supporting browsers not to revalidate even on a hard reload, because the filename is content-hashed and a changed font gets a new URL.
  • CDN-Cache-Control is a separate directive several CDNs (Fastly, and Cloudflare via a similar mechanism) honor independently of the browser-facing Cache-Control. This lets you set a different, edge-only TTL — useful if you want the edge to hold the object for exactly a year but keep browser-side max-age shorter for some other asset class, or vice versa. For fonts, both layers agreeing on one year is usually correct since the URL is immutable.
  • stale-while-revalidate=86400 tells the cache it may serve a stale copy for up to 24 hours after expiry while it revalidates with the origin in the background. Because font URLs are immutable, this directive practically never triggers a real content change — its value here is resilience: if the origin has a brief outage, the edge still serves the (identical) cached font instead of failing the request.
  • Access-Control-Allow-Origin must be present because fonts are fetched with crossorigin even when same-site through a CDN subdomain; omitting it silently blocks the font from applying.
  • Content-Encoding: br confirms the edge is serving the pre-compressed Brotli variant rather than re-compressing per request, which matters for CPU cost at the edge under load.

Once the header contract is in place, configure the CDN's edge cache explicitly rather than relying on defaults. A Cloudflare Cache Rule targeting the font path pattern looks like this:

{
  "expression": "(http.request.uri.path matches \"^/fonts/.*\\.woff2$\")",
  "action": "set_cache_settings",
  "action_parameters": {
    "cache": true,
    "edge_ttl": { "mode": "override_origin", "default": 31536000 },
    "browser_ttl": { "mode": "override_origin", "default": 31536000 },
    "serve_stale": { "disable_stale_while_updating": false }
  }
}

This explicit rule protects you from a CDN's default heuristic cache, which for some providers caps static-asset edge TTL far below a year unless you opt in. override_origin guarantees the edge honors the full year regardless of what the origin sends, which is useful if the origin is a CMS or framework you don't fully control.

Font TTFB: origin vs edge cache Bar chart comparing time to first byte for a font request served from a distant origin versus a nearby warm edge cache. Font TTFB: origin vs edge cache Cross-region origin 240ms Regional shield hit 80ms Warm edge POP hit 18ms time to first byte
A cold origin fetch across regions costs far more time-to-first-byte than a warm edge-cache hit.

Verifying the cache tier

Both response headers and CDN-specific debug headers confirm whether a request was served from edge cache or forwarded to origin:

curl -sI https://assets.example.com/fonts/inter-latin-a1b2c3.woff2 | grep -Ei 'cf-cache-status|x-cache|age|cache-control'

A cache hit typically reports:

cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
age: 41328

cf-cache-status: HIT (or x-cache: Hit from cloudfront on CloudFront, x-served-by plus x-cache: HIT on Fastly) confirms the edge answered without contacting origin. The age header is the number of seconds the object has sat in that POP's cache — a high number is a good sign for a font that should almost never change.

Defensive variant: cold-POP and cache-miss handling

The first request to hit a given POP after a deploy, a cache purge, or simply because that POP rarely sees traffic (a "cold POP") is always a cache miss, meaning the edge must fetch from origin before it can respond. If your origin is slow or geographically distant from that POP, a cold request can be slower than no CDN at all. Two defenses matter: pre-warming the cache after deploys, and making sure the miss path itself isn't catastrophic.

#!/usr/bin/env bash
# warm-font-cache.sh — hit every font URL from a sample of edge locations
# right after deploy, so first real users don't pay the cold-miss cost.
set -euo pipefail

FONT_URLS=(
  "https://assets.example.com/fonts/inter-latin-a1b2c3.woff2"
  "https://assets.example.com/fonts/inter-latin-700-d4e5f6.woff2"
)

# curl from several regions via a multi-region runner, or rely on your
# CDN's own cache-warming/prefetch API if it exposes one.
for url in "${FONT_URLS[@]}"; do
  status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
  cache=$(curl -sI "$url" | grep -i 'cf-cache-status' || true)
  echo "$url -> HTTP $status, $cache"
done

This script is intentionally simple: it issues a request per font URL so the nearest PoP (from wherever the CI runner executes) populates its cache before real traffic arrives. In production you'd run equivalent requests from multiple regions, or better, call the CDN's dedicated prefetch/warm API if one exists (Cloudflare's Cache Reserve and CloudFront's origin-shield reduce the blast radius of cold misses automatically, without a manual warm step). The second defensive layer is origin-shield: placing a single mid-tier cache between edge POPs and the true origin means even a cold POP's miss is answered by a nearby shield node rather than crossing an ocean, keeping worst-case latency bounded.

Client → nearest edge POP (cache miss)
       → origin shield POP (cache hit, regional)
       → (only on shield miss) origin server

CDN-specific cache directives

The names of the headers and dashboards differ across providers even when the underlying mechanism (edge TTL, revalidation, stale serving) is the same. Knowing the mapping saves time when you move a font pipeline between providers or run a multi-CDN setup:

Concept Cloudflare Fastly CloudFront
Edge TTL override Cache Rules edge_ttl beresp.ttl in VCL Cache policy Default TTL
Stale-while-revalidate stale-while-revalidate header (honored) beresp.stale_while_revalidate Not native; approximate with Lambda@Edge
Cache hit debug header cf-cache-status x-cache, x-served-by x-cache
Origin shield equivalent Tiered Cache Origin Shield (per-service) Regional Edge Caches
Manual purge granularity URL, tag, or full zone Surrogate key or URL Path pattern invalidation

The practical takeaway is that CloudFront lacks a native stale-while-revalidate, so on that platform the resilience against transient origin errors has to come from a longer max-age plus a CloudFront cache policy that keeps serving the last good object on origin 5xx (Origin Response Timeout and Error Caching Minimum TTL settings), rather than the directive itself.

Verification

Confirm the improvement with a synthetic multi-region test rather than a single local curl, since edge behavior is location-dependent:

// Run in the browser console on the deployed page, or in a
// Lighthouse/WebPageTest script, after the font request completes.
const [entry] = performance.getEntriesByType('resource')
  .filter(r => r.name.endsWith('.woff2'));

console.log({
  ttfb: entry.responseStart - entry.requestStart,
  total: entry.responseEnd - entry.requestStart,
  transferSize: entry.transferSize,
});

For a cache hit at a nearby edge POP, expect ttfb in the 5–30ms range on a broadband connection, versus 150–300ms typical for a direct origin fetch across a continent. Cross-check with PerformanceObserver for paint entries (see Using Paint Timing to Measure Font Renders) to confirm the shorter font fetch actually shifted first-contentful-paint earlier, not just that the network log looks better in isolation.

Cold-POP request path Numbered steps showing how a font request travels from client to edge POP to origin shield to origin server on a cache miss. Cold-POP request path 1 Client requests font nearest edge POP 2 Edge cache miss cold POP 3 Origin shield lookup regional cache 4 Origin fetch only on shield miss 5 Response cached at edge future hits are fast
A cache miss at the edge escalates through a shield tier before ever reaching the true origin.

Common Pitfalls

  • Relying on CDN defaults for static assets. Many CDNs cap the default heuristic edge TTL at one or two hours for content without explicit cache headers, which means a font gets re-fetched from origin dozens of times a day at high-traffic POPs. Fix: set an explicit Cache-Control with a one-year max-age and, where the CDN supports it, an explicit cache rule that overrides origin/default heuristics.
  • Purging the whole cache on every deploy. Because font files are content-hashed, they never need purging — only the CSS or HTML referencing them changes. A full-zone purge that includes fonts forces every POP back to a cold cache simultaneously, creating a latency spike for all visitors. Fix: purge by path pattern (e.g. /*.html, /*.css) and never touch /fonts/*.woff2.
  • Missing stale-while-revalidate on an origin that has occasional 5xx blips. Without it, an origin hiccup during a background revalidation turns into a hard failure for the client request that triggered it. Fix: add stale-while-revalidate (and, if supported, stale-if-error) so the edge serves the last-known-good font instead of propagating the origin error.
  • Ignoring cache-key variance. If the CDN's cache key includes the Accept-Encoding header or a query string added by a bundler's cache-buster, near-identical requests can miss each other's cache entries and multiply the number of cached variants, diluting hit ratio. Fix: normalize the cache key to path only for fonts, since the filename hash already guarantees uniqueness — no query string is needed.
  • Testing cache status from one location and assuming it holds everywhere. A HIT from your own office network only proves the POP nearest you is warm; a first visitor in a different region can still hit a cold POP. Fix: use a multi-region synthetic tool (WebPageTest with multiple test locations, or your CDN's own edge-analytics dashboard) before declaring the rollout verified.
  • Confusing edge caching with cross-site cache sharing. Even though the object sits at a shared edge POP, browsers still partition the browser HTTP cache per top-level site (see HTTP Cache Partitioning and Cross-Site Font Sharing). Edge caching speeds up every origin fetch across sites; it does not let two unrelated sites share one browser-cached copy.
Edge cache directives by CDN Comparison table of edge TTL override, stale-while-revalidate support, and origin shield naming across Cloudflare, Fastly, and CloudFront. Edge cache directives by CDN Cloudflare Fastly CloudFront Edge TTL override Cache Rules VCL beresp.ttl Cache policy TTL stale-while-revalida… Native Native (VCL) Not native Origin shield Tiered Cache Origin Shield Regional Edge Cache
The same caching concepts map to different header names and dashboard controls across providers.

Frequently Asked Questions

Does edge caching help returning visitors, or only first-time ones? Both, but for different reasons. First-time visitors benefit because the CDN's edge cache is already warm from other users hitting the same POP, so even a brand-new visitor gets a fast, geographically local response instead of a cross-continent origin fetch. Returning visitors on the same device mostly bypass the network entirely thanks to the browser's own HTTP cache with a one-year max-age; edge caching becomes relevant for them again only when the browser cache has been cleared or the request originates from a different device or browser profile.

Should I set a shorter edge TTL than the browser TTL, "just in case"? No, not for content-hashed font files. A shorter edge TTL only means more redundant origin fetches for no benefit, since the object never changes at that URL. The "just in case I need to update it" instinct is solved correctly by fingerprinting: when the font changes, you ship a new filename and a new URL, so the old cached entry (edge or browser) simply becomes unreferenced and ages out naturally. Reserve short TTLs for genuinely mutable resources like HTML.

Does stale-while-revalidate risk serving an outdated font after a real update? Practically no, if your fonts are content-hashed. The directive lets the edge serve the cached (correct) response while it re-validates with origin in the background; because the URL never changes for a given font version, there is nothing to go "stale" in a meaningful sense — the only scenario it protects against is a transient origin failure, not content drift.

Is origin-shield worth the extra hop for a small site? For a low-traffic site with few PoPs seeing regular traffic, most requests will be cold misses regardless of a shield layer, so the benefit is smaller. Origin-shield earns its cost once you have meaningful multi-region traffic: it consolidates what would otherwise be dozens of independent origin round trips (one per cold POP) into a handful of shield-to-origin fetches, then lets every subsequent edge POP fetch from the nearby shield instead of the far-away origin.

Related