Website Optimisation: Stop Waiting for Fonts

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:

  1. User navigates to your site
  2. Browser downloads HTML and CSS
  3. Browser sees @font-face rule for custom font (Montserrat, etc.)
  4. Browser blocks all text rendering and requests the custom font file
  5. Browser waits… waits… waits for the font file to download
  6. 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:

  1. Browser shows fallback font immediately (system font like Arial)
  2. Custom font loads in background (no blocking)
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *