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 area. 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.
The distinction matters because Core Web Vitals treats every navigation as a fresh measurement. A repeated FOUT costs you a layout-shift entry and a visible re-render on every single page a user visits during a session, not just the first. On a five-page browsing session that's four avoidable flashes, each one degrading the perceived polish of the site and, if the fallback and web font have mismatched metrics, contributing avoidable CLS. The two-stage pattern turns a per-navigation cost into a per-session cost, which is the correct unit for something bound to the browser's disk cache lifetime rather than the page lifecycle.
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. - A clear picture of your site's navigation model: multi-page reload, SPA client-side routing, or a hybrid (server-rendered first load, client-routed thereafter) — the technique's payoff differs sharply between them, covered below.
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.
Worked example: multiple font families and independent flags
A page that loads more than one family — say a display face for headings and a text face for body copy — should not collapse both into a single flag. If the display face fails or is slower, a single combined flag either waits for the slowest family (delaying the fast one's synchronous paint) or lies about the slow one being ready. Track each family's readiness independently and compose the CSS selector from both classes:
<script>
(function () {
var root = document.documentElement;
var FAMILIES = [
{ key: 'fonts-loaded-body-v3', match: '16px Inter', cls: 'body-font-loaded' },
{ key: 'fonts-loaded-display-v3', match: '16px Newsreader', cls: 'display-font-loaded' }
];
FAMILIES.forEach(function (f) {
try {
if (sessionStorage.getItem(f.key) === '1') root.classList.add(f.cls);
} catch (e) { /* storage blocked, fall through to per-load swap */ }
});
document.fonts.ready.then(function () {
FAMILIES.forEach(function (f) {
if (document.fonts.check(f.match)) {
root.classList.add(f.cls);
try { sessionStorage.setItem(f.key, '1'); } catch (e) {}
}
});
});
})();
</script>
This keeps the two families decoupled: a slow display font never blocks the fast body font's synchronous paint, and each gets its own cache-bust suffix so replacing one asset doesn't invalidate the other's flag.
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.
Edge case: back/forward cache (bfcache) restores
When a user hits the browser's back button, some navigations are served from the bfcache — a frozen snapshot of the previous page, restored without re-running <head> scripts. If the page was captured before fonts.ready resolved (e.g. the user clicked back within the first second), the restored snapshot can carry a stale, unloaded state. Listen for pageshow and re-check the flag on every restore, not just on first execution:
window.addEventListener('pageshow', function (event) {
if (event.persisted) {
// Page came from bfcache — re-validate the class rather than
// trusting whatever state the frozen snapshot captured.
try {
if (sessionStorage.getItem('fonts-loaded-v3') === '1') {
document.documentElement.classList.add('fonts-loaded');
}
} catch (e) {}
}
});
Because bfcache restores do not re-run the synchronous head script, this listener is the only hook that fires on that path — omit it and a small fraction of back-navigations can show a stale unstyled flash that neither your Network panel nor a normal reload test will ever reproduce.
Edge case: cross-subdomain navigation
sessionStorage is origin-scoped, not site-scoped. A user moving from www.example.com to shop.example.com starts with an empty session store on the new origin, even though both subdomains share the same font files and the same CDN cache. If your architecture spans subdomains, either accept one flash per subdomain per session, or replace the per-origin flag with a Set-Cookie written by the server (cookies can be scoped to the parent domain with Domain=.example.com) read synchronously the same way. The cookie path trades storage isolation for domain-wide reach, so weigh it against any privacy-sandbox restrictions your stack already applies to first-party cookies.
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). - Back-navigation test — load the page, click a link away within roughly a second (before fonts settle), then press back. Confirm via the Performance panel that the restored page shows no residual FOUT and no unexpected class flip; this exercises the
pageshow/bfcache path specifically. - Automated check — a Playwright script that navigates twice and screenshots the first paint of the second navigation catches regressions a manual click-through easily misses:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com/');
await page.waitForFunction(() => document.fonts.status === 'loaded');
await page.goto('https://example.com/about/');
// Screenshot as close to first paint as possible.
await page.screenshot({ path: 'second-nav-first-paint.png' });
await browser.close();
})();
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). - Ignoring bfcache restores. Testing only with hard reloads misses the back-navigation path, where a frozen snapshot can carry a stale pre-load state; add a
pageshowlistener that re-validates the flag onevent.persisted. - Assuming one flag covers every subdomain. sessionStorage is per-origin; a multi-subdomain site needs either a per-origin flag (accept one flash per subdomain) or a domain-scoped cookie read synchronously in its place.
Frequently Asked Questions
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.
What happens on a hard refresh (not a link click)? A hard refresh still reads the sessionStorage flag synchronously if the tab's session hasn't been torn down — the browser tab, not the navigation type, owns the session store. Closing the tab or opening a new one starts a fresh session with no flag, so the very first view of a new tab always pays the one legitimate flash before the flag is (re)written.