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) | Value | Tool |
|---|---|---|
| First paint (layout, no text) | 0.9 s | WebPageTest filmstrip |
| Body text visible | 3.5 s | WebPageTest filmstrip |
| LCP (heading text) | 3.6 s | WebPageTest |
| Lighthouse "Ensure text remains visible" | Failed, 3 fonts | Lighthouse 12 |
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:
| Font | Declared in | font-display |
|---|---|---|
| Inter (body) | Site CSS | swap |
| Inter Display (headings) | @import of a Google Fonts URL inside a theme partial | missing → auto |
| Icon font | Vendor CSS from a theme component | block |
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.
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.
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) | Before | After | Tool |
|---|---|---|---|
| Body text visible | 3.5 s | 0.9 s (with first paint) | WebPageTest filmstrip |
| LCP | 3.6 s | 1.4 s | WebPageTest |
| Font bytes | 214 KB | 78 KB | WebPageTest |
| CLS from font swap | — | 0.003 | Performance panel |
| LCP p75, field, mobile | 2.9 s | 1.8 s | CrUX, 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.
swapwithout 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.
Related
- Parent: Font Loading Strategies for Static Sites — the full set of font strategies.
- font-display: optional vs swap — choosing the descriptor.
- Metric-Matched Fallback Fonts with size-adjust — the fallback in Fix 4.
- Self-Hosted Fonts vs Google Fonts for Static Sites — the hosting decision measured.
- Eliminating Layout Shift From Web Fonts — the CLS side of font loading.
Filed underFixes