Font CDN & Edge Delivery

This guide is part of the Font Loading & Delivery Strategies area.

Every millisecond a font spends in flight between an edge node and a browser is a millisecond that either delays first paint or extends a FOUT window. Where you host a font file — origin server, general-purpose CDN, or an edge network with points of presence (PoPs) close to the visitor — has a measurable effect on Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS), independent of the font's byte size. A 24 KB WOFF2 file served from a single origin in us-east-1 to a visitor in Singapore can take 280–400 ms round trip before the first byte even arrives; the same file served from a PoP 40 km away resolves in under 20 ms. This guide covers the mechanics of serving fonts from a CDN or edge network correctly: the CORS headers that are mandatory (not optional) for cross-origin font requests, the cache directives that let a font go from "revalidated every visit" to "never touched again," and how HTTP/2 and HTTP/3 multiplexing and prioritization change the calculus for where a font sits in the request waterfall.

Why Fonts Need CDN-Aware Delivery

Fonts are unusual static assets. Unlike images, they block text rendering under most font-display configurations, so their round-trip time (RTT) and time-to-first-byte (TTFB) matter far more than their raw transfer size once compressed. Unlike JavaScript bundles, they are almost always requested from a <link rel="preload"> or a @font-face src — meaning the browser fetches them in no-cors unless you configure otherwise, which triggers CORS restrictions the moment the font is served from a different origin than the page. And unlike most CSS or JS, font files are near-perfect candidates for indefinite caching because they are content-addressed (fingerprinted in the filename) and never mutate.

These three properties — render-blocking sensitivity to latency, mandatory CORS handling, and long-lived cacheability — are exactly what a CDN or edge network is built to optimize. A general-purpose CDN with global PoPs cuts the RTT component; correct Access-Control-Allow-Origin headers stop the browser from silently discarding a same-looking-but-differently-fetched response; and aggressive, immutable cache headers mean that after the first visit, a repeat visitor never touches the network for that font file again. Getting any one of the three wrong produces a specific, debuggable failure mode: a blocked cross-origin fetch, a font re-downloaded on every navigation, or a slow TTFB that stalls the render-blocking path.

Baseline Configuration

A minimal, correct baseline for serving a self-hosted font from a CDN combines a preload hint that matches the eventual @font-face declaration with cache and CORS headers set at the edge:

<link
  rel="preload"
  href="https://cdn.example.com/fonts/inter-var-latin.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>
@font-face {
  font-family: "Inter";
  src: url("https://cdn.example.com/fonts/inter-var-latin.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}
# Origin / CDN response headers for the font file
Content-Type: font/woff2
Access-Control-Allow-Origin: https://www.example.com
Cache-Control: public, max-age=31536000, immutable
Timing-Allow-Origin: *

Three details matter here and are frequently dropped in real deployments. First, the crossorigin attribute on the <link rel="preload"> is required even though the resource looks like a plain download — fonts are always fetched in anonymous CORS mode by the CSSOM, so a preload without crossorigin creates a second, uncredentialed fetch that doesn't match the one the CSS engine issues, and the browser downloads the font twice. Second, Access-Control-Allow-Origin must name the exact origin serving the HTML (or * if the font has no credentialed use, which is the common case) — a CDN with wildcard CORS disabled by default will otherwise return an opaque response the browser refuses to use as a font. Third, Timing-Allow-Origin is optional but valuable: without it, Resource Timing API entries for the cross-origin font are zeroed out except for startTime and duration, which breaks the granular timing breakdowns described in Capturing Font Timing with the Resource Timing API.

CDN Font Delivery Workflow A five-step numbered process for rolling out CDN-backed font delivery, from fingerprinting the file to preloading the CDN URL. CDN Font Delivery Workflow 1 Fingerprint file content hash in filename 2 Upload to CDN correct Content-Type 3 Set CORS headers ACAO at the edge 4 Set cache headers immutable, 1yr max-age 5 Preload + reference matching crossorigin
Five steps from fingerprinted file to a permanently cached, CORS-correct font.

The Delivery Workflow

Rolling out CDN-backed font delivery is a five-step process, and skipping a step tends to produce a hard-to-diagnose failure later:

  1. Fingerprint the font files. Build a content hash into the filename (inter-var-latin.a1b2c3.woff2) so the file can be cached forever without an invalidation story.
  2. Upload to the CDN or edge storage with the correct Content-Type: font/woff2 (or font/woff for the legacy format) — many origins default to application/octet-stream, which some browsers refuse to load as a font.
  3. Set CORS headers at the edge, either via the CDN's header-rewrite rules or an edge function, matching the calling origin.
  4. Set Cache-Control: public, max-age=31536000, immutable so both the browser cache and any intermediate shared caches treat the file as permanent — the mechanics of this header are covered fully in Cache-Control: immutable for Long-Lived Font Files.
  5. Preload and reference the CDN URL in both the <link rel="preload"> and the @font-face src, with matching crossorigin attributes on both, per Why Font Preload Needs the crossorigin Attribute.

Once these five steps are in place, verify with a hard-refresh network trace: the font request should complete in one round trip on first visit and produce a (disk cache) or (memory cache) entry with zero network time on every subsequent visit, including across page navigations within the site.

CORS: The Non-Negotiable Header

The CSS Font Loading spec requires all font fetches to use crossorigin: anonymous request mode, regardless of whether the resource is same-origin or cross-origin. This means a font served from cdn.example.com while the page is www.example.com is subject to the standard CORS response check even though nothing about a font "looks" cross-origin to the person authoring the CSS. If the origin's response omits Access-Control-Allow-Origin, the browser receives the bytes over the wire, discards them as an opaque cross-origin response, and the @font-face silently falls through to the next src in the fallback chain (or to the system fallback if there is none) — with no console error in most browsers. The exact header requirements, the interaction with credentialed requests, and how to debug the opaque-response failure in the Network panel are detailed in Serving Fonts from a CDN with Correct CORS.

A common CDN misconfiguration is enabling CORS only for a wildcard set of MIME types that doesn't include font/woff2, or applying the header only to GET requests without also covering the CORS preflight OPTIONS request some proxies insert. Test both explicitly:

curl -sI -H "Origin: https://www.example.com" \
  https://cdn.example.com/fonts/inter-var-latin.woff2 | grep -i access-control

Cache-Control and Immutability at the Edge

Because fingerprinted font URLs never change content, the correct cache posture is the most aggressive one available: public, max-age=31536000, immutable. The immutable directive (supported in Firefox, Safari, and Chromium-based browsers) tells the browser it does not even need to send a conditional revalidation request (If-None-Match / If-Modified-Since) on reload — without it, some browsers still issue a 304-round-trip revalidation on a hard refresh even though max-age hasn't expired, wasting a round trip for a byte-for-byte-identical response. At the CDN layer, a separate edge TTL controls how long the PoP itself holds the object before checking the origin again; this can safely exceed the browser's max-age since the fingerprinted filename guarantees content stability. A typical edge configuration sets Cache-Control: public, max-age=31536000, immutable for the browser and a CDN-specific edge TTL (often s-maxage or a CDN control-panel setting) of the same year-long duration, refreshed automatically whenever a new fingerprinted filename is deployed rather than by invalidating the old one.

Cache-Control: public, max-age=31536000, immutable, s-maxage=31536000
Vary: Accept-Encoding

The Vary: Accept-Encoding header matters when the CDN serves both Brotli- and gzip-compressed variants of the same WOFF2 file depending on the client's Accept-Encoding — without it, a shared cache can serve a Brotli response to a client that only accepts gzip. WOFF2's own internal compression is discussed in How WOFF2 Brotli Compression Shrinks Fonts, and the interaction between this header and shared-cache partitioning is covered in HTTP Cache Partitioning and Cross-Site Font Sharing — a page worth reading if you assumed a popular font CDN would give you a cross-site cache hit, because modern browsers partition the HTTP cache per top-level site and that assumption no longer holds.

Font TTFB by Delivery Path Bar chart comparing typical time-to-first-byte for a font file served from a distant origin, a general CDN, and an edge-cached PoP. Font TTFB by Delivery Path Distant origin 320ms General CDN 60ms Edge PoP cached 20ms TTFB
Time to first byte drops sharply as the font moves from a single origin to an edge PoP.

HTTP/2, HTTP/3, and Request Prioritization

Serving fonts from a CDN also changes how the browser can prioritize the request relative to everything else on the page. Under HTTP/1.1, each origin gets a small number of parallel connections, so a font competing with CSS and images for one of six connection slots to the same host can queue. Under HTTP/2 and HTTP/3, all requests to a given origin multiplex over a single connection, and the browser assigns priority weights — a <link rel="preload" as="font"> is typically issued at a high priority comparable to render-blocking CSS, ahead of below-the-fold images. Splitting fonts onto a dedicated CDN subdomain historically meant paying a new TLS handshake and DNS lookup for that subdomain (mitigated by <link rel="preconnect">), a trade-off covered in depth in Preload vs Prefetch vs Preconnect for Fonts. With HTTP/3's 0-RTT connection resumption and QUIC's independent stream multiplexing, the penalty for a separate CDN origin has shrunk further, but it has not disappeared — a cold connection to a font CDN still costs a DNS lookup plus a TLS handshake on a visitor's very first page view, which is exactly the request that determines first paint. fetchpriority="high" on the preload link, discussed in Using fetchpriority to Prioritise Font Requests, gives you an additional lever independent of the connection-level prioritization the protocol already applies.

CDN vs Self-Hosted Origin vs Google Fonts: A Decision Matrix

Delivery option Typical TTFB (edge-cached) CORS setup effort Cache control you own Third-party request
General-purpose CDN (self-hosted files) 10–40 ms Manual, full control Full No
Dedicated edge network (Cloudflare, Fastly) 5–25 ms Manual, full control Full No
Google Fonts CDN 20–60 ms (varies by region) None needed (preconfigured) None Yes
Origin server, no CDN 80–400 ms (geography-dependent) Manual, full control Full No

A self-hosted CDN or dedicated edge network gives you full control over cache headers and CORS, at the cost of setting them up correctly yourself. Google Fonts removes that setup burden but removes your control over caching policy and adds a third-party request — the trade-offs of that specific choice are the subject of Google Fonts vs Self-Hosting: The Delivery Decision. An unfronted origin server is rarely the right choice for a production font once your audience spans more than one region, since TTFB scales directly with the distance to your single serving location.

Edge Delivery at a Glance Four stat tiles summarizing the measured effects of CDN and edge delivery on font TTFB, repeat-visit requests, and cache lifetime. Edge Delivery at a Glance 20ms Edge-cached font TTFB 0 Repeat-visit font requests 1yr Safe immutable max-age 2x Fetches without crossorigin
Headline effects of correct CDN configuration on font requests.

Two More Worked Examples

Edge function CORS rewrite (Cloudflare Workers style):

export default {
  async fetch(request) {
    const response = await fetch(request);
    const headers = new Headers(response.headers);
    if (request.url.endsWith(".woff2")) {
      headers.set("Access-Control-Allow-Origin", "https://www.example.com");
      headers.set("Cache-Control", "public, max-age=31536000, immutable");
      headers.set("Timing-Allow-Origin", "*");
    }
    return new Response(response.body, { status: response.status, headers });
  },
};

This pattern is useful when the storage backend (an object store, a bucket) doesn't let you set per-object headers directly, or when you want one rule applied consistently across every font file without re-uploading each one.

Nginx origin configuration:

location ~* \.(woff2|woff)$ {
  add_header Access-Control-Allow-Origin "https://www.example.com" always;
  add_header Cache-Control "public, max-age=31536000, immutable" always;
  add_header Timing-Allow-Origin "*" always;
  types { font/woff2 woff2; font/woff woff; }
  gzip off;
  brotli off;
}

Note gzip off and brotli off at this layer — WOFF2 is already compressed internally, and re-compressing an already-compressed binary format wastes CPU and can occasionally inflate the payload slightly. Let the CDN or origin serve the pre-built WOFF2 as-is.

Multi-Region Failover and Origin Shield

A single CDN configuration usually assumes the origin is reliably reachable, but font delivery deserves the same failover thinking as any render-blocking asset. Most CDNs support an "origin shield" — a single intermediate cache layer that sits between edge PoPs and your origin, so that a cache miss at one PoP doesn't create dozens of simultaneous origin requests (a thundering-herd problem that's easy to trigger the moment you deploy a new fingerprinted font filename and every PoP's cache invalidates at once). Configuring an origin shield region close to your actual origin, rather than letting every PoP hit the origin independently, keeps the origin's request volume flat even during a global cache-cold event such as a fresh deploy.

A second failover consideration is what happens when the CDN itself has a regional outage. Because fonts degrade gracefully to the next src in the @font-face fallback chain or to the browser's system font when a fetch fails outright, a CDN outage on a render-blocking font-display: block configuration can hold text invisible for the full block period before falling back — worse than a slow-but-successful fetch. Pairing a defensive font-display: swap or optional with CDN delivery means a rare outage degrades to a visible fallback font rather than a blank page, which is the same reasoning covered for JavaScript-driven timeouts in Graceful Degradation When Fonts Time Out. Some teams also configure a secondary CDN or a same-region object-storage fallback URL and swap between them with a health check, though this is only worth the operational overhead for sites where font-driven CLS or blank-text time is a tracked business metric.

Compression Negotiation at the Edge

WOFF2 files are already Brotli-compressed internally, but the HTTP transport layer can still apply a second, transport-level compression pass if the CDN is misconfigured to do so — and whether that helps or hurts depends on the CDN. Because WOFF2's internal compression already reduces entropy close to the practical floor, re-compressing the container with gzip or Brotli at the transport layer typically saves under 1% and sometimes adds a few bytes of framing overhead, while spending CPU cycles at the edge on every cache-miss request. The correct edge configuration disables transport compression specifically for .woff2 and .woff MIME types (as shown in the Nginx example above) while leaving it enabled for text assets like CSS, HTML, and JS, where compression can still save 60–80% of transfer size. Confirm the behavior empirically rather than assuming: request the font with curl -H "Accept-Encoding: br" and compare the Content-Length against a request with Accept-Encoding: identity — if they're within a few bytes of each other, transport compression isn't helping and can be safely disabled to save edge CPU.

Common Pitfalls

  • Preloading without crossorigin, causing a silent double fetch. The font downloads twice — once for the preload, once for the actual CSSOM fetch — because the two requests use different credential modes and the browser cannot match the cache entries. Fix: always pair as="font" with crossorigin on the preload tag.
  • Missing Access-Control-Allow-Origin on the CDN response. The font request completes with a 200 in the Network panel but the glyph never renders — the response is opaque and the @font-face rule fails closed to the fallback font. Fix: set the header explicitly at the CDN or edge-function layer rather than assuming a default CORS policy covers font MIME types.
  • Serving fonts with Content-Type: application/octet-stream. Some browsers reject a font whose MIME type doesn't match font/woff2 or font/woff, especially when strict MIME sniffing is enabled. Fix: configure the correct type mapping at the storage or CDN layer.
  • Relying on max-age alone without immutable. Even with a year-long max-age, some browsers issue a conditional revalidation request on a user-triggered reload, adding a round trip that a fingerprinted, unchanging file doesn't need. Fix: add immutable to the same header.
  • Splitting fonts onto a separate CDN subdomain without preconnect. The first font request pays a fresh DNS lookup and TLS handshake exactly when the render-blocking path can least afford it. Fix: add <link rel="preconnect" href="https://cdn.example.com" crossorigin> in the document head.
  • Assuming a shared font CDN caches across sites. Modern browsers partition the HTTP cache by top-level site, so two unrelated sites loading the identical Google Fonts URL each pay their own cache miss on first visit — the once-common "shared cache" advantage no longer exists. Fix: evaluate self-hosting on its actual latency and control merits, not on a cross-site cache-sharing assumption.

Frequently Asked Questions

Do I need a CDN if my font files are already small (under 30 KB each)? Byte size and delivery latency are separate problems. A small file still pays the full round-trip time to a distant origin server before its first byte arrives, and that RTT — not the transfer time — dominates when the font is render-blocking. A CDN or edge network reduces RTT by serving from a PoP near the visitor; it helps even a tiny file load faster on a first, uncached visit.

Does Access-Control-Allow-Origin: * work for fonts, or do I need to name my exact origin? A wildcard * works for fonts because font requests are never sent with credentials (cookies), so the stricter same-origin-echo requirement that applies to credentialed CORS requests doesn't apply here. Naming your exact origin is still fine and slightly more defensive, but * is a valid and common choice specifically for font assets.

Why does the font still show a brief flash of the fallback font even with a CDN and long cache headers? Long cache headers only help on repeat visits after the first cache write; a first-time visitor to your site (or one whose cache was cleared) still pays the network round trip once, however fast the CDN. That window is governed by font-display and preload timing, not by cache headers — see font-display Values: swap, optional, fallback Explained for the render-timing side of the problem.

Should I use the same CDN for fonts as for images and JavaScript? It's usually fine and simpler operationally, but if your CDN doesn't let you set per-path cache and CORS rules distinctly from other asset types, consider a dedicated font path or subdomain so a change to your image caching policy can never accidentally regress font immutable headers.

Related