All articles
Web DevelopmentSeptember 29, 2026 5 min read

Font Subsetting in Next.js: How We Cut CLS to Near Zero on a Content-Heavy Site

A war story about chasing a stubborn 0.18 CLS score on a publishing site, and the font subsetting and fallback metrics work that finally got us to 0.02.

Font Subsetting in Next.js: How We Cut CLS to Near Zero on a Content-Heavy Site

We inherited a Next.js publishing site last spring that scored a stubborn 0.18 Cumulative Layout Shift on article pages. Everything looked right on paper — next/font, display: swap, preloaded critical CSS — but readers on mid-tier Android devices watched headlines jump half a line on every navigation. This is the story of how we got that number to 0.02, and why the fix wasn't what we expected.

The site, the stack, the symptom

The client publishes long-form editorial content — think 2,000-word features with pull quotes, drop caps, and mixed serif/sans typography. Stack was Next.js 15 (App Router), React 19, Tailwind, self-hosted variable fonts (one serif for body, one sans for UI chrome). Traffic was 70% mobile, mostly organic search, so Core Web Vitals mattered directly to acquisition.

The CLS pattern was consistent:

  • First paint used the system fallback (roughly Georgia on iOS, Roboto Serif on Android)
  • Custom serif swapped in ~200–400ms later
  • Headlines re-flowed by one or two lines
  • Any image below the fold that depended on that flow shifted with it

Field data from CrUX confirmed what the lab told us. The 75th percentile CLS sat between 0.15 and 0.20 for months.

Why the obvious fixes didn't work

We started where anyone would start:

// app/fonts.ts
import localFont from 'next/font/local';

export const editorialSerif = localFont({
  src: './fonts/EditorialSerif-Variable.woff2',
  display: 'swap',
  variable: '--font-serif',
  preload: true,
});

This is the recommended pattern. next/font computes a fallback with size-adjust, ascent-override, and descent-override automatically. In theory, the fallback should occupy roughly the same box as the real font, and swap should be visually silent.

In practice it wasn't. Two reasons, both of which took embarrassingly long to see.

Reason one: our fallback stack was wrong for the metric

next/font generates fallback metrics based on a single reference font — by default, Arial for sans and Times New Roman for serif. If your actual CSS font-family fallback chain is different, the browser uses a font whose metrics don't match what Next.js computed the overrides for.

Our Tailwind config had this:

// tailwind.config.ts
fontFamily: {
  serif: ['var(--font-serif)', 'Georgia', 'ui-serif', 'serif'],
}

Georgia is not Times. On iOS, Georgia has a taller x-height and different ascent metrics. The next/font-generated adjust values were tuned for Times, so on iOS the fallback rendered slightly larger than the real font, and headlines wrapped differently before the swap.

The fix was to explicitly tell next/font which fallback we actually wanted to match, and to make the CSS chain match it:

export const editorialSerif = localFont({
  src: './fonts/EditorialSerif-Variable.woff2',
  display: 'swap',
  variable: '--font-serif',
  preload: true,
  adjustFontFallback: 'Times New Roman',
  fallback: ['Times New Roman', 'Times', 'serif'],
});

That alone got CLS from 0.18 down to around 0.09. Better, but still bad. The good threshold is 0.10, and we were only just there in the lab — field data lagged.

Reason two: the font file was too big to arrive in time

Our variable serif was 148KB compressed. It supported weights 100–900, italics, small caps, and the full Latin Extended range plus Cyrillic and Vietnamese. The site was English-only.

On a mid-tier Android on 4G, that font took 300–500ms to arrive after the preload started. With display: swap, that meant 300–500ms of fallback text, and any layout shift during that window counted.

Subsetting to what we actually use

We ran the font through fonttools (Python) with pyftsubset, keeping only the Latin subset the site actually served, only the weights the CMS could produce (400, 600, 800), and dropping small caps and old-style figures we never used:

pyftsubset EditorialSerif-Variable.woff2 \
  --output-file=EditorialSerif-Subset.woff2 \
  --flavor=woff2 \
  --unicodes="U+0000-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF" \
  --layout-features="kern,liga,dlig,onum" \
  --axes=wght \
  --instancer='wght=400:800'

The file dropped from 148KB to 34KB. That's not a rounding error — that's a different network profile entirely. On the same mid-tier Android, the subset arrived in roughly 90–150ms, comfortably inside the swap window most users never notice.

A note if you go down this path: automate this. We put the subsetting step in the build pipeline so a designer swapping the source font can't accidentally ship the 148KB version. The script lives next to the fonts directory and runs before next build.

Reason three: the drop cap

Editorial articles opened with a drop cap — the first letter of the article, 4 lines tall, using a different weight of the serif. We rendered it with a ::first-letter pseudo-element.

::first-letter is notoriously sensitive to font metrics. When the fallback swapped to the real font, the drop cap's computed height changed by a few pixels, which cascaded into a small but measurable shift on every article page.

We swapped the pseudo-element for a real span with explicit dimensions:

// components/DropCap.tsx
export function DropCap({ children }: { children: string }) {
  const [first, ...rest] = children;
  return (
    <p className="article-lead">
      <span
        className="float-left mr-2 font-serif font-black leading-[0.85]"
        style={{
          fontSize: '4.2em',
          width: '0.7em',
          height: '3.4em',
          display: 'inline-block',
        }}
        aria-hidden="true"
      >
        {first}
      </span>
      <span className="sr-only">{first}</span>
      {rest.join('')}
    </p>
  );
}

Explicit width and height reserve the box regardless of which font is rendering. The aria-hidden plus screen-reader-only twin keeps the reading order intact — a small accessibility detail that's easy to miss when you're chasing CLS.

Putting it together

After all three changes shipped, we watched field data for two weeks:

  • P75 CLS: 0.18 → 0.02
  • P75 LCP: essentially unchanged (we weren't LCP-bound)
  • Bounce rate on article pages: down noticeably, though we won't quote a number because it was tangled up with an unrelated navigation change that shipped the same week

The order of impact was roughly: subsetting > drop-cap fix > fallback matching. Your mileage will vary depending on which of these you already have right.

A checklist we now run on every content site

  1. Confirm the CSS fallback chain matches the font next/font is computing overrides against
  2. Subset custom fonts to the character ranges, weights, and features actually used
  3. Audit any ::first-letter, ::first-line, or initial-letter usage for shift risk
  4. Reserve dimensions for anything decorative that depends on font metrics
  5. Verify with real device throttling, not just DevTools

What we'd do next time

On a new content site, we'd start with the font pipeline before writing a line of layout code: pick the fallback deliberately, subset aggressively at build time, and treat every decorative typographic flourish as a potential CLS source until proven otherwise. It's cheaper to design around the constraint than to retrofit around it.

If you're staring at a stubborn CLS number on a Next.js site and you've already done the obvious things, start with your font file size and your fallback stack. That's where ours was hiding, and it's usually where the last stubborn hundredths of a point live. Our team writes more about this kind of production work on the 72Technologies blog, and we help clients ship it through our web development services.

#Next.js#Performance#Core Web Vitals#Typography

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project