TechLead
Lesson 6 of 22
5 min read
Performance Engineering

Font Optimization

Eliminate font-related layout shifts and render blocking with optimal loading strategies and subsetting

The Performance Cost of Web Fonts

Web fonts are render-blocking resources that can cause both delayed text rendering and layout shifts. When a browser encounters a web font, it may hide text (FOIT — Flash of Invisible Text) or show a fallback font that causes a layout shift when the web font loads (FOUT — Flash of Unstyled Text). Both degrade user experience and impact Core Web Vitals.

Font Loading Behaviors

  • FOIT (block): Text is invisible until the font loads. Default in most browsers for 3 seconds.
  • FOUT (swap): Fallback font shown immediately, swapped when web font loads. Causes layout shift.
  • Optional: Font only used if already cached. No layout shift, but may show fallback permanently.
  • Fallback: Short block period (~100ms), then fallback. Swap when font arrives within 3s.

Optimal Font Loading Strategy

// next.config.js — Next.js automatic font optimization
// Next.js automatically inlines Google Font CSS at build time.
// For local fonts, use next/font:

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
import localFont from 'next/font/local';

// Google Fonts — automatically self-hosted, no external requests
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',           // Use font-display: swap
  variable: '--font-inter',  // CSS variable for Tailwind
  preload: true,
});

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
});

// Local fonts — full control over loading
const customFont = localFont({
  src: [
    { path: '../fonts/custom-regular.woff2', weight: '400', style: 'normal' },
    { path: '../fonts/custom-medium.woff2', weight: '500', style: 'normal' },
    { path: '../fonts/custom-bold.woff2', weight: '700', style: 'normal' },
  ],
  display: 'swap',
  variable: '--font-custom',
  preload: true,
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable} ${customFont.variable}`}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Font Subsetting

A typical font file contains glyphs for many languages and special characters. If you only need Latin characters, subsetting can reduce font file sizes by 70-90%. Tools like glyphhanger or fonttools analyze which characters your site uses and generate optimized subsets.

// Font subsetting with fonttools (Python) — run as a build step
// pip install fonttools brotli

// subset-fonts.ts — Node.js wrapper for font subsetting
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

interface SubsetConfig {
  inputFont: string;
  outputFont: string;
  unicodeRange: string;
  features: string[];
}

function subsetFont(config: SubsetConfig): void {
  const { inputFont, outputFont, unicodeRange, features } = config;

  const command = [
    'pyftsubset',
    inputFont,
    `--output-file=${outputFont}`,
    `--unicodes=${unicodeRange}`,
    '--flavor=woff2',
    '--layout-features=' + features.join(','),
    '--desubroutinize',
    '--no-hinting',
  ].join(' ');

  execSync(command);

  const originalSize = fs.statSync(inputFont).size;
  const subsetSize = fs.statSync(outputFont).size;
  const savings = ((1 - subsetSize / originalSize) * 100).toFixed(1);

  console.log(`${path.basename(inputFont)}: ${(originalSize/1024).toFixed(1)}KB -> ${(subsetSize/1024).toFixed(1)}KB (${savings}% smaller)`);
}

// Latin subset for Western European languages
subsetFont({
  inputFont: 'fonts/Inter-Regular.ttf',
  outputFont: 'public/fonts/inter-latin.woff2',
  unicodeRange: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
  features: ['kern', 'liga', 'calt', 'ccmp'],
});

Preventing Font Layout Shifts

The biggest CLS impact from fonts comes when the fallback font has different metrics than the web font. The CSS size-adjust, ascent-override, descent-override, and line-gap-override properties let you match fallback font metrics to your web font, eliminating the layout shift.

// Matching fallback font metrics to prevent CLS
// Use the @font-face override descriptors

// In your global CSS:
// @font-face {
//   font-family: 'Inter';
//   src: url('/fonts/inter-latin.woff2') format('woff2');
//   font-weight: 400;
//   font-style: normal;
//   font-display: swap;
// }
//
// /* Adjusted fallback that matches Inter's metrics */
// @font-face {
//   font-family: 'Inter Fallback';
//   src: local('Arial');
//   ascent-override: 90.49%;
//   descent-override: 22.56%;
//   line-gap-override: 0%;
//   size-adjust: 107.06%;
// }
//
// body {
//   font-family: 'Inter', 'Inter Fallback', sans-serif;
// }

// Generating size-adjust values programmatically
// Using fontkit to read font metrics
import fontkit from 'fontkit';

interface FontMetrics {
  unitsPerEm: number;
  ascent: number;
  descent: number;
  lineGap: number;
  avgCharWidth: number;
}

function getFontMetrics(fontPath: string): FontMetrics {
  const font = fontkit.openSync(fontPath);
  const os2 = font['OS/2'];

  return {
    unitsPerEm: font.unitsPerEm,
    ascent: os2.sTypoAscender,
    descent: Math.abs(os2.sTypoDescender),
    lineGap: os2.sTypoLineGap,
    avgCharWidth: os2.xAvgCharWidth,
  };
}

function calculateOverrides(webFont: FontMetrics, fallbackFont: FontMetrics) {
  const sizeAdjust = webFont.avgCharWidth / fallbackFont.avgCharWidth;

  return {
    sizeAdjust: `${(sizeAdjust * 100).toFixed(2)}%`,
    ascentOverride: `${((webFont.ascent / webFont.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`,
    descentOverride: `${((webFont.descent / webFont.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`,
    lineGapOverride: `${((webFont.lineGap / webFont.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`,
  };
}

Font Optimization Checklist

  • Self-host fonts: Eliminate third-party connection overhead. Next.js does this automatically with next/font.
  • Use WOFF2: Best compression for web fonts, near-universal browser support.
  • Subset aggressively: Only include the character sets you actually need.
  • Preload critical fonts: Add preload hints for fonts used above the fold.
  • Use font-display: swap: Show text immediately with a fallback font.
  • Match fallback metrics: Use size-adjust and override descriptors to prevent CLS.
  • Limit font variants: Only load the weights and styles you actually use.

Continue Learning