Fixing Invisible Text While Web Fonts Load

On a fast connection nobody sees it. On a train, a phone with weak signal, or a first visit from another continent, a static page arrives in 400 ms — layout, colours, images — and then shows no text for two or three seconds while the web font downloads. This is the "flash of invisible text", and it means the page's fastest asset, its HTML, is held hostage by one of its slowest. Static sites are particularly exposed, because everything else about them is fast; the font becomes the bottleneck for reading.

This guide fixes invisible text on a company blog built with Hugo, where WebPageTest filmstrips on a 3G profile showed text appearing 2.6 seconds after the layout, and where three separate font-face rules were involved — only one of which the team knew about. It belongs to Font Loading Strategies for Static Sites.

Symptoms

Measure (Moto G Power, Slow 4G, first view)ValueTool
First paint (layout, no text)0.9 sWebPageTest filmstrip
Body text visible3.5 sWebPageTest filmstrip
LCP (heading text)3.6 sWebPageTest
Lighthouse "Ensure text remains visible"Failed, 3 fontsLighthouse 12
Filmstrip timeline of a first visit on a slow connection before the fix Four moments. At 0.9 seconds the layout, background and images appear with no text. At 1.8 seconds still no text while fonts download. At 3.5 seconds body text appears when the body font arrives. At 3.6 seconds the heading appears, which is the LCP element. What a reader saw on a slow connection Layout painted Still blank Body text Heading (LCP) 0.9 s no text visible 1.8 s fonts downloading 3.5 s body font arrives 3.6 s heading font arrives
WebPageTest filmstrip, Moto G Power on Slow 4G, first view; 2.6 seconds of a page with nothing to read.

Step 1: Find Every Font-Face Rule

The team had already set font-display: swap in the site's own CSS, so they assumed the problem was elsewhere. DevTools' Network panel filtered to fonts showed three font families loading, and searching all loaded stylesheets for @font-face found where each came from:

FontDeclared infont-display
Inter (body)Site CSSswap
Inter Display (headings)@import of a Google Fonts URL inside a theme partialmissing → auto
Icon fontVendor CSS from a theme componentblock

The heading font came from a Google Fonts URL without &display=swap, imported inside a theme stylesheet the team had never edited. The icon font's rule explicitly set block — common in icon fonts, because showing fallback text for icons would show the ligature names ("arrow_forward") instead. And the body font's own rule, though correct, was blocked on discovering the font, since it lived in a CSS file loaded by another CSS file.

Fix 1: Self-Host the Heading Font With swap

The @import of Google Fonts had two costs: invisible heading text, and a chain of three sequential requests (theme CSS → Google CSS → font file) before the font could start downloading. The heading font was downloaded, subset to Latin, and self-hosted with the body font, as in Self-Host Google Fonts to Eliminate Layout Shift:

@font-face {
  font-family: 'Inter Display';
  src: url('/fonts/inter-display-latin-600.woff2') format('woff2');
  font-weight: 600;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+20AC, U+2122;
}

The @import was removed from the theme partial with a Hugo override of that partial.

Fix 2: Replace the Icon Font

Icon fonts are the classic cause of invisible content that cannot simply be switched to swap. The blog used 14 icons from a 96 KB icon font. They were replaced with inline SVG icons from a sprite generated at build time, which render immediately with the HTML and have no font loading at all. SVG Icon Sprites vs Inline Icons compares the two SVG approaches. Removing the font removed 96 KB and one render-blocking request.

Fix 3: Let the Body Font Start Earlier

Inter was declared in main.css, linked from the <head>. The browser only discovers a font when it finds a rule that needs it, after the CSS is downloaded and parsed and the DOM shows text using it. A preload for the one weight used above the fold lets the download start with the HTML:

<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>

crossorigin is required even for same-origin fonts, or the preloaded file is not reused and downloads twice, as in Preloading Fonts Without Double Downloads. Only the regular weight was preloaded; preloading every weight would compete with the HTML and CSS for bandwidth.

Font bytes and discovery time before and after the fixes Paired bars. Font bytes on a typical page went from 214 to 78 kilobytes. The time at which the body font request started went from 1,240 to 180 milliseconds. Requests in the chain before the heading font started went from 3 to 1. Font requests before and after Before After Font bytes (KB) 214 78 Body font request starts (ms) 1240 180 Requests before heading font 3 1
WebPageTest waterfall, first view on Slow 4G, median of nine runs.

Fix 4: Make the Swap Invisible

With swap on every font, text now appears in the fallback immediately — and then reflows when Inter arrives, because Arial's characters are wider. On article pages that produced CLS of 0.06 from text alone. Metric overrides on a fallback face make the system font occupy the same space as Inter:

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}
body { font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif; }

The values come from the fonts' metrics — tools such as Fontaine or @capsizecss/metrics compute them. Metric-Matched Fallback Fonts with size-adjust shows how. After this, text-related shift fell to 0.003.

Choosing swap or optional

The blog uses swap for headings and body. For sites where the web font is decoration rather than brand, font-display: optional is worth considering: on a slow first visit, the browser uses the fallback for the whole page view and caches the font for the next one, so there is never a swap and never invisible text. font-display: optional vs swap measures both.

Decision tree for fixing invisible text by font type The root asks what kind of font is hiding text. An icon font should be replaced with inline SVG icons. A third-party hosted font loaded through a CSS import should be self-hosted with font-display swap. A self-hosted text font discovered late should be preloaded with crossorigin and given a metric-matched fallback. A decorative font where brand fidelity matters less can use font-display optional. Choosing a fix for each font What kind of font is hiding text? Icon font Inline SVG icons no font at all Third-party via @import Self-host + swap one fewer chain step Own font, found late Preload + fallback crossorigin, metrics Decorative font font-display: optional no swap ever
All four applied on the blog except the last, which the brand team declined.

Keeping It Fixed

Font regressions arrive with theme updates and new components, so two checks guard the fix. A CI step greps the built CSS for every @font-face block and fails if any lacks font-display, or uses block, which would have caught both hidden rules on day one. And the Lighthouse CI run on five templates asserts the "font-display" audit passes and that the number of font requests per page stays at or below three, since every extra weight or family is another chance for invisible or late text. A designer who later wanted a third weight of Inter for pull quotes got it — as part of the variable font file already loaded, which added no request.

Measured Impact

Measure (Slow 4G, first view)BeforeAfterTool
Body text visible3.5 s0.9 s (with first paint)WebPageTest filmstrip
LCP3.6 s1.4 sWebPageTest
Font bytes214 KB78 KBWebPageTest
CLS from font swap—0.003Performance panel
LCP p75, field, mobile2.9 s1.8 sCrUX, 28 days

Pitfalls & Rollback

  • Fixing only the CSS you own. Search every loaded stylesheet for @font-face.
  • Switching icon fonts to swap. Readers see ligature names; replace them with SVG.
  • Preloading every weight. Preload only what renders above the fold.
  • swap without a matched fallback. Invisible text becomes layout shift.
  • Rollback: each font change is independent; reverting the fallback face alone leaves visible text with a little more shift.

Conclusion

Invisible text on a static site comes from every @font-face rule involved, not just the one you wrote: a third-party import without display=swap, an icon font set to block, and a late-discovered body font. Self-hosting with swap, replacing the icon font with SVG, preloading the one critical weight and matching the fallback's metrics made text visible with the first paint on a slow connection — 2.6 seconds sooner — and cut field LCP from 2.9 to 1.8 seconds.

FAQ

Why is my text invisible until the web font loads?

Browsers hide text that uses a web font while the font downloads, for up to about three seconds in Chrome and Firefox, if the font-face rule has no font-display descriptor or uses font-display block. On a slow connection the reader sees a blank page with images and backgrounds but no words. Setting font-display to swap or optional shows text in a fallback font immediately.

Does font-display swap cause layout shift?

It can. When the web font arrives and replaces the fallback, differences in character widths and line heights reflow the text. Matching the fallback's metrics to the web font with size-adjust and ascent and descent overrides makes the swap almost invisible. font-display optional avoids the swap entirely on slow loads by keeping the fallback for that page view.

I set font-display swap but text is still invisible. Why?

Often because the font-face rule that applies is not the one you edited. Fonts loaded from a third-party stylesheet, an icon font, a CSS import inside another stylesheet, or a font-face declared twice can each bring their own rule. Check the computed font-face in DevTools and search all loaded CSS for every font-face rule.

How do I see invisible text during testing?

Throttle the network in DevTools to Slow 4G and enable Disable cache, then reload and use the Performance panel's screenshots or WebPageTest's filmstrip. Frames with layout but no text show the problem. Lighthouse also flags it under ensure text remains visible during webfont load.

Filed underFixes