Font Loading in Next.js 15: Why Your LCP Still Sucks
next/font is supposed to make fonts a solved problem. It isn't. Here's why your Largest Contentful Paint still regresses, and how to actually fix it.

We shipped a marketing site last quarter that scored a perfect Lighthouse run locally and a 3.2s LCP on real devices in the field. The culprit wasn't images, JS, or a slow API. It was a 14KB woff2 file routed through next/font exactly the way the docs recommend. If your LCP graph in Vercel Analytics looks like a heart monitor, fonts are probably why.
The promise next/font made
When next/font landed, it solved three real problems: it self-hosted Google Fonts to avoid the third-party hop, it generated fallback metrics to prevent layout shift, and it preloaded the binary alongside the HTML. For most sites, you import it, spread the className, and move on.
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
This is fine. It is also where most teams stop reading and start shipping. The defaults are sensible until your design system has four weights, an italic variant, a display font for headings, and a monospace for code blocks. Then the math changes.
Why LCP regresses anyway
LCP is almost always a text node on a marketing page — a hero headline. The browser cannot paint that headline until it has either the real font or has decided to render with the fallback. With display: swap you get a flash of unstyled text (FOUT); with display: optional you may not get the custom font on first load at all. Neither is wrong, but both affect what counts as the "largest contentful paint".
Here's the part that bites teams: the LCP element is measured against the rendered font. If your fallback metrics are off, the browser paints the headline at one size, then the real font arrives and the headline reflows. On some Chromium versions, that reflow resets the LCP candidate. Your local run on a warm cache never sees this. Your users on cold 4G see nothing else.
The fallback metrics trap
next/font injects a @font-face block with size-adjust, ascent-override, descent-override, and line-gap-override calculated against a default Arial or Times fallback. These overrides try to make the fallback render at the same visual dimensions as your real font, eliminating CLS during swap.
The trap: those metrics are computed against the first weight you import. If you load Inter 400 and 700, the overrides match 400. Your H1 in 700 still shifts. We've seen CLS scores jump from 0.02 to 0.18 between releases because a designer added a font-weight: 800 headline and nobody recomputed anything.
The fix is to declare an explicit adjustFontFallback per weight, or — more pragmatically — to use a single variable font and let the browser interpolate.
A debugging checklist that actually works
When LCP regresses and fonts are the suspect, run through this in order. Don't skip steps; the cheap ones catch most issues.
1. Confirm the LCP element
Open Chrome DevTools → Performance → record a cold load with CPU and network throttled. Find the LCP marker. If it's text, note the computed font-family at paint time. If it shows your fallback (e.g. Arial), the real font hasn't arrived yet and display: swap is doing its job. If it shows the real font but LCP is still high, you're blocked on the font request itself.
2. Check the preload
View source on the HTML response. You should see something like:
<link rel="preload" href="/_next/static/media/abc123.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
If the preload is missing, you probably imported the font inside a client component or a non-root layout. next/font only preloads fonts referenced from the route segment being rendered. Move the import to the root layout, or accept that nested routes will fetch the font on demand.
3. Audit your subsets
We've inherited codebases that import subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'] for an English-only site. Each subset is a separate file. The browser preloads all of them. You're paying for languages no one in your audience reads.
const inter = Inter({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
preload: true,
adjustFontFallback: 'Arial',
});
Be explicit about weights too. The default behaviour for variable fonts is to load the full variable file, which is great for design flexibility and terrible for first paint.
4. Look at the cascade for surprise overrides
Design systems love CSS variables. Something like this is common:
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-display' });
return (
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body>{children}</body>
</html>
);
This ships two fonts. If your hero uses font-family: var(--font-display), the LCP element is waiting on Playfair, not Inter. The Inter preload is irrelevant to the metric you care about. We've watched teams optimise the wrong font for a week.
When to break the rules
The sharp edge of next/font is that it optimises for the framework's defaults, not your design. There are three situations where we deliberately step outside it.
Critical text in a system font
For data-dense product UIs — dashboards, admin panels — we render the first paint in system-ui and load the brand font only for marketing surfaces. Users on the dashboard don't care that the sidebar is in San Francisco instead of Inter. They care that it appeared in 400ms.
:root {
--font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.brand-surface {
--font-sans: var(--font-inter), system-ui, sans-serif;
}
Scope the custom font to the routes that need it. The marketing layout opts in; the app layout doesn't.
Self-hosted, manually preloaded
For sites with a single hero font and ruthless performance budgets, we sometimes drop next/font entirely and ship the woff2 from /public with a hand-written <link rel="preload"> and a tuned @font-face. You lose the build-time fallback metric calculation, but you gain full control over headers, immutable caching, and the ability to inline the @font-face in the document head before any CSS bundle loads.
The tradeoff: you own the cache key. When the font changes, you need to bust it manually. next/font handles this with hash-based filenames automatically.
Variable fonts with explicit ranges
If you genuinely need three weights, a variable font with a constrained font-weight range is almost always smaller than three static files. Modern variable Inter is around 100KB for the full variable axis; three static weights are roughly 45KB each. The variable file wins above two weights, every time.
The Core Web Vitals reality check
In our experience auditing client sites, font-driven LCP regressions cluster in a few patterns:
- Marketing pages where the H1 uses a display font with no fallback adjustment — LCP commonly lands in the 2.5–4s range on mid-tier Android.
- Dashboards that import six weights of two families on every route — first-load JS looks fine, but font transfer dominates the critical path.
- Sites that A/B test font choices via client-side swap — LCP becomes non-deterministic because the test bucket controls the font URL.
None of these show up in Lighthouse on a developer's MacBook. They all show up in field data the moment you ship.
What we'd do on Monday
If you suspect fonts are dragging your LCP, do the cheap audit first. Open production in DevTools with the network throttled to Slow 4G, record a cold load, and count the font requests in the waterfall. If there are more than two, you have work to do. Trim subsets and weights, consolidate to a single variable font where you can, scope brand fonts to the layouts that need them, and verify the preload tag is actually in the HTML response.
If you want a second pair of eyes on a real codebase, we do this work as part of our web development engagements — usually the first afternoon pays for itself in field data improvements within a week.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Font Loading in 2026: Why next/font Isn't Always the Answer
next/font is great until it isn't. A practical breakdown of when to use it, when to ship raw @font-face, and how to stop fonts from wrecking your LCP and CLS scores.

View Transitions in the App Router: The Good, the Janky, and the Layout Shifts
Cross-document view transitions finally landed in stable browsers and Next.js shipped a primitive for them. Here's what actually works in production, what still tears, and how to keep CLS honest.

Middleware Is Not a Router: What We Learned Rebuilding Auth on the Edge
Next.js middleware looks like the perfect place for auth. Then your bundle balloons, your Edge runtime chokes on a Node API, and your login redirects loop. Here's how we untangled it.
