SevyDevy

Guide · Frontend

Web Font Loading Explained: Stop Invisible Text and Layout Shift

21 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Web font loading is how a browser downloads a custom font file and decides what text to show while it waits. Get it wrong and text hides, flashes or jumps.
  • FOIT (flash of invisible text) means text stays hidden while the font loads. FOUT (flash of unstyled text) means text shows in a backup font first, then swaps.
  • A CSS setting called font-display, written inside each font's @font-face rule, picks between them. Use swap or optional for most text, so readers never stare at blank space.
  • Make the file small: ship WOFF2, a compressed font format. Split it by script with unicode-range, the list of characters each file covers, so English pages skip Hindi letters.
  • Beginners can stop after the font-display section. Engineers get our measured file sizes for a Devanagari font, a Chromium detail we read in its source, preload traps and fallback overrides that took one layout shift to zero.

What goes wrong when a website loads a custom font?

The page arrives, but the words are blank, or they appear in one font and then jump into another. Both happen because web font loading takes time, and the browser must decide what to show while it waits.

Picture a common setup. A news site shows headlines in English and Hindi, using one open-licence font that covers both scripts. The site is made up, but the measurements in this guide come from our own test runs.

On a slow mobile connection, three problems show up. The headline area is empty for a few seconds. Then the text appears in a plain system font. Then it redraws in the brand font, and the story below jumps down.

Each problem has its own fix. A CSS setting controls the blank time. Smaller files arrive sooner. A tuned backup font stops the jump. This guide walks through all three on that one news site.

Which words do you need before learning web font loading?

Six short terms carry the whole guide. Each one is explained here once, and the same name is used everywhere after.

  • Web font: a font file the page downloads from a server, instead of one already installed on the reader's device.
  • Fallback font: the backup font the browser uses when the web font is not ready, such as a system sans-serif font.
  • @font-face rule: a block of CSS that names a font family and says where its file lives. The browser reads it but downloads the file only when text needs it.
  • Glyph: the drawn shape of one character. One Hindi conjunct, such as क्ष, can be a single glyph built from several characters.
  • Subsetting: cutting a font file down to only the glyphs you need, so the file is smaller.
  • CLS (Cumulative Layout Shift): a Core Web Vitals score for how much visible content moves unexpectedly while the page loads. A good score is 0.1 or less.

How does web font loading work in the browser?

Web font loading starts late. The browser must first download the HTML, then the CSS, then work out which text uses which font. Only then does it request the font file.

The CSS Fonts specification adds a clock to this. When the browser first tries to use a web font, it starts a font download timer. That timer moves through three periods, and the period decides what the reader sees.

Why a font request starts late

Notice that the font request is the fifth step, not the first. Everything the guide suggests either shortens this chain or decides what to show during it.

Diagram connections:

  • HTML arrives to CSS downloaded and parsed (finds stylesheet link)
  • CSS downloaded and parsed to Text matched to fonts (builds styles)
  • Text matched to fonts to Characters in range? (text needs a font)
  • Characters in range? to Font file requested (yes)
  • Font file requested to Font download timer (starts waiting)
  • Font download timer to Text painted (period decides)
  • HTML arrives: Server to browser
  • CSS downloaded and parsed: Finds the @font-face rules
  • Text matched to fonts: Which elements use which family
  • Characters in range?: unicode-range decides which file
  • Font file requested: Browser to font server, CORS mode
  • Font download timer: Block, then swap, then failure
  • Text painted: Web font, fallback or blank

The three periods of the font display timeline

  • Block period: if the font has not arrived, the browser draws the text with an invisible fallback. The space is held, but the reader sees nothing.
  • Swap period: if the font still has not arrived, the browser draws the text in the fallback font. If the web font arrives now, it swaps in.
  • Failure period: if the font arrives this late, the browser treats it as a failed load and keeps the fallback for that page view.

Think of a play whose lead actor is stuck in traffic. The block period keeps the curtain closed. The swap period lets the understudy start, and the lead takes over on arrival. The failure period means the understudy finishes the show.

Where the picture breaks: an actor swap does not move the scenery. A font swap can, because the new font may be wider or taller. That is the layout jump, and it gets its own fix later.

What is the difference between FOIT and FOUT?

FOIT is a flash of invisible text: the reader sees blank space while the font loads. FOUT is a flash of unstyled text: the reader sees the fallback font first, then a swap.

Neither term appears in the CSS specification, but they map neatly onto it. FOIT is what a long block period looks like. FOUT is what a short block period followed by a swap looks like.

FOUT is usually the better problem to have. Readers can start reading at once, and a well-matched fallback makes the swap hard to notice. FOIT hides content the reader came for, with no way to read around it.

Which font-display value should you use?

Use swap for short text where the brand font matters, such as a logo or headings. Use optional or fallback for body text. Avoid block except for icon fonts, where a fallback letter would mislead.

The font-display descriptor goes inside each @font-face rule. It sets the length of the block and swap periods. The specification gives recommended lengths, and browsers may use slightly different ones.

We tested each value in Chromium 153 through Playwright 1.63.0. A test server held the font back for 1.5 seconds, then for 5 seconds. We checked the screen 0.5, 2, 4 and 6.5 seconds after the page started loading.

Block and swap periods are the specification's recommendations. The last two columns are what our Chromium 153 run showed; times are seconds after the page started loading.
ValueBlock periodSwap periodFont arrives at 1.5 sFont arrives at 5 s
autoBrowser decidesBrowser decidesBlank at 0.5 s, web font by 2 sBlank until about 2 s, fallback, web font by 6.5 s
blockShort, 3 s recommendedInfiniteBlank at 0.5 s, web font by 2 sBlank until about 3 s, fallback, web font by 6.5 s
swap100 ms or lessInfiniteFallback at 0.5 s, web font by 2 sFallback until the web font at 6.5 s
fallback100 ms or lessShort, 3 s recommendedFallback at 0.5 s, web font by 2 sFallback for the whole visit
optional100 ms or lessNoneFallback for the whole visitFallback for the whole visit

Two rows surprise people. With optional, the font arrived at 1.5 seconds and was still never used on that page view. The specification allows this on purpose: an optional font must never make the layout jump.

The download is not always wasted. The specification expects it may finish in the background, so a returning reader can get the font from cache at first paint. Browsers may also cancel it.

For the news site, headlines use swap because the brand look matters there. Story text uses optional, so a slow phone never waits or reflows mid-paragraph. Every reader can read within the first paint.

How do you make web fonts smaller with WOFF2 and subsetting?

Convert the font to WOFF2, then cut out the glyphs your pages never use. On our Devanagari font, those two steps took the English-page download from 626.9 KB to 39.0 KB.

WOFF2 is a W3C font format that compresses font data with Brotli, a general-purpose compression method. web.dev guidance says WOFF2 has wide browser support and is often all a site needs.

We used Noto Sans Devanagari, an open-licence font under the SIL Open Font License that covers Hindi and English. Its variable file has 1,117 glyphs, a weight axis from 100 to 900 and a width axis.

Noto Sans Devanagari variable font (version 2.007), processed with fontTools 4.67.0 on 26 October 2026. KB means 1,024 bytes.
FileGlyphsSizeWhat changed
Original TTF1,117626.9 KBNothing; the file as published
WOFF (older format)1,117327.7 KBOlder web compression
WOFF21,117250.1 KBBrotli compression, 60.1% smaller than the TTF
WOFF2, Latin only20339.0 KBKept English letters, digits, punctuation, rupee sign
WOFF2, Devanagari only750196.6 KBKept Devanagari letters, marks and joiners
WOFF2, one Hindi heading257.2 KBKept only the glyphs for one fixed headline

Our WOFF2 file was 23.7% smaller than WOFF. web.dev guidance says up to 30%. The saving depends on the font, so measure your own files.

Running the subset yourself

The pyftsubset tool from the fontTools Python library does the cutting. The two commands below produce the two files the news site serves. Install the library with its woff extra, which pulls in Brotli.

bash
python3 -m venv fontenv
fontenv/bin/pip install "fonttools[woff]==4.67.0"

# Latin file: English text, digits, punctuation and the rupee sign
fontenv/bin/pyftsubset "NotoSansDevanagari[wdth,wght].ttf" \
  --unicodes="U+0000-00FF,U+2000-206F,U+20B9" \
  --flavor=woff2 --output-file=deva-sans-latin.woff2

# Devanagari file: Hindi, Marathi and Nepali text
fontenv/bin/pyftsubset "NotoSansDevanagari[wdth,wght].ttf" \
  --unicodes="U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20B9,U+25CC,U+A830-A839,U+A8E0-A8FF" \
  --flavor=woff2 --output-file=deva-sans-devanagari.woff2

ls -l *.woff2
# deva-sans-latin.woff2        39,904 bytes (39.0 KB)
# deva-sans-devanagari.woff2  201,340 bytes (196.6 KB)

Subsetting Devanagari without breaking conjuncts

Hindi needs more than its letters. Devanagari forms conjuncts, where two consonants join into one shape, through layout rules stored inside the font. A careless subset can drop those rules.

pyftsubset 4.67.0 keeps the Indic layout features by default, plus every glyph those rules can reach. We drew क्षत्रियहिन्दीश्रद्धा, written without spaces, with the full font and the subset. The pixels matched exactly.

One check failed at first, and it teaches something. With spaces between the words, the pictures differed. The Devanagari file has no space character, so Chromium drew each space with another font.

Then we made a subset with every layout feature removed. It was smaller, at 59.9 KB, but क्ष came out as क, a visible virama mark (the sign that cancels a vowel) and ष. That is the careless subset.

Two more ranges in that command matter. U+200C and U+200D are the zero-width non-joiner and joiner, which change how conjuncts form. U+25CC is the dotted circle drawn as a stand-in when a vowel sign has no consonant.

How does unicode-range split a font by script?

unicode-range tells the browser which characters a font file covers. The specification says the browser must not download a file for characters outside that range, so pages fetch only the scripts they use.

You declare the same family twice, once per file, each with its own range. Deva Sans is simply the name the news site gives the font in its CSS. The browser builds one family out of both and fetches each file only when the page text needs it.

css
/* File 1: Latin letters, digits, punctuation, rupee sign */
@font-face {
  font-family: "Deva Sans";
  src: url("/fonts/deva-sans-latin.woff2") format("woff2");
  font-weight: 400 700;
  font-display: swap;
  unicode-range: U+0000-00FF, U+2000-206F, U+20B9;
}

/* File 2: Devanagari, plus the joiners and dotted circle it needs */
@font-face {
  font-family: "Deva Sans";
  src: url("/fonts/deva-sans-devanagari.woff2") format("woff2");
  font-weight: 400 700;
  font-display: swap;
  unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20B9,
                 U+25CC, U+A830-A839, U+A8E0-A8FF;
}

body {
  font-family: "Deva Sans", sans-serif;
}

We loaded four test pages in Chromium 153 with the same two files and ranges, and logged every font request. The results show where the savings come from, and one trap.

  • English-only page: only the Latin file downloaded, 39.0 KB instead of 250.1 KB.
  • Hindi headline without spaces: only the Devanagari file downloaded.
  • Hindi headline with spaces: both files downloaded, because the space character, U+0020, sits in the Latin range.
  • A family that no element used: nothing downloaded at all.

The space result surprises people. We then added U+0020 to the Devanagari range as well, declared last. The Hindi page fetched only that file, but the English page now fetched both files.

So whichever file owns the space gets downloaded on almost every page. For the news site, the Latin file is small at 39.0 KB, so we left the space there.

Which files one page downloads

Notice that the decision is made per file from the characters on the page. A Hindi page with spaces answers yes twice, so it pays for both files.

Diagram connections:

  • Page text styled to Any Latin characters? (check range 1)
  • Page text styled to Any Devanagari? (check range 2)
  • Any Latin characters? to Fetch Latin file (yes)
  • Any Devanagari? to Fetch Devanagari file (yes)
  • Any Latin characters? to Skip that file (no)
  • Any Devanagari? to Skip that file (no)
  • Page text styled: Uses the Deva Sans family
  • Any Latin characters?: Letters, digits, space, punctuation
  • Any Devanagari?: U+0900 to U+097F and friends
  • Fetch Latin file: 39.0 KB
  • Fetch Devanagari file: 196.6 KB
  • Skip that file: No request, no bytes

Are variable fonts smaller than static font files?

Yes, once you use two or more weights. A variable font stores one design plus instructions to bend it along axes, such as weight, so one file replaces several static files.

We cut the Devanagari file down to normal width and weights 400 to 700. It came to about 79 KB. Four separate static files at weights 400, 500, 600 and 700 came to about 190 KB together.

The variable file is about 1.75 times the size of a single static weight, which was about 45 KB. So for one weight, a static file wins. From two weights up, the variable file wins, and it also gives every weight in between.

bash
# Keep only normal width and weights 400 to 700 (output stays WOFF2)
fontenv/bin/fonttools varLib.instancer deva-sans-devanagari.woff2 \
  wdth=100 wght=400:700 --output=deva-400-700.woff2
# deva-400-700.woff2  78.5 to 78.8 KB across our runs

Trimming axes matters as much as choosing variable. The same Devanagari subset with its full weight and width ranges was 196.6 KB. Pinning width and narrowing weight saved 60% before any other change.

In CSS, give the @font-face rule a weight range, such as 400 700. MDN notes that weights outside the declared range are clamped to its nearest end.

When should you preload a web font?

Preload only the one or two files your first screen needs, and always add the crossorigin attribute. Preloading tells the browser to fetch a file early, before it discovers it in the CSS.

Preloading is a link element in the page head with rel set to preload, as set to font and type set to font/woff2. Without crossorigin, the preload is wasted, and our test showed why.

Fonts are always fetched in CORS mode, a cross-origin safety check, with anonymous credentials, even from your own domain. A preload without crossorigin uses a different mode, so the browser cannot reuse it.

Chromium 153, one page, same-origin font, 26 October 2026
Preload linkRequests for the font fileConsole message
With crossorigin1None
Without crossorigin2Preload not used because the request credentials mode does not match

Preload has two more limits. web.dev guidance notes that preload ignores unicode-range, so a preloaded Devanagari file downloads even on English pages. Every preload also competes with images and scripts for early bandwidth.

For the news site, we preload the Latin file only, because every page has Latin characters and the brand headline sits on the first screen. The Devanagari file loads normally through unicode-range.

How do fallback font overrides stop layout shift?

Four @font-face descriptors resize a local fallback font so it takes the same space as the web font. When the swap happens, lines stay about the same length and the same height, so text below barely moves.

  • size-adjust: scales the fallback's glyphs and spacing by a percentage. It is defined in CSS Fonts Level 5.
  • ascent-override: sets the height above the baseline, the line letters sit on.
  • descent-override: sets the depth below the baseline, where tails of letters like g and y go.
  • line-gap-override: sets the extra gap the font asks for between lines.

The specification says size-adjust also scales the three override values. So you divide the web font's metrics by the scale first. This Python script computes all four numbers from the two font files.

python
# fonttools 4.67.0: make a local fallback take up the same space as the web font
from fontTools.ttLib import TTFont

SAMPLE = (
    "Monsoon rains reached Kerala this morning, and the weather office expects "
    "heavy showers across the western coast over the next three days. Trains "
    "between Mumbai and Goa may run late, so check the status before you leave home."
)

def width_in_em(path, text):
    font = TTFont(path)
    cmap, hmtx = font.getBestCmap(), font["hmtx"]
    upm = font["head"].unitsPerEm
    return sum(hmtx[cmap[ord(c)]][0] for c in text if ord(c) in cmap) / upm

web = "NotoSansDevanagari[wdth,wght].ttf"
fallback = "LiberationSans-Regular.ttf"
scale = width_in_em(web, SAMPLE) / width_in_em(fallback, SAMPLE)

hhea, upm = TTFont(web)["hhea"], TTFont(web)["head"].unitsPerEm
print(f"size-adjust: {scale:.2%}")
print(f"ascent-override: {hhea.ascent / upm / scale:.2%}")
print(f"descent-override: {-hhea.descent / upm / scale:.2%}")
print(f"line-gap-override: {hhea.lineGap / upm / scale:.2%}")

On the test paragraph, the script printed 105.53%, 84.90%, 38.66% and 0.00%. You then add a second @font-face rule that points at the installed font with local() and lists it after the web font.

css
/* Tuned on our Linux test machine against Liberation Sans */
@font-face {
  font-family: "Deva Sans Fallback";
  src: local("Liberation Sans");
  size-adjust: 105.53%;
  ascent-override: 84.9%;
  descent-override: 38.66%;
  line-gap-override: 0%;
}

body {
  font-family: "Deva Sans", "Deva Sans Fallback", sans-serif;
}

We used swap, held the font back 800 ms and observed layout shifts in a 420 by 900 pixel viewport. Without overrides, the paragraph went from five lines, 90 px tall, to six lines, 126 px tall.

The box below moved down 36 px, and Chromium logged a shift score of 0.0052. With the overrides, the paragraph was 126 px tall before and after the swap. Nothing moved, and the score was 0.

A score of 0.0052 sounds tiny, but our test page had one box below the text. On a real article page, every element below the paragraph moves too, so more of the screen shifts and the score rises.

The numbers fit one fallback font only. Readers on Windows, Android and macOS have different system fonts, so compute a separate fallback rule for each local font you list.

Should you self-host fonts or use a third-party font service in 2026?

web.dev's performance course says self-hosting is faster in most cases. Its font best practices article is more cautious: in practice, it says, the difference is less clear cut. Self-hosting gives you full control over subsetting, caching and font-display. A third-party font service is quicker to set up and can subset for you.

The old reason to use a shared font service was the shared cache. A font downloaded for one site would already be cached for the next. That no longer holds in Chrome.

Since Chrome 86, the browser keys its HTTP cache by the top-level site and the frame site. A font cached while reading one site is downloaded again on another site.

A third-party service also adds a connection to another server before the first byte of font arrives. If you use one, add a preconnect hint with crossorigin, because font files travel over a CORS connection.

Before self-hosting, check the licence. Noto Sans Devanagari uses the SIL Open Font License, which allows modified versions such as subsets. Paid font licences differ, so read yours before subsetting or hosting.

How do you check that your web fonts load well?

Load the page on a throttled connection and watch three things: which font files download, whether text is ever blank, and whether anything moves when the font arrives.

  1. Throttle the network

    In Chrome DevTools, set the Network panel to a slow preset and disable the cache, so you see what a first-time visitor on mobile data sees.

  2. Filter requests to fonts

    Pick the Font filter. An English page should list only your Latin file, in WOFF2, with sizes close to what you subset.

  3. Read the console

    A warning that a preload was not used means a missing crossorigin attribute or an unused file. Fix it or remove the preload.

  4. Watch the first paint

    Use the Performance panel's screenshots. Text should be readable in the first frames, in the fallback if needed, never blank.

  5. Log layout shifts

    Run the snippet below, reload, and confirm the font swap logs no shift, or a much smaller one than before your overrides.

javascript
// Paste in the DevTools console, then reload with the Network panel throttled
new PerformanceObserver(function (list) {
  list.getEntries().forEach(function (entry) {
    if (!entry.hadRecentInput) {
      console.log("layout shift", entry.value.toFixed(4), entry.sources);
    }
  });
}).observe({ type: "layout-shift", buffered: true });

How do you explain web font loading in a system design interview?

Treat fonts as a resource on the critical path, then walk through what the user sees at each moment. Interviewers listen for trade-offs and measurements, not a list of tricks.

  1. State the goal

    Readable text at first paint, the brand font when it is cheap, and no layout jumps. Name CLS and its 0.1 threshold.

  2. Pick font-display per text type

    swap for small branded text, optional or fallback for body text, block only for icon fonts. Explain the block and swap periods.

  3. Shrink the bytes

    WOFF2 only, subset by script with unicode-range, and a variable font when two or more weights are used. Quote rough sizes.

  4. Shorten discovery

    Inline the @font-face rules, preload only the first-screen file with crossorigin, and self-host on your CDN to avoid an extra connection.

  5. Remove the jump

    A local fallback tuned with size-adjust and the override descriptors, computed per platform font, with Safari's missing support named.

  6. Measure

    Track CLS and text render times from real users, split by connection type, and re-check after every font or design change.

Questions people ask

What is the difference between FOIT and FOUT?
FOIT, a flash of invisible text, means text is hidden while the web font downloads. FOUT, a flash of unstyled text, means text shows in a fallback font first and then swaps to the web font. FOIT comes from a long block period, such as font-display block. FOUT comes from a short block period, such as font-display swap.
Is font-display swap or optional better for performance?
Both show text within about 100 ms, so neither hides content. swap always switches to the web font when it arrives, which can move text if the fallback is a different size. optional uses the web font only if it is ready at first paint, so it never moves the layout. Use swap for small branded text and optional for long body text.
Do I need the crossorigin attribute to preload a font from my own domain?
Yes. Browsers always fetch fonts in CORS mode with anonymous credentials, even from the same domain. A preload without crossorigin uses a different mode, so the browser cannot reuse it. In our Chromium 153 test, the font downloaded twice and the console warned that the preload was not used.
How much does subsetting reduce font size?
It depends on the font and the characters you keep. Our Noto Sans Devanagari file was 250.1 KB as WOFF2 with every glyph. A Latin-only subset was 39.0 KB, and a subset for one fixed Hindi headline was 7.2 KB. Fonts that cover many scripts save the most.
Are variable fonts always smaller?
No. A variable font replaces several static files, so it wins when you use two or more weights. In our test, a variable Devanagari file covering weights 400 to 700 was about 79 KB, while four static weights came to about 190 KB. One static weight alone was smaller, at about 45 KB.
Does size-adjust work in every browser?
size-adjust works in Chrome 92, Firefox 92 and Safari 17 onwards. As of 26 October 2026, MDN's compatibility data lists ascent-override, descent-override and line-gap-override in Chrome and Firefox, but Safari only in Technology Preview. Safari users therefore get the width scaling but not the line-height matching.
Should I use a hosted font service or self-host?
It depends, so measure both. web.dev's performance course says self-hosting is faster in most cases, because it avoids a connection to another server. Its font best practices article says the difference in practice is less clear cut. Since Chrome 86, the HTTP cache is partitioned by site, so a font cached for one site is not reused on another. A hosted service is still easier to set up.

Sources and further reading

Practise this with Lala

Practise