Two-Stage Font Render with sessionStorage
The flash of unstyled text (FOUT) you fix on first load quietly returns on every subsequent navigation. A class-swap strategy — render in the fallback, then add a fonts-loaded class when the web font arrives — works the first time, but each new page in the session repeats the same fallback-then-swap dance even though the font is already sitting in the HTTP cache. The fix is a two-stage render: stage one establishes the loaded state and remembers it; stage two reads that memory synchronously in the <head> and applies the final typeface before first paint, so repeat views never flash.
This workflow is part of FOUT vs FOIT Mitigation, which sits under the Font Loading & Delivery Strategies blueprint. It builds directly on the class-swap technique and pairs with font-display values for the first-load stage.
Problem statement
Single-page apps hide this problem because they navigate without a document reload, but multi-page sites and server-rendered apps reload the document on every link click. On each reload the parser builds a fresh DOM with no fonts-loaded class, text paints in the fallback, your JavaScript runs, document.fonts.ready resolves, and only then does the class flip — a visible swap, even though the WOFF2 was served from disk cache in single-digit milliseconds. The font isn't slow; the signal that it's ready arrives after first paint. sessionStorage lets you carry that signal across navigations and consult it synchronously, before the browser paints.
Prerequisites
- A first-load class-swap already in place (
document.fonts.readyaddsfonts-loadedto<html>), as covered in Eliminating FOUT with the CSS Font Loading API. @font-facedeclarations with a sensible font-display value (swaporoptional) for the stage-one fallback render.- Fonts served with long-lived caching —
Cache-Control: public, max-age=31536000, immutable— so repeat views genuinely hit the disk cache and the synchronous class is honest. - The inline head script must run before any stylesheet paints, so place it as the first
<script>in<head>, ahead of the CSS link.
Implementation
The core of the technique is a tiny synchronous script placed first in <head>. It reads the flag and, if present, adds fonts-loaded to the document element before the browser computes styles for first paint — so the page paints directly in the web font. The same script registers the stage-one work: when document.fonts.ready resolves, it adds the class and writes the flag for next time.
Inline head script: synchronous read + ready-driven write
<!-- FIRST element in <head>, BEFORE the stylesheet link -->
<script>
(function () {
var KEY = 'fonts-loaded-v3'; // bump suffix when font assets change
var root = document.documentElement;
// STAGE 2 (repeat view): this runs synchronously during head parse,
// before any box is laid out or painted. If the flag is set, the
// class lands ahead of first paint — the page paints in the web font
// and never flashes the fallback.
if (sessionStorage.getItem(KEY) === '1') {
root.classList.add('fonts-loaded');
}
// STAGE 1 (first view): when every pending font has settled, flip the
// class and remember it so the NEXT navigation hits the branch above.
document.fonts.ready.then(function () {
root.classList.add('fonts-loaded');
sessionStorage.setItem(KEY, '1');
});
})();
</script>
The CSS contract is unchanged from a normal class swap — the fallback renders until fonts-loaded is present, then the web font takes over:
Stage CSS: fallback first, web font on class
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap; /* stage-1 visibility while the font downloads */
}
/* Metric-matched fallback minimises CLS on the stage-1 swap */
@font-face {
font-family: 'InterFallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 23%;
line-gap-override: 0%;
}
body { font-family: 'InterFallback', system-ui, sans-serif; }
.fonts-loaded body { font-family: 'Inter', 'InterFallback', sans-serif; }
Why sessionStorage and not localStorage? The session scope matches the truth we're caching: the HTTP disk cache is reliably warm within a browsing session. A localStorage flag could survive a cache eviction between sessions and assert "loaded" when the font must actually be re-fetched — reintroducing the very flash you removed, this time with no fallback because the class is applied pre-paint. Session scope keeps the flag and the cache lifetime roughly aligned. The version suffix (-v3) is your cache-bust: change the font file, change the suffix, and stale flags are ignored.
Timeout / error handling variant
sessionStorage access throws in two real situations: Safari Private Browsing historically threw QuotaExceededError on any write, and embedding the page in a cross-origin iframe with storage partitioning blocked can throw SecurityError on read. An unguarded sessionStorage.getItem in your first head script will abort the whole script, leaving fonts-loaded never applied and FOUT on every view. Wrap every access defensively.
Storage-safe head script
<script>
(function () {
var KEY = 'fonts-loaded-v3';
var root = document.documentElement;
function read() {
try { return sessionStorage.getItem(KEY) === '1'; }
catch (e) { return false; } // private mode / blocked storage
}
function write() {
try { sessionStorage.setItem(KEY, '1'); }
catch (e) { /* no-op: degrade to per-load swap */ }
}
if (read()) root.classList.add('fonts-loaded');
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(function () {
root.classList.add('fonts-loaded');
write();
});
} else {
// No Font Loading API: fall back to always-swap behaviour.
root.classList.add('fonts-loaded');
}
})();
</script>
With the try/catch guards, a browser that blocks storage simply degrades to the first-load class swap on every navigation — the original FOUT behaviour, never a broken page. The document.fonts feature check covers the rare engine without the API by applying the class unconditionally, relying on font-display: swap to keep text visible.
Verification
- DevTools Network throttling + reload — load the page once cold, then click an internal link. With throttling off (disk cache warm), the second view's text should appear in the web font from the first painted frame. Record the navigation in the Performance panel and confirm no "Layout Shift" marker fires for the heading region.
document.fonts.check()in the console — on the repeat view, rundocument.fonts.check('16px Inter')immediately; atrueconfirms the cached font is available, validating that the synchronous class is honest.- Application panel — open Application → Session Storage and confirm the
fonts-loaded-v3key appears with value1after the first load. Clear it, reload, and the flash should return — proving the flag is what suppresses it. - Private window — repeat the test in a private/incognito window to confirm the
try/catchpath still renders correctly (it should fall back to per-load swap, not break).
Common pitfalls
- Placing the script after the stylesheet. If the CSS link parses before the inline script runs, first paint already happened in the fallback — the synchronous read is too late. The flag-reading script must be the first thing in
<head>. - Never bumping the version suffix. Ship a new font file without changing the key suffix and stale flags apply
fonts-loadedpre-paint while the browser silently re-downloads the changed asset, causing a flash with no fallback to cover it. - Using
localStoragefor the flag. It outlives the disk cache, so it can assert "loaded" after a cache eviction. Keep the flag's lifetime aligned with the cache by usingsessionStorage. - Unguarded storage access. A bare
sessionStorage.getItemthrows in private mode or partitioned iframes and aborts the entire head script, disabling the swap on every view. Always wrap reads and writes intry/catch. - Forgetting the API feature check. Calling
document.fonts.readywheredocument.fontsis undefined throws and prevents the class from ever being added; guard withif (document.fonts && document.fonts.ready).
FAQ
Why sessionStorage instead of localStorage for this?
Session scope mirrors the HTTP disk cache's reliable warmth within a single browsing session. A localStorage flag can persist past a cache eviction and wrongly assert the font is ready, applying the class before paint with no fallback to mask the re-download. sessionStorage keeps the cached "loaded" claim honest for the lifetime where it's actually true.
Does this reintroduce a flash if the font 404s on a later page?
Only briefly, and only if you don't version-bump on asset changes. With a correct version suffix, a changed or removed asset invalidates the stale flag, so the page falls back to the stage-one swap behaviour with font-display: swap keeping text visible. The flag never forces invisible text.
Will this help a single-page app?
Less so — an SPA navigates without reloading the document, so the fonts-loaded class already persists across route changes and there's no fresh DOM to flash. The technique's payoff is on multi-page and server-rendered sites where every navigation rebuilds the document from scratch.