Stop Waiting for Fonts
Quick Guide to font-display: swap on macOS
Your website might be secretly blocking page renders while it waits for fancy custom fonts to load. This invisible delay tanks your Core Web Vitals and frustrates users. The fix is simple: font-display: swap.
Here’s how to audit your sites and fix it in minutes.
The Problem: FOIT
FOIT stands for Flash of Invisible Text. Here’s what happens:
By default, browsers do this:
- User navigates to your site
- Browser downloads HTML and CSS
- Browser sees
@font-facerule for custom font (Montserrat, etc.) - Browser blocks all text rendering and requests the custom font file
- Browser waits… waits… waits for the font file to download
- Font finally arrives, text renders
The problem? During steps 4-5, your page is completely blank. Users see nothing.
Timeline Example (Default Behavior)
0ms - User clicks link
100ms - HTML loads, browser sees font request
110ms - Font download starts
500ms - [BLANK SCREEN - User sees nothing]
1000ms - Font arrives
1100ms - Text finally appears
On 4G or slow networks, fonts can take 2-3 seconds or longer. Your users are staring at a blank page. They think the site is broken. They click back. You lose them.
The Hidden Cost
This isn’t just about user experience. Google’s Core Web Vitals algorithm measures:
- LCP (Largest Contentful Paint): How long until the largest visible element renders
- If your fonts block rendering, your LCP score tanks
- Bad LCP = lower search rankings
So FOIT costs you traffic, engagement, and SEO. All because of one missing CSS line.
The Solution: font-display: swap
With font-display: swap, the browser does this instead:
- Browser shows fallback font immediately (system font like Arial)
- Custom font loads in background (no blocking)
- Font swaps in when ready (silently, no jank)
Users see content immediately. Custom font upgrades happen silently. Everyone wins.
Timeline Example (With font-display: swap)
0ms - User clicks link
100ms - HTML loads with fallback font (Arial)
150ms - TEXT APPEARS IMMEDIATELY ✓
200ms - Custom font download starts in background
1000ms - Custom font arrives
1050ms - Font swaps in (user barely notices)
Your page is readable in 150ms instead of 1100ms. That’s 7x faster.
Real User Impact
Without swap:
- Blank screen for 1-3 seconds
- Users think site is broken
- Bounce rate spikes
- LCP score: bad
With swap:
- Text visible in 100-200ms using fallback
- Custom font swaps in silently
- No waiting, no jank
- LCP score: good
- Users stay
Audit Your Sites with a CLI Tool
Download the font-loader-checker tool to scan any website. It parses the HTML, extracts @font-face rules, and tells you exactly which fonts are optimized and which ones are blocking.
Let’s install and run it:
Step 1: Setup on macOS
Open Terminal and paste this entire block. It creates the checker script and makes it executable:
cat > font-loader-checker.js << 'EOF'
#!/usr/bin/env node
const https = require('https');
const http = require('http');
class FontLoaderChecker {
constructor(options = {}) {
this.timeout = options.timeout || 10000;
this.verbose = options.verbose || false;
}
async check(targetUrl) {
try {
const urlObj = new URL(targetUrl);
const html = await this.fetchPage(urlObj);
const report = await this.analyzeHtml(html, urlObj);
return report;
} catch (error) {
return {
url: targetUrl,
status: 'error',
error: error.message
};
}
}
fetchPage(urlObj) {
return new Promise((resolve, reject) => {
const protocol = urlObj.protocol === 'https:' ? https : http;
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: this.timeout,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
};
const request = protocol.request(options, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
if (data.length > 5 * 1024 * 1024) {
request.destroy();
reject(new Error('Response too large'));
}
});
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
} else {
resolve(data);
}
});
});
request.on('timeout', () => {
request.destroy();
reject(new Error('Request timeout'));
});
request.on('error', reject);
request.end();
});
}
async analyzeHtml(html, baseUrl) {
const fontLinks = this.extractFontLinks(html);
const inlineStyles = this.extractInlineFontFaces(html);
const externalFontContent = {};
// Fetch and analyze external font CSS files
for (const link of fontLinks) {
try {
const fullUrl = this.resolveUrl(link.url, baseUrl);
const cssContent = await this.fetchPage(new URL(fullUrl));
const fontFaces = this.extractFontFacesFromCss(cssContent);
externalFontContent[link.url] = {
fullUrl,
fontFaces,
hasFontDisplay: fontFaces.some(f => f.hasFontDisplay),
optimizedCount: fontFaces.filter(f => f.isOptimized).length,
totalCount: fontFaces.length,
isDeferredMedia: link.media === 'print' || !!link.onload
};
} catch (e) {
externalFontContent[link.url] = {
error: e.message
};
}
}
const fontDeclarations = this.extractFontDeclarations(html);
const hasOptimizedFonts = fontDeclarations.some(f => f.optimized) ||
Object.values(externalFontContent).some(c => c.hasFontDisplay);
const unoptimizedCount = fontDeclarations.filter(f => !f.optimized).length +
Object.values(externalFontContent)
.filter(c => !c.error)
.reduce((sum, c) => sum + (c.totalCount - c.optimizedCount), 0);
const totalFontFaces = inlineStyles.length +
Object.values(externalFontContent)
.filter(c => !c.error)
.reduce((sum, c) => sum + c.totalCount, 0);
return {
url: baseUrl.href,
status: 'success',
summary: {
totalFontDeclarations: totalFontFaces,
optimizedFonts: totalFontFaces - unoptimizedCount,
unoptimizedFonts: unoptimizedCount,
hasFontDisplay: fontDeclarations.some(f => f.fontDisplay) || hasOptimizedFonts,
isOptimized: hasOptimizedFonts && unoptimizedCount === 0,
totalFontLinks: fontLinks.length,
deferredFontLinks: fontLinks.filter(l => l.media === 'print' || l.onload).length
},
fontLinks: fontLinks,
fontDeclarations: fontDeclarations,
inlineStyles: inlineStyles,
externalFontContent: externalFontContent,
recommendations: this.generateRecommendations(fontDeclarations, fontLinks, externalFontContent, inlineStyles)
};
}
extractFontDeclarations(html) {
const fontDisplayPattern = /font-display\s*:\s*(swap|fallback|optional|auto|block)/gi;
const matches = [];
let match;
while ((match = fontDisplayPattern.exec(html)) !== null) {
const value = match[1].toLowerCase();
const isOptimized = value === 'swap' || value === 'fallback' || value === 'optional';
matches.push({
value,
optimized: isOptimized,
fontDisplay: true
});
}
return matches;
}
extractFontLinks(html) {
const linkPattern = /<link[^>]+rel=["']?stylesheet["']?[^>]*>/gi;
const hrefPattern = /href=["']?([^"'\s>]+)["']?/i;
const mediaPattern = /media=["']?([^"'\s>]+)["']?/i;
const onloadPattern = /onload=["']?([^"'\s>]+)["']?/i;
const fonts = [];
let match;
while ((match = linkPattern.exec(html)) !== null) {
const link = match[0];
if (link.includes('font') || link.includes('googleapis') || link.includes('merriweather') || link.includes('montserrat')) {
const hrefMatch = hrefPattern.exec(link);
if (hrefMatch) {
const href = hrefMatch[1];
const mediaMatch = mediaPattern.exec(link);
const onloadMatch = onloadPattern.exec(link);
const isCrossOrigin = link.includes('googleapis') || link.includes('fonts.gstatic.com');
fonts.push({
url: href,
isExternal: isCrossOrigin,
media: mediaMatch ? mediaMatch[1] : 'all',
onload: onloadMatch ? onloadMatch[1] : null,
fullHtml: link
});
}
}
}
return fonts;
}
resolveUrl(relativeUrl, baseUrl) {
if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
return relativeUrl;
}
if (relativeUrl.startsWith('/')) {
return `${baseUrl.protocol}//${baseUrl.host}${relativeUrl}`;
}
return new URL(relativeUrl, baseUrl).href;
}
extractInlineFontFaces(html) {
const stylePattern = /<style[^>]*>([\s\S]*?)<\/style>/gi;
const fontFacePattern = /@font-face\s*\{([^}]*)\}/gi;
const fontFaces = [];
let styleMatch;
while ((styleMatch = stylePattern.exec(html)) !== null) {
const styleContent = styleMatch[1];
let fontMatch;
while ((fontMatch = fontFacePattern.exec(styleContent)) !== null) {
const fontFaceContent = fontMatch[1];
const fontFamily = fontFaceContent.match(/font-family\s*:\s*['"]*([^'";\n]+)/i);
const fontDisplay = fontFaceContent.match(/font-display\s*:\s*(swap|fallback|optional|auto|block)/i);
fontFaces.push({
fontFamily: fontFamily ? fontFamily[1].trim() : 'unknown',
fontDisplay: fontDisplay ? fontDisplay[1].toLowerCase() : 'auto (blocking)',
isOptimized: fontDisplay && (fontDisplay[1].toLowerCase() === 'swap' || fontDisplay[1].toLowerCase() === 'fallback' || fontDisplay[1].toLowerCase() === 'optional')
});
}
}
return fontFaces;
}
extractFontFacesFromCss(cssContent) {
const fontFacePattern = /@font-face\s*\{([^}]*)\}/gi;
const fontFaces = [];
let match;
while ((match = fontFacePattern.exec(cssContent)) !== null) {
const content = match[1];
const fontFamily = content.match(/font-family\s*:\s*['"]*([^'";\n]+)/i);
const fontWeight = content.match(/font-weight\s*:\s*([^;]+)/i);
const fontStyle = content.match(/font-style\s*:\s*([^;]+)/i);
const fontDisplay = content.match(/font-display\s*:\s*(swap|fallback|optional|auto|block)/i);
fontFaces.push({
fontFamily: fontFamily ? fontFamily[1].trim() : 'unknown',
weight: fontWeight ? fontWeight[1].trim() : '400',
style: fontStyle ? fontStyle[1].trim() : 'normal',
fontDisplay: fontDisplay ? fontDisplay[1].toLowerCase() : 'MISSING',
hasFontDisplay: !!fontDisplay,
isOptimized: fontDisplay && ['swap', 'fallback', 'optional'].includes(fontDisplay[1].toLowerCase())
});
}
return fontFaces;
}
generateRecommendations(fontDeclarations, fontLinks, externalFontContent, inlineStyles) {
const recommendations = [];
// Check inline styles
const unoptimizedInline = inlineStyles.filter(f => !f.isOptimized);
if (unoptimizedInline.length > 0) {
recommendations.push(`${unoptimizedInline.length} inline @font-face block(s) not optimized. Add font-display: swap.`);
}
// Check external font files
let totalExternal = 0;
let unoptimizedExternal = 0;
for (const url in externalFontContent) {
const content = externalFontContent[url];
if (!content.error) {
totalExternal += content.totalCount;
unoptimizedExternal += content.totalCount - content.optimizedCount;
}
}
if (totalExternal > 0 && unoptimizedExternal > 0) {
recommendations.push(`${unoptimizedExternal} external @font-face block(s) not optimized. Add font-display: swap to CSS files.`);
}
// Check if fonts are deferred
const deferredLinks = fontLinks.filter(l => l.media === 'print' || l.onload);
const notDeferredLinks = fontLinks.filter(l => l.media !== 'print' && !l.onload);
if (notDeferredLinks.length > 0) {
recommendations.push(`${notDeferredLinks.length} font link(s) are render-blocking. Load with media="print" onload="this.media='all'" to defer.`);
}
// Check for preloading
if (fontLinks.length > 0) {
recommendations.push(`Consider adding rel="preload" as="style" to critical font links to improve performance.`);
}
if (recommendations.length === 0) {
recommendations.push('Font loading appears well optimized!');
}
return recommendations;
}
}
function formatReport(report) {
if (report.status === 'error') {
return `
Error checking ${report.url}
${report.error}
`;
}
const { summary, fontLinks, fontDeclarations, inlineStyles, externalFontContent, recommendations } = report;
let output = `
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Font Loading Optimization Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
URL: ${report.url}
Summary:
Status: ${summary.isOptimized ? '✓ OPTIMIZED' : '✗ NOT OPTIMIZED'}
Total Font Declarations: ${summary.totalFontDeclarations}
Optimized: ${summary.optimizedFonts}
Unoptimized: ${summary.unoptimizedFonts}
Font Links Deferred: ${summary.deferredFontLinks}/${summary.totalFontLinks}
`;
if (fontLinks.length > 0) {
output += `
Font Links: ${fontLinks.length}
`;
fontLinks.forEach((fl) => {
const deferStatus = fl.media === 'print' || fl.onload ? '✓' : '✗';
const deferText = fl.media === 'print' ? ' [media=print]' : (fl.onload ? ' [onload]' : ' [RENDER-BLOCKING]');
output += ` ${deferStatus} ${fl.url}${deferText}\n`;
});
}
if (inlineStyles.length > 0) {
output += `
Inline Font Faces: ${inlineStyles.length}
`;
inlineStyles.forEach((ff) => {
const status = ff.isOptimized ? '✓' : '✗';
output += ` ${status} ${ff.fontFamily}: ${ff.fontDisplay}\n`;
});
}
if (Object.keys(externalFontContent).length > 0) {
output += `
External Font CSS Files: ${Object.keys(externalFontContent).length}
`;
for (const url in externalFontContent) {
const content = externalFontContent[url];
if (content.error) {
output += ` ✗ ${url} - Error: ${content.error}\n`;
} else {
const status = content.optimizedCount === content.totalCount ? '✓' : '✗';
const deferStatus = content.isDeferredMedia ? '✓' : '✗';
output += ` ${status} ${url}\n`;
output += ` Fonts: ${content.optimizedCount}/${content.totalCount} optimized\n`;
output += ` Deferred: ${deferStatus}\n`;
if (content.fontFaces.length > 0) {
content.fontFaces.forEach((ff) => {
const optStatus = ff.isOptimized ? '✓' : '✗';
output += ` ${optStatus} ${ff.fontFamily} ${ff.weight}/${ff.style}: ${ff.fontDisplay}\n`;
});
}
}
}
}
if (recommendations.length > 0) {
output += `
Recommendations:
`;
recommendations.forEach((rec, index) => {
output += ` ${index + 1}. ${rec}\n`;
});
}
output += `
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`;
return output;
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(`
Font Loader Checker - Verify font-display optimization and deferring
Usage:
./font-test.sh <URL> [URL2] [URL3] ...
./font-test.sh https://example.com
./font-test.sh https://example.com https://another.com
Features:
✓ Checks inline @font-face blocks
✓ Fetches external CSS files and analyzes font-face blocks
✓ Detects font-display: swap declarations
✓ Checks if fonts are deferred (media="print" or onload)
✓ Shows detailed recommendations
Examples:
./font-test.sh https://andrewbaker.ninja
./font-test.sh https://example.com https://test.com
`);
process.exit(0);
}
const urls = args.filter(arg => arg.startsWith('http://') || arg.startsWith('https://'));
if (urls.length === 0) {
console.error('Error: No valid URLs provided');
process.exit(1);
}
const checker = new FontLoaderChecker();
console.log(`\nChecking ${urls.length} site(s) for font optimization...\n`);
for (const targetUrl of urls) {
const report = await checker.check(targetUrl);
console.log(formatReport(report));
}
}
main().catch(console.error);
EOF
chmod +x font-loader-checker.js
Done. The tool uses only Node.js built-ins, no dependencies needed.
Step 2: Check Your Site
Test any website:
node font-loader-checker.js https://andrewbaker.ninja Example output:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Font Loading Optimization Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
URL: https://andrewbaker.ninja/
Summary:
Status: ✓ OPTIMIZED
Total Font Declarations: 43
Optimized: 43
Unoptimized: 0
Font Links Deferred: 1/1
Font Links: 1
✓ https://andrewbaker.ninja/wp-content/themes/twentysixteen/fonts/merriweather-plus-montserrat-plus-inconsolata.css [media=print]
External Font CSS Files: 1
✓ https://andrewbaker.ninja/wp-content/themes/twentysixteen/fonts/merriweather-plus-montserrat-plus-inconsolata.css
Fonts: 43/43 optimized
Deferred: ✓
✓ Merriweather 400/normal: fallback
✓ Merriweather 400/normal: fallback
✓ Merriweather 400/normal: fallback
✓ Merriweather 400/normal: fallback
✓ Merriweather 400/normal: fallback
✓ Merriweather 700/normal: fallback
✓ Merriweather 700/normal: fallback
✓ Merriweather 700/normal: fallback
✓ Merriweather 700/normal: fallback
✓ Merriweather 700/normal: fallback
✓ Merriweather 900/normal: fallback
✓ Merriweather 900/normal: fallback
✓ Merriweather 900/normal: fallback
✓ Merriweather 900/normal: fallback
✓ Merriweather 900/normal: fallback
✓ Merriweather 400/italic: fallback
✓ Merriweather 400/italic: fallback
✓ Merriweather 400/italic: fallback
✓ Merriweather 400/italic: fallback
✓ Merriweather 400/italic: fallback
✓ Merriweather 700/italic: fallback
✓ Merriweather 700/italic: fallback
✓ Merriweather 700/italic: fallback
✓ Merriweather 700/italic: fallback
✓ Merriweather 700/italic: fallback
✓ Merriweather 900/italic: fallback
✓ Merriweather 900/italic: fallback
✓ Merriweather 900/italic: fallback
✓ Merriweather 900/italic: fallback
✓ Merriweather 900/italic: fallback
✓ Montserrat 400/normal: fallback
✓ Montserrat 400/normal: fallback
✓ Montserrat 400/normal: fallback
✓ Montserrat 400/normal: fallback
✓ Montserrat 400/normal: fallback
✓ Montserrat 700/normal: fallback
✓ Montserrat 700/normal: fallback
✓ Montserrat 700/normal: fallback
✓ Montserrat 700/normal: fallback
✓ Montserrat 700/normal: fallback
✓ Inconsolata 400/normal: fallback
✓ Inconsolata 400/normal: fallback
✓ Inconsolata 400/normal: fallback
Recommendations:
1. Consider adding rel="preload" as="style" to critical font links to improve performance.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Green checkmarks = you’re good. Users won’t wait for fonts.
Step 3: Audit Multiple Sites
Check your entire portfolio in one command:
node font-loader-checker.js \
https://andrewbaker.ninja \
https://example.com \
https://mysite.io \
https://client-site.com
Expected output for unoptimized fonts:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Font Loading Optimization Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
URL: https://example.com
Summary:
Status: ✗ NOT OPTIMIZED
Total Font Declarations: 2
Optimized: 0
Unoptimized: 2
Font Faces Found: 2
✗ Lato: auto (blocking)
✗ Playfair Display: auto (blocking)
Recommendations:
1. 2 font declaration(s) are not optimized. Use font-display: swap instead of auto or block.
2. Consider preloading 2 font link(s) with <link rel="preload" as="style">
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Red X = your fonts are blocking. Fix them next.
Step 4: Fix Unoptimized Fonts
If your site has custom fonts, add swap to the @font-face rules. Find your CSS and change:
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2');
font-display: auto; /* BAD: blocks rendering */
}
To:
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2');
font-display: swap; /* GOOD: shows fallback immediately */
}
Also handle external fonts (Google Fonts, etc.). Add preload headers to your HTML or server config.
Step 5: Re-run the Checker
After fixing, re-run the tool to confirm:
node font-loader-checker.js https://yoursite.com
All green checkmarks = problem solved.
Real World Example
Check multiple sites and save the report:
node font-loader-checker.js \
https://example.com \
https://test.com \
https://another.com > font-report.txt
cat font-report.txt
This gives you a baseline. Share the report with your team. Track which sites need fixes.
Why This Matters
Font loading affects three critical metrics:
Largest Contentful Paint (LCP): Fonts block rendering. Swap avoids it.
Cumulative Layout Shift (CLS): Font swap can cause layout jank if not tuned. Preload helps.
First Input Delay (FID): Fonts don’t block interactivity, but they do block paint.
All three together = your Core Web Vitals score. Google weighs this heavily in search rankings.
Wrap Up
Font loading is invisible to end users, but it has huge impact on performance. With font-display: swap and a quick audit, you can:
- Show text immediately using fallback fonts
- Upgrade to custom fonts silently in the background
- Improve Core Web Vitals
- Boost SEO rankings
Check out the following for other similar tests: https://pagespeed.web.dev/
Start today. Audit one site. Fix the fonts. Ship it.