You’re loading fonts wrong (and it’s crippling your performance)

Fonts are one of the most visible, most powerful parts of the web. They carry our brands, shape our identities, and define how every word feels. They’re the connective tissue between design, content, and experience.

And yet: almost everyone gets them wrong.

It’s a strange paradox. Fonts are everywhere. Every website uses them. But very few people – designers, developers, even performance specialists – actually know how they work, or how to load them efficiently. The result is a web full of bloated font files, broken loading strategies, poor accessibility, and a huge amount of wasted bandwidth.

Fonts aren’t decoration. They’re infrastructure. They sit on the critical rendering path, they affect performance metrics like LCP and CLS, they carry licensing and privacy baggage, and they directly influence whether users can read, engage, or trust what’s on the page. If you don’t treat them with the same care and discipline you apply to code, you’re hurting your users and your business.

This article is a deep dive into that problem. We’ll look at how we got here – from the history of web-safe fonts and the rise of Google Fonts, through the myths and bad habits that still dominate today. We’ll get into the mechanics of how fonts actually work in browsers, and why the defaults and “best practices” you’ll find online are often anything but.

We’ll explore performance fundamentals, loading strategies, modern CSS techniques, and the global realities of serving text in multiple scripts and languages. We’ll also dig into the legal and ethical side of font usage, and what the future of web typography might look like.

By the end, you’ll know why your current font setup is probably wrong – and how to fix it.

Because if there’s one thing you take away, it’s this: fonts are not free, fonts are not simple, and fonts are not optional. They deserve the same rigour you apply to performance, accessibility, and SEO.

A brief history of webfonts

To understand why so many people still get fonts wrong, you need a bit of history. The way we think about web typography today is still shaped by the compromises, hacks, and half-truths of the last twenty years.

The “web-safe” era

In the early days, there was no such thing as custom web typography. You picked from a handful of “web-safe” system fonts (Arial, Times New Roman, Verdana, Georgia) and hoped they looked the same on your users’ machines. If you wanted anything else, you sliced it into images.

Hacks before @font-face: sIFR and Cufón

Designers wanted brand typography, but browsers weren’t ready. Enter the hacks:

  • sIFR (Scalable Inman Flash Replacement): text rendered in Flash, swapped in at runtime over the real HTML. It worked, sort of, but was heavy, brittle, and inaccessible.
  • Cufón: a JavaScript trick that converted fonts into vector graphics and injected them into pages. No Flash required, but still slow and inaccessible.

These were desperate attempts to break out of the web-safe ecosystem, but they cemented the idea that custom typography was always going to be fragile, heavy, and hacky.

The arrival of @font-face

Then came @font-face. In theory, it let you serve any typeface you wanted, embedded straight into your CSS. In practice, it was a mess:

  • Different browsers required different, often proprietary formats: EOT (Embedded OpenType) for Internet Explorer, SVG fonts for early iOS Safari, raw TTF/OTF elsewhere.
  • Developers built “bulletproof” @font-face stacks – verbose CSS rules pointing to four different file formats just to cover every browser.
  • Licensing was a nightmare: many foundries banned web embedding or charged per-domain/pageview royalties.
  • Piracy was rampant, with ripped desktop fonts dumped online as “webfonts”.

Commercial services: Typekit and friends

Recognising the mess, commercial services stepped in. Typekit (launched 2009, now Adobe Fonts, and just as awful) offered subscription-based, legally licensed, properly formatted webfonts with a simple embed script. Other foundries built their own hosted services.

Typekit solved licensing and compatibility headaches for many teams, but it also entrenched the idea that fonts should load via third-party JavaScript snippets – a pattern that persists on millions of sites today.

Compatibility hacks and workarounds

Even with @font-face and services like Typekit, the webfont era was littered with workarounds:

  • Hosting multiple formats of the same font, bloating payloads.
  • Shipping fonts with whole Unicode ranges bundled “just in case”.
  • Battling FOUT (Flash of Unstyled Text) vs FOIT (Flash of Invisible Text), often with ugly JavaScript “fixes”.
  • Leaning on icon fonts to cover missing glyphs and UI symbols.

A whole generation of developers learned fonts as fragile, bloated, and temperamental – lessons that still echo in today’s bad practices.

Google Fonts and the “free font” boom

In 2010, Google Fonts arrived. Suddenly, there was a free, easy CDN with a growing library of open-licensed fonts. Developers embraced it, designers tolerated it, and performance people grumbled but went along with it.

It solved a lot of problems (including licensing, formats, hosting, and CSS wrangling) but it also created new ones. Everyone defaulted to it, even when they shouldn’t. Fonts started loading from a third-party CDN on every pageview, often slowly, and sometimes even illegally (as European courts would later decide).

An aside: Licensing realities

Licensing is the quiet trap in many font strategies. Not every “webfont license” lets you do what this article recommends. Some foundries:

  • Prohibit subsetting or conversion to WOFF2.
  • Charge based on pageviews or monthly active users.
  • Restrict embedding to specific domains.

That’s why Google Fonts felt liberating: no lawyers. But commercial fonts often come with terms that make aggressive optimisation legally risky. If you’re paying for a brand font, read the contract – or negotiate it – before you start slicing and optimising. Some foundries even charge per pageview or per monthly active user, so aggressive optimisation could technically put you out of compliance if you don’t have the right license.

The myths that stuck

From these eras came a set of myths and bad habits that are still alive today:

  • That custom fonts are “free” and easy.
  • That it’s fine to ship a single, monolithic font file for every user, in every language.
  • That Google Fonts (or Typekit) is always the best option.
  • That typography is a design flourish, not a performance or accessibility concern.

Those assumptions made sense in 2005 or even 2010. They don’t today. But they still shape how most websites load fonts – which is why the state of web typography is such a mess.

How fonts work (the basics)

Before we start tearing down bad practices, we need a shared baseline. Fonts are deceptively simple – “just some CSS” – but under the hood, they’re a surprisingly complex part of the rendering pipeline. Understanding that pipeline explains why fonts so often go wrong.

Formats: from TTF to WOFF2

At heart, a font is a container of glyphs (shapes), tables (instructions, metrics, metadata), and sometimes extras (ligatures, alternate forms, emoji palettes). They come in one of the following formats:

  • TTF/OTF (TrueType/OpenType): desktop-oriented formats, heavy and not optimised for web transfer.
  • EOT: Internet Explorer’s proprietary format, thankfully extinct.
  • SVG fonts: an early hack for iOS Safari, nearly extinct.
  • WOFF (Web Open Font Format): a wrapper that compressed TTF/OTF for the web.
  • WOFF2: the modern default – smaller, faster, built on Brotli compression.

If you’re serving anything but WOFF2 today, you’re doing it wrong. For almost every project, WOFF2 is all you need. Unless you have a specific business case (like IE11 on a locked-down enterprise intranet), serving older formats just makes every visitor pay a performance tax. If you absolutely must support a legacy browser, add WOFF as a conditional fallback – but don’t ship it to everyone.

The rendering pipeline

When a browser “loads a font,” it isn’t just a straight line from CSS to pixels. Multiple stages (and your CSS choices) dictate how text behaves.

  1. Registration: As the browser parses CSS, each @font-face rule is registered in a font set – essentially a catalogue of families, weights, styles, stretches, and unicode-ranges. At this stage, no files are downloaded.
  2. Style resolution: The cascade runs. Each element ends up with a computed font-family, font-weight, font-style, and font-stretch. The browser compares that against the registered font set to see what could be used.
  3. Font matching: The font-matching algorithm looks for the closest available face. If the requested weight or style doesn’t exist, the browser may synthesise it (fake bold/italic) or fall back to a generic serif/sans/monospace.
  4. Glyph coverage: Fonts are only queued for download if the text actually requires glyphs from them. If a unicode-range excludes the characters on the page, the font may never load at all.
  5. Request: Once needed, the font request is queued. If @font-face rules are buried in a late-loading stylesheet, this can happen surprisingly late in the render cycle. Preload or inline to avoid the lag.
  6. Display phase: While waiting for the font to arrive, the browser decides how to handle text – this is where font-display matters: 
    • No explicit setting (old default): Historically inconsistent. Safari often hid text entirely until the font arrived (FOIT), while Chrome showed fallback text immediately (FOUT). This inconsistency fuelled years of bad hacks.
    • font-display: swap; Renders fallback text immediately, then swaps to the webfont when ready (FOUT).
    • font-display: block; Hides text for up to ~3s (FOIT), then shows fallback if still not ready.
    • font-display: fallback; Very short block (~100ms), then fallback shows. Font swaps later if it arrives.
    • font-display: optional; Shows fallback immediately and may never swap if conditions are poor.
  7. Decoding and shaping: Once downloaded, the font is decompressed, parsed, and shaped (OpenType features applied, ligatures resolved, contextual forms chosen). Only then can glyphs be rasterised and painted. On low-end devices, this shaping step can add noticeable delay.

All of this happens under the hood before a single glyph hits the screen. Developers can’t change how a shaping engine works – but they can influence what happens afterwards. The next piece of the puzzle is metrics: how tall, wide, and spaced your text appears, and how to stop those dimensions from shifting when fonts swap in.

Metrics

Fonts don’t just define glyph shapes. They also define metrics:

  • Ascent, descent, line gap – how tall lines are, where baselines sit.
  • x‑height – how big lowercase letters appear.
  • Kerning and ligatures – how characters fit together.

If your fallback system font has different metrics, the page will render one way during FOUT, then “jump” when the custom font loads. That’s not just ugly – it’s measurable layout shift, and it can tank your Core Web Vitals.

We’ll explore how to tinker with these values later in the post.

Synthesised styles

When the browser can’t find the exact weight or style you’ve asked for, it doesn’t just give up. It fakes it:

  • Fake bolding: If you request font-weight: 600 but only have a 400 (regular), most browsers will thicken the strokes algorithmically. The result often looks clumsy, inconsistent, and can ruin brand typography.
  • Fake italics: If you request font-style: italic without having a true italic face, the browser simply slants the regular glyphs. It’s a cheap trick, and typographically awful.

That “helpfulness” can make your typography look sloppy, and it can throw off spacing/metrics in subtle ways. The fix:

  • Only declare weights/styles you actually provide.
  • Use font-synthesis: none; to prevent browsers from faking bold/italic.
  • Subset/serve the actual weights you need – and stop at the ones you’ll really use.

One more layer: once fetched, fonts aren’t just “ready.” Browsers must decode, shape, and rasterize them. That means parsing OpenType tables, applying shaping rules (HarfBuzz, CoreText, DirectWrite), and rasterising glyphs to pixels. On low-end devices, this step can take measurable milliseconds. Font choice isn’t just about bytes on the wire – it’s also about CPU cycles at paint time.

Glyph coverage

Finally, fonts aren’t universal. A Latin font may not contain accented characters, Cyrillic glyphs, Arabic ligatures, or emoji. When a glyph is missing, the browser silently switches to a fallback font to cover that code point. The result can be inconsistent rendering, mismatched sizing, or even boxes and question marks.

This is why subsetting matters, why fallback stacks matter, and why understanding coverage is essential.


So: fonts aren’t just “download this file and it works.” They’re complex, heavy, and integral to how the browser paints text. Which is exactly why treating them as decoration – instead of as infrastructure – is such a bad idea.

Performance & strategy fundamentals

If the history explains why fonts are messy, the performance reality explains why they matter. Fonts aren’t just a design choice – they’re part of your critical rendering path, and they can make or break your Core Web Vitals.

File size

Most websites are serving far too much font data. A single “complete” font family can easily be 400 – 800 KB per style. Add bold, italic, and a few weights, and suddenly you’re shipping multiple megabytes of font data before your content is even legible. That’s more than many sites spend on JavaScript.

And the kicker? Most of those glyphs and weights are never used.

Layout shift

Fonts don’t just block rendering; they actively cause reflows when they arrive.

  • If your fallback font has different metrics (x‑height, ascent, descent, line-gap), your content will jump when the webfont loads.
  • That’s measurable Cumulative Layout Shift (CLS), and it directly impacts Core Web Vitals.

The good news: modern CSS gives us the tools to fix all of this.

Modern CSS descriptors (and what they actually do)

font-display – controls what happens while the font is loading.

  • swap: show fallback immediately, swap to webfont when ready (FOUT). Good default.
  • fallback: tiny block (~100ms), then fallback; swap later. Safer on poor networks.
  • optional: show fallback, may never swap. Great for decorative fonts.
  • block: hide text for a while (≈3s). Looks “clean” on fast, awful on slow. Avoid.

👉 This is your first-paint policy. Choose carefully.


Metrics override descriptors – make fallback and webfont metrics match.

These live inside @font-face. They tell the browser: “scale and align this webfont so it behaves like the fallback you showed first.” That way, when the swap happens, nothing jumps.

  • size-adjust: scales the webfont so its perceived x‑height matches the fallback.
  • ascent-override / descent-override: align baselines and descender space.
  • line-gap-override: controls extra line spacing to keep paragraphs steady.

Example:

@font-face {
  font-family: 'Brand';
  src: url('/fonts/brand.woff2') format('woff2');
  font-display: swap;                 /* first paint policy */  size-adjust: 102%;                  /* match fallback x-height */  ascent-override: 92%;               /* align baseline */  descent-override: 8%;               /* balance descenders */  line-gap-override: normal;          /* stabilise line height */}

In practice, you can use tools like Font Style Matcher to calculate the right values. These help you match fallback and custom font metrics precisely and eliminate CLS .


unicode-range – serve only the glyphs a page actually needs.

Declare separate @font-face blocks for each subset (Latin, Latin-Extended, Cyrillic, etc.). The browser only requests the ones it needs.

Example:

@font-face {
  font-family: "Brand";
  src: url("/fonts/brand-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
  font-display: swap;
}

👉 Saves hundreds of kilobytes by not shipping glyphs for scripts you’ll never use.


font-size-adjust – property for elements (not @font-face).

Scales fallback fonts so their x‑height ratio matches the intended font. Prevents fallback text from looking too small or too tall.

Example:

html { font-size-adjust: 0.5; } /* ratio matched to your brand font */

From descriptors to strategy

These CSS descriptors are your scalpel: precise tools for cutting out CLS and wasted payload. But solving font performance isn’t just about fine-tuning metrics; it’s about making the right high-level choices in how you ship, scope, and prioritise fonts in the first place.

Language coverage and subsetting

A huge but often overlooked opportunity is language coverage and subsetting.

Most sites only need Latin or Latin Extended, yet many ship fonts containing Cyrillic, Greek, Arabic, or full CJK sets they’ll never use. That’s hundreds of kilobytes – sometimes megabytes – wasted on every visitor.

Smarter strategy:

  • Subset fonts with tools like fonttools, Glyphhanger, or Subfont.
  • Use unicode-range to declare subsets per script.
  • Build locale-specific bundles (e.g. fonts-en.css, fonts-ar.css) for internationalised sites.

That way, browsers will only download subsets if they’re needed – so a Cyrillic user gets Cyrillic, a Latin user gets Latin, and nobody pays for both.

When not to subset

⚠️ For sites with genuine multilingual needs, especially across non-Latin scripts, stripping glyphs can do more harm than good. Arabic, Hebrew, Thai, and Indic scripts rely on shaping and positioning tables (GSUB/GPOS). We’ll explore this later.

⚠️ And if your site has a lot of user-generated content, be conservative. Users will surprise you with stray Greek, Cyrillic, or emoji. In those cases, lean on broader coverage or robust system fallbacks rather than slicing too aggressively.

Lazy-loading non-critical fonts

Not every font has to be part of the critical rendering path. Headline display fonts, decorative typefaces, and icon sets (e.g., for social media icons that only appear in the footer) often aren’t essential to that first paint. These can be deferred or staged in later, once the core content is visible.

Two reliable approaches:

  • Use the Font Loading API (document.fonts.load) to request and apply them after the page is stable.
  • Or set font-display: optional, which tells the browser the fallback is fine – and if the custom font arrives late (or never), the page still works.

This keeps the focus on performance where it matters most: content-first, aesthetics second.

Fonts as progressive enhancement

At the end of the day, fonts should be treated as progressive enhancement. Your site should load quickly, render legibly, and remain usable even if a custom font never arrives. A well-chosen system fallback ensures content-first delivery, while the webfont (if, or when, it loads) adds polish and brand identity.

Typography matters, but it should never get in the way of reading, speed, or stability.

Variable fonts: promise vs reality

If subsetting and smart loading are the practical fixes, variable fonts are the seductive promise. One font file, infinite possibilities. The idea is compelling: instead of shipping a dozen separate files for regular, bold, italic, condensed, and wide, you just ship one variable font that can flex along those axes.

And in theory, that means less to download, finer design control, and a more responsive, fluid typographic system.

The promise

  • Consolidation: collapse dozens of static files into a single resource.
  • Precision: use exact weights (512, 537…) instead of stepping through 400/500/600.
  • Responsiveness: unlock width and optical size axes that adjust seamlessly across breakpoints.
  • Consistency: fewer moving parts, cleaner CSS, and potentially smaller payloads.

The reality

Variable fonts are brilliant – but not a magic bullet.

  • File size creep: if you only need two weights, a variable file may actually be larger than two well-subset static fonts.
  • Browser support quirks: weight interpolation is universal, but some axes (optical sizing, italic, grade) are patchy across browsers.
  • Double-loading traps: many teams ship a variable font and static files “just in case,” which cancels out the benefits.
  • Licensing headaches: some foundries sell or license variable fonts separately, or prohibit modifications like subsetting.
  • Support quirks: core axes like weight, width, slant, and optical size are now universally supported. But custom axes (like grade) still require font-variation-settings and may not have CSS shorthands. So don’t assume every axis is ergonomic across browsers.

Performance strategy

Treat variable fonts like any other asset: audit, measure, and subset.

  • Pick your axes carefully: do you really need width, optical size, or italics?
  • Subset by script just as you would with static fonts; don’t ship the whole world.
  • Benchmark payloads: check whether one variable file actually saves over two or three statics.

Design strategy

When used deliberately, variable fonts unlock design latitude you simply can’t get otherwise.

  • Responsive typography: scale weight or width subtly as the viewport changes.
  • Optical sizing: automatically adjust letterforms for legibility at small vs large sizes.
  • Brand expression: interpolate between styles for more personality than a static set.

But use restraint. Animating font-variation-settings may look slick in demos, but it often janks in practice.

Example: using variable font axes in CSS

/* Load a variable font */@font-face {
  font-family: "Acme Variable";
  src: url("/fonts/acme-variable.woff2") format("woff2-variations");
  font-weight: 100 900;        /* declares supported weight range */  font-stretch: 75% 125%;      /* declares supported width range */  font-style: normal italic;   /* declares upright and slanted */  font-display: swap;
}

/* Use weight and width as normal */h1 {
  font-family: "Acme Variable", system-ui, sans-serif;
  font-weight: 700;    /* resolves within the declared 100–900 range */  font-stretch: 110%;  /* slightly wider */}

/* Non-standard axes via font-variation-settings */.hero-text {
  font-family: "Acme Variable", system-ui, sans-serif;
  font-variation-settings: "wght" 500, "wdth" 120, "slnt" -5;
}

/* Responsive fluid typography: adjust weight with viewport size */h2 {
  font-family: "Acme Variable", system-ui, sans-serif;
  font-weight: clamp(400, 2vw + 300, 700);
}

👉 This shows both the “semantic” way (with font-weight, font-stretch, font-style) and the raw font-variation-settings way for full control.

Best practice

  • Start with a needs audit: what weights, styles, and scripts do you actually use?
  • If variable fonts win on size and coverage, great – deploy them with subsetting and unicode-range.
  • If two or three statics are leaner and simpler, stick with them.

Variable fonts are a tool, not a default. The key is to be deliberate: weigh the trade-offs, and implement them with the same discipline you’d apply to any other part of your performance budget.

System stacks and CDNs

Not every project needs custom fonts. In fact, one of the most powerful performance wins is simply not loading them at all.

System font stacks

System fonts – the ones already bundled with the OS – are free, instant, and familiar. The trick is in choosing a system stack that feels cohesive across platforms. A typical modern stack looks like this:

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
               Helvetica, Arial, sans-serif, "Apple Color Emoji",
               "Segoe UI Emoji", "Segoe UI Symbol";
}

This cascades through macOS, iOS, Windows, Android, Linux, and falls back cleanly to web-safe sans-serifs. For body text, navigation, and utilitarian UI elements, system stacks are hard to beat.

They’re also excellent fallbacks: even if you do load custom fonts, designing around the system stack first guarantees legibility and resilience.

It’s also worth noting that system fonts almost always handle emoji better – lighter weight, more coverage, and more consistent rendering than trying to ship emoji glyphs in a webfont. We’ll explore emojis this in more detail later.

CDNs and third-party hosting

For years, Google Fonts was the default solution: paste a <link> into your <head> and you were done. But today that’s a bad trade-off.

  • Privacy: loading fonts from Google Fonts leaks visitor data to Google. Regulators (especially in Europe) have judged this a GDPR violation.
  • Performance: third-party CDNs add latency, DNS lookups, and potential blocking. In most cases, self-hosting is faster and more reliable.
  • Caching myths: the old argument that “Google Fonts are already cached” simply isn’t true anymore. Modern browsers partition caches per site for privacy. A font fetched on site A won’t be reused on site B. In practice, each site (and the user) pays the cost independently.

Best practice is simple: self-host your fonts. Download them from the foundry (or even from Google Fonts), serve them from your own domain, and control headers, preloading, and caching yourself.

But even if you self-host and optimise your fonts, what users see first isn’t your brand font – it’s the fallback. That’s where the real user experience lives.

Fallbacks and matching

Loading fonts isn’t just about the primary choice – it’s also about how gracefully the design holds up before and if the custom font arrives. That’s where fallbacks matter.

Designing with fallbacks in mind

A fallback font isn’t just an emergency plan – it’s a baseline your visitors might actually see, even if only for a few milliseconds. That makes it worth designing for. A good fallback:

  • Matches the x‑height and letter width of your primary font closely enough that layout shifts are minimal.
  • Feels stylistically compatible: if your brand font is a geometric sans, pick a system sans, not Times New Roman.
  • Includes emoji, symbols, and ligatures that your primary font may lack.

Tuning fallbacks with modern CSS

We covered font-size-adjust and font-optical-sizing in Section 4, but the key is their application here: you can actually tune your fallback stack to minimise visible shifts.

For example, if your fallback font has a smaller x‑height, you can bump its size slightly using font-size-adjust so that text aligns more closely with your custom font when it swaps in.

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  font-size-adjust: 0.52; /* Match x-height ratio of custom font */}

This avoids the infamous “jump” when the real font finishes loading.

Matching custom and fallback fonts

The end goal isn’t perfection, it’s stability. You won’t get Helvetica Neue to perfectly mirror Segoe UI, but you can:

  • Choose fallbacks with similar proportions.
  • Adjust size/line-height to reduce reflow.
  • Use variable font axes (when available) to more closely approximate your fallback’s look at initial render.

The better your fallback, the less anyone notices when your custom font finally kicks in.

And remember: rendering engines differ. Windows ClearType, macOS CoreText, and Linux FreeType all anti-alias and hint fonts differently. Chasing pixel-perfect consistency across platforms is a lost cause; stability and legibility matter more than identical rendering.

Preloading and loading strategies

Even with the right fonts, subsets, and fallbacks, the delivery strategy can make or break the user experience. A beautiful font served late is still a broken experience.

The alphabet soup of loading outcomes

Most developers have at least heard of FOIT and FOUT, but rarely think about how deliberate choices (or lack thereof) cause them.

  • FOIT (Flash of Invisible Text): text is hidden until the custom font loads. Looks sleek when it works fast, looks catastrophic on slow networks.
  • FOUT (Flash of Unstyled Text): fallback text renders first, then switches when the custom font arrives. Stable, but potentially jarring.
  • FOFT (Flash of Faux Text): a messy hybrid where a browser synthesises weight/italic, then swaps to the real cut. Distracting and ugly.

The browser’s defaults – and your CSS – determine which outcome users see.

font-display

The font-display descriptor is the blunt instrument for influencing this:

  • swap: show fallback immediately, swap when ready (the safe modern default).
  • block: hide text (FOIT) for up to 3s, then fallback. Dangerous.
  • fallback: like swap, but gives the real font less time to load.
  • optional: load only if the font is already fast/cached. Good for non-critical assets.

Most sites should default to swap. Don’t leave it undefined.

Preloading fonts

Preload is the sharp tool. Adding:

<link rel="preload" as="font" type="font/woff2" crossorigin
      href="/fonts/brand-regular.woff2">

…tells the browser to fetch the font immediately, rather than waiting until it encounters the @font-face rule in CSS. This is especially valuable if you inline your @font-face declarations in the <head> (as you should) – otherwise fonts often load after layout and render have already begun.

⚠️ Be selective when preloading: if you’ve split fonts into subsets with unicode-range, only preload the subset you know is needed for initial content. Preloading every subset defeats the purpose by forcing them all to download, even if not used.

Preload gotchas (worth your time)

  • Match everything: your preload URL must exactly match the @font-face src (path, querystring), and the response must include the right CORS header (Access-Control-Allow-Origin), and your <link> must carry crossorigin. If any of those disagree, the preload won’t be reused by the actual font load. <link rel="preload" as="font" type="font/woff2" href="/fonts/brand-regular.woff2" crossorigin>
  • Use the right as/type: as="font" and a correct MIME hint (type="font/woff2") influence prioritization and help browsers coalesce requests. Wrong/missing values can cause the preload to be ignored.
  • Don’t preload everything: if you’ve split by unicode-range (e.g., Latin, Cyrillic), preload only the subset you’ll actually paint above the fold. Preloading every subset forces downloads and defeats subsetting.
  • Preload used late” warnings: browsers will warn if a preloaded resource isn’t used shortly after navigation. That’s usually a smell (wrong URL, late‑discovered @font-face, or you preloaded a non‑critical face).
  • Service Worker synergy: if you run a SW, pre‑cache WOFF2 at install. First‑hit uses preload; subsequent hits come from SW in ~0ms.

Inline vs buried @font-face

This is an easy win that almost nobody takes. If your @font-face lives in an external CSS file, the browser won’t even discover the font until that file is downloaded, parsed, and executed. Inline it in the <head> and preload the asset, and you’ve cut an entire round trip out of the waterfall.

But – there are caveats.

  • If you already ship a single, render-blocking stylesheet early: inlining doesn’t buy you much. The browser was going to see those @font-face rules quickly anyway, and it still won’t request the font until the text that needs it is on screen. The browser will also wait until that render-blocking CSS is executed in case it overrides the font. In that setup, preload is what really makes the difference.
  • If your CSS arrives late or piecemeal – critical CSS inline, async or route-level styles, CSS-in-JS, @import, SPA hydration – then inlining can be genuinely useful. It ensures fonts are discovered immediately, not halfway through page render. In those cases, it’s an under-used safeguard.

So: inlining plus preload can be a neat win, especially on modern, fragmented architectures. But if it makes a dramatic difference to your site, that’s also a signal that your CSS delivery strategy might need fixing.

Early Hints (HTTP 103)

Even preloading has limits – the browser still has to parse enough of the HTML to see your <link rel="preload"> (or, wait for the HTTP response to get the equivalent header). If your server or network is slow, that might take quite some time.

With Early Hints (HTTP status 103), the server can tell the browser immediately which critical assets to start fetching – before the main HTML response is delivered.

That means your fonts can be on the wire during the first round trip, rather than waiting for HTML parsing.

HTTP/1.1 103 Early Hints
Link: </fonts/brand-regular.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin

Things to bear in mind:

  • Coalesce with HTML Link preloads: it’s fine to hint the same font in 103 and again in the final 200 via a Link header/HTML tag (as modern browsers dedupe). Don’t rely on intermediaries though; some proxies still drop 103s. Keep the HTML/200 fallback preload.
  • Manage CORS in the hint: include crossorigin in the 103 Link, so that he early request is eligible for reuse by the @font-face.
  • Be choosy: only hint critical above‑the‑fold faces/weights. Over‑hinting competes with HTML/CSS and can slow TTFB in practice.

Support is growing across servers, CDNs, and browsers. If you’re already preloading fonts, adding Early Hints is a straightforward way to shave another few hundred milliseconds off time-to-text.

⚠️ Don’t go wild: only hint fonts you know are needed above the fold. Over-hinting can waste bandwidth and compete with more critical assets.

Don’t use @import

One of the worst mistakes is loading fonts (or CSS that declares them) via @import. Every @import is another round trip: the browser fetches the parent CSS, parses it, then discovers it needs another CSS file, then discovers the @font-face… and only then requests the font.

That means your text can’t render until the slowest possible path has played out.

Best practice is simple: never use @import for fonts. Always declare @font-face in a stylesheet the browser sees as early as possible, ideally inlined in the <head> with a preload.

Strategic trade-offs

  • Critical fonts (body, navigation): preload + font-display: swap.
  • Secondary fonts (headlines, accents): preload only if they’re above the fold, otherwise lazy-load.
  • Decorative fonts: consider optional or defer entirely.

Loading strategy isn’t about dogma (“always swap” vs “always block”) – it’s about choosing the least worst compromise for your audience. The difference between text that renders instantly and text that lags behind is the difference between a user staying or bouncing.

The font loading API

For fine-grained control, the Font Loading API gives you promises to detect when fonts are ready and orchestrate swaps. But in practice, it’s rarely necessary unless you’re building a highly dynamic or JS-heavy site, but it’s useful to know it exists.

File formats: WOFF2, WOFF, TTF, and the legacy baggage

The font world is littered with old formats, half-truths, and cargo-cult practices. A lot of sites are still serving fonts like it’s 2012 – shipping multiple redundant formats and bloating their payloads.

WOFF2: the modern default

If you take only one thing away from this section: serve WOFF2, and almost nothing else.

  • It’s the most efficient web format, compressing smaller than WOFF or TTF.
  • It’s universally supported in all modern browsers.
  • It can contain full OpenType tables, variations, and modern features.

For the vast majority of projects, WOFF2 is all you need. Unless you have an explicit business case for IE11 or very old Android builds, there’s no reason to ship anything else. Legacy compatibility can’t justify making every visitor pay a performance tax.

One caveat: some CDNs try to Brotli-compress WOFF2 files again, even though they’re already Brotli-encoded. That wastes CPU cycles for no gain. Make sure your pipeline serves WOFF2 as-is.

WOFF: the fallback you probably don’t need

WOFF was designed as a web-optimised wrapper around TTF/OTF. Today it’s only relevant if you absolutely must support a very old browser (think IE11 in corporate intranets). In public web contexts, it’s dead weight.

TTF/OTF: desktop-first relics

TrueType (TTF) and OpenType (OTF) fonts are great for design tools and local installs, but shipping them directly to browsers is wasteful. They’re larger, slower to parse, and in some cases reveal more metadata than you want to serve publicly.

If your build pipeline still spits out .ttf for the web, it’s time to modernise.

SVG fonts: just… no

Once upon a time, SVG-in-font was a hack to get colour glyphs (like emoji) into the browser. That era is gone. Modern emoji and colour fonts use COLR/CPAL or CBDT/CBLC tables inside OpenType/WOFF2. If you see SVG fonts in your stack, delete them with fire.

Base64 embedding

Every so often, someone still tries to inline fonts as base64 blobs in CSS. Don’t. It bloats CSS files, breaks caching, and blocks parallelisation. Fonts are heavy assets that deserve their own requests and their own cache headers.

Do you need multiple formats?

No. Not unless your business case genuinely includes “must support IE11 and Android 4.x, and absolutely cannot live with fallback system fonts” For everyone else:

  • WOFF2 only
  • Self-hosted
  • Preloaded and cached properly

That’s it. And once you serve WOFF2, serve it well: give font files a long cache lifetime (months or a year) and use versioned file names for cache busting when fonts change. Fonts rarely update, so they should almost always come from cache on repeat visits. NB, see my post about caching for more tips here.

Legacy formats are ballast. If you’re still serving them, you’re making every visitor pay the price for browsers that nobody uses anymore.

Icon fonts: Font Awesome and the great mistake

Once upon a time, icon fonts felt clever. Pack a bunch of glyphs into a font file, assign them to letters, and voilà – scalable, CSS-stylable icons. Font Awesome, Ionicons, Bootstrap’s Glyphicon set… they were everywhere.

But it was always a hack. And in 2025, it’s indefensible.

The fundamental problems with icon fonts

  • Accessibility: Screen readers announce “private use” characters as gibberish, because there’s no semantic meaning.
  • Fragility: If the font fails to load, users see meaningless squares or fallback letters.
  • Styling hacks: Matching line-height, alignment, and sizing was always fragile.
  • Performance: You end up shipping an entire font file (often hundreds of unused icons) just to use a handful.

Better alternatives

  • Inline SVGs: semantic, flexible, styleable with CSS.
  • SVG sprites: cacheable, easy to swap or reference by ID.
  • Icon components (React/Vue/etc): imported on demand, tree-shakeable.
  • CSS mask-image / -webkit-mask-image: a neat option when you want a vector shape as a pure CSS-driven mask (e.g. colourising icons dynamically).

“But I already use Font Awesome…”

If you’re stuck with an icon font, there are two urgent things you should do:

  1. Subset it so you’re not shipping 700 icons to render 7.
  2. Plan your migration – usually to SVG. Most modern icon sets (including Font Awesome itself) now offer SVG-based alternatives.

The lingering myth

People cling to icon fonts because they “just work everywhere.” That used to be true. But today, SVG has universal support, better semantics, and better tooling.

Icon fonts are like using tables for layout – a clever hack in their day, but a mistake we shouldn’t still be repeating.

Beyond Latin: Non-Latin scripts, RTL languages, and emoji

If icon fonts were a hack born from a lack of glyph coverage, global typography is the opposite problem: too many glyphs, too many scripts, and too much complexity.

It’s easy to optimise fonts if you’re only thinking in English. But the web isn’t just Latin letters, and many of the “best practices” break down once you step into other scripts.

Non-Latin scripts

Arabic, Devanagari, Thai, and many others are far more complex than Latin. They rely on shaping engines, ligatures, and contextual forms. Subsetting recklessly can break whole words, turning live text into nonsense.

  • Don’t subset blindly. Many scripts need entire blocks intact to render correctly.
  • Test across OSes. Some scripts have wildly different default fallback behaviour depending on platform.
  • Expect heavier fonts. A full-featured CJK font can easily be 5 – 10MB before optimisation. In those cases, variable fonts or progressive loading are even more critical.

RTL languages

Right-to-left scripts like Arabic and Hebrew aren’t just flipped text. They come with:

  • Different punctuation and digit shaping.
  • Directional controls (bidi) that interact with your markup and CSS.
  • Font metrics that can differ significantly from Latin-based fallbacks.

Your fallback stack needs to understand RTL – not just render mirrored Latin glyphs. Always test with real RTL content.

Emoji

Emoji are a special case. Nobody should be shipping emoji glyphs in a webfont. They’re heavy, inconsistent, and outdated as soon as the Unicode consortium adds new ones.

Best practice is simple:

  • Use the system’s native emoji font (Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, etc).
  • Include them in your system stack, usually after your primary fonts: font-family: "YourBrandFont", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Color Emoji", "Apple Color Emoji", sans-serif;
  • Accept that emoji will look different on different platforms. That’s the web.

Designing for global text

If your brand works internationally, test with:

  • Mixed scripts (English + Arabic, or Chinese + emoji).
  • Platform differences (Android vs iOS vs Windows).
  • Fallback handling when your chosen font doesn’t cover the script.

Global typography isn’t just about coverage – it’s about resilience. Your font strategy should assume diversity, not break under it.

The future of webfonts: evolving standards and modern risks

We’ve covered the history and the present. But the font story isn’t finished – new CSS specs, new browser behaviours, and new app architectures are all shaping what “best practice” will look like over the next few years.

Upcoming CSS and font tech

There’s a steady stream of new descriptors and properties landing across specs and browsers:

  • font-palette: lets you switch or customise colour palettes inside COLR/CPAL colour fonts.
  • font-synthesis: controls whether the browser is allowed to fake bold/italic styles (finally giving you a “no thanks” switch).
  • size-adjust (expanding on font-size-adjust): more granular tuning of fallback alignment.
  • Incremental font transfer (IFT): a still-emerging approach where browsers can fetch only the glyphs a page needs, progressively, instead of downloading the full file.

None of these are mainstream defaults yet, but they point towards a more controlled, nuanced future.

Risks in JS-heavy websites and SPAs

New standards are exciting, but real-world implementation often collides with how sites are actually built today. And the reality is: the modern web is dominated by JavaScript-heavy, framework-driven applications. That changes the font-loading landscape.

Modern JavaScript frameworks (React, Vue, Angular, Next, etc.) introduce new challenges for font loading:

  • Fonts triggered late: if your routes/components lazy-load CSS, fonts may only be requested after hydration, leading to jank.
  • Critical CSS extraction gone wrong: automated tooling sometimes misses @font-face rules, breaking preload chains.
  • Client-side routing: navigating between views might trigger new font loads that weren’t preloaded up front.
  • Font Loading API misuse: some SPAs try to orchestrate font loading manually and end up delaying it unnecessarily.

Best practice here is simple: treat fonts as application-critical assets, not just “another stylesheet.” Preload and inline your declarations early, and test your routes for late font requests.

The trend towards variable and colour fonts

Variable fonts are becoming the expectation rather than the exception, and colour/emoji fonts are mainstreaming. That means:

  • Your font strategy needs to handle richer files and more axes of variation.
  • Subsetting and loading strategies matter even more as file sizes grow.
  • Expect to see more sites using a single highly flexible variable font, instead of juggling multiple static weights.

The cultural shift

For years, fonts were treated as decorative – a flourish bolted on at the end of a build. The future demands the opposite: treating typography as infrastructure. Performance budgets, accessibility standards, and internationalisation all hinge on doing fonts properly.

The webfont ecosystem is still maturing. If the last decade was about getting fonts to work at all, the next decade will be about making them efficient, predictable, and global.

But optimism and theory don’t mean much without proof. Fonts need measurement, not just faith – which is where tooling comes in.

Tooling and auditing

The nice thing about tinkering with fonts is that your decisions and performance are measurable. If you want to know whether your setup is efficient (and beautiful – or, at least, on brand), everything is testable. You can use:

  • DevTools: Simulate “Slow 3G, empty cache” in Chrome/Edge to see whether text is invisible, unstyled, or jumping. Watch the waterfall to confirm when fonts start downloading.
  • WebPageTest / Lighthouse: Both expose font request timing, blocking resources, and CLS caused by late swaps.
  • Glyphhanger / Subfont: CLI tools that analyse which glyphs your site actually uses, and generate subsets automatically.
  • Fonttools (pyftsubset): The Swiss Army knife for professional font subsetting and editing.
  • CI checks: Set budgets (e.g. no more than 3 weights, no font over 200 KB compressed).
  • Transfonter for generating optimised font files and CSS.
  • Font Style Matcher: For configuring fallback font metrics to match your custom font.

The golden rule: if you’ve never tested your fonts under a cold-cache, slow-network condition, you don’t know how your site actually behaves.

A manifesto for doing fonts properly

Webfonts are not decoration. They shape usability, performance, accessibility, and even legality. Yet most of the web still treats them as an afterthought – bolting them on late, bloating them with legacy baggage, and breaking the user experience for something as basic as text.

It doesn’t have to be this way. Handling fonts properly is straightforward once you treat them with the same seriousness you treat JavaScript, caching, or analytics.

The principles:

  1. System-first: Start with a robust system stack. Custom fonts are progressive enhancement, not a crutch.
  2. Subset aggressively (but intelligently): Ship only what users need, and test in the languages you support.
  3. Preload and inline: Don’t bury critical @font-face rules or delay requests.
  4. WOFF2 only (in 99% of cases): Drop the ballast of legacy formats.
  5. SVG for icons: Leave icon fonts in the past where they belong.
  6. Variable fonts when they add value: One flexible file beats a family of static weights.
  7. Design your fallbacks: Tune metrics so your system stack doesn’t break the layout.
  8. Respect global scripts: Optimise differently for Arabic, CJK, RTL, and emoji.
  9. Test like it matters: Different devices, different networks, different locales.

This isn’t about chasing purity or obscure micro-optimisations. It’s about building a web that renders fast, looks good, and works everywhere.

Fonts are content. Fonts are brand. Fonts are user experience. If you’re still treating them as “just another asset,” you’re loading them wrong.

10 Comments
Inline Feedbacks
View all comments

Something wrong with the first paragraph – not sure what the last sentence is trying to say.

Came up to this link through Kevin Powell’s newsletter and it was totally worth the time! Tons of insights, both historically and “how things work under the hood”-wise. Thanks for your effort.

Great work Jono, thank you! Just wanted to point out a small typo:

Manage CORS in the hint: include crossorigin in the 103 Link, so tthat he early request is eligible for reuse by the @font-face.

Bookmarked!

Three things though:

1. You’ve repeatedly stated that @font-face rules with WOFF, EOT, TTF declarations bloats payloads. Are you comments aimed solely at the additional lines in the declarations themselves (likely < 1KB after compression) or are you suggesting that browsers will unnecessarily download multiple font formats, which is clearly a bigger concern?

2. Re: Language subsetting: Determining what language subset you need based upon you site content works fine until someone uses a language translation tool like Google Translate. Experience reflects what you’ve said: “When a glyph is missing, the browser silently switches to a fallback font to cover that code point. The result can be inconsistent rendering, mismatched sizing, or even boxes and question marks.”

What *should* work is having a font stack like:

font: normal 1em/1.5em “Roboto”, “Roboto Extended”, “Roboto Fallback”, Arial, Helvetica, sans-serif;

where “Roboto” is a latin subset of Roboto, “Roboto Extended” is an everything-but-the-kitchen-sink version of Roboto, and “Roboto Fallback” is Arial with metrics adjusted to mirror Roboto (see Font Style Matcher or Fontpie below). “Roboto Extended” *should* only load when extend characters are requested. The same logic as your emoji stack. Older browsers tend to load “Roboto Extended” by default, but the modern ones I’ve tested are getting it right.

3. More tools:

google-webfonts-helper @ https://gwfh.mranftl.com/fonts for locally hosting Google fonts.

FontDrop! @ https://fontdrop.info/ for checking that your generated font files contain what they’re supposed to.

Fontpie @ https://github.com/pixel-point/fontpie A node.js command line tool that automatically generates the metric changes for fallback fonts.

Very insightful. One big, ugly missing piece is web fonts in the context of email. Would love to hear your thoughts on best practices for that.

Excellent detail in your explanations and thinking. It makes me consider that when a website is SSG (or even SSR), the build step could examine the content (especially if it’s in Markdown or MDX) and generate the inline CSS for each page. It seems like an obvious move to leverage what you know ahead of time.

There’s a real need to bring back graceful degradation as a sensible web design approach. Progressive enhancement is not a useful philosophy if designers vastly overestimate what counts as a universal basis to work from (as I regularly notice just using browser read modes!). Font loading, which many browsers and expansions offer an option to block entirely, is a prime example of that.