The Font Loading Tax: How next/font Quietly Wrecks INP on Slow Devices
next/font promises zero layout shift and no external requests. On mid-range Android, it can also add 200ms to your INP. Here's what we found and how we fixed it.

We shipped a marketing site last quarter that scored a clean 98 on Lighthouse desktop and passed CWV on the Chrome UX Report for weeks. Then a product manager pulled up the page on her three-year-old Pixel over a hotel Wi-Fi and INP hit 480ms on the first click. The culprit wasn't a heavy component or a rogue analytics script. It was next/font.
This is a war story about how the default font pipeline in the App Router — the one everyone recommends, including us — has a slow-device tax nobody talks about, and what we changed to get INP back under 200ms without losing the layout-shift guarantees.
Why next/font feels free but isn't
The pitch for next/font is genuinely great. It self-hosts Google Fonts at build time, inlines the font-face declarations, generates a size-adjusted fallback to eliminate CLS, and preloads the files for the current route. No FOUT, no CLS, no third-party request to Google. On a fast laptop with a warm cache, it is invisible.
The part that gets glossed over: each font variant you import becomes a <link rel="preload"> in the document head, which the browser treats as high-priority. On a slow 4G connection with a constrained CPU, those preloads compete with the main JS bundle for both bandwidth and the main thread. When the font finally arrives, the browser triggers a style recalculation and a repaint. If that repaint lands during the user's first interaction — a tap on a nav item, say — INP eats the cost.
We measured it with the Web Vitals attribution build and a throttled Moto G Power profile in DevTools. The interaction target was ready. React was hydrated. But the processingDuration phase of INP was blocked by a synchronous style recalc triggered by the late-arriving variable font file.
The default that bit us
Here is the setup we started with. It looks fine. It is what the docs suggest.
// app/fonts.ts
import { Inter, Fraunces } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
export const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-fraunces',
display: 'swap',
axes: ['opsz', 'SOFT'],
});
Two variable fonts. Both preloaded on every route because they are declared in the root layout. Fraunces with two optional axes, which balloons the file size past 180KB. On the homepage that used both, the preload waterfall pushed the LCP image back by roughly 150ms on throttled mobile, and the late paint clobbered INP whenever a user tapped before the fonts settled.
What the browser is actually doing
When a <link rel="preload" as="font"> is in the head, Chrome fetches it in parallel with the HTML and JS. Good. But font-display: swap means the browser paints with the fallback first, then swaps when the real font is ready. That swap is not free — it invalidates layout for every element using the font, forces a style recalc, and repaints. If your React app is hydrating during that window, the two costs stack.
On a fast device this all happens in under 50ms and you never notice. On a device with a 4x CPU slowdown and a 1.5Mbps connection, the recalc alone can take 80ms, and if it lands inside an interaction it becomes part of INP.
The variable font axis trap
We assumed adding optical size (opsz) and softness (SOFT) axes was cheap because it is one file. It is not. Variable font files grow roughly linearly with the number of axes, and each additional axis is data your users download whether or not your CSS uses the full range. We were pulling down SOFT on every page even though only the hero on the homepage used it.
What we changed
Four things, in order of impact.
1. Stop preloading fonts on routes that don't render above the fold
next/font preloads a font on any route that imports it. If your footer uses Fraunces but your above-the-fold content does not, you are still paying the preload cost on every page. We split fonts into per-route imports and disabled preload for the display face:
// app/fonts.ts
import { Inter, Fraunces } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
preload: true,
});
export const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-fraunces',
display: 'swap',
preload: false, // only used in headings, fine to load late
axes: ['opsz'],
});
Dropping SOFT cut the Fraunces file by about 40%. Disabling preload freed a high-priority slot for the LCP image.
2. Scope fonts to the layouts that use them
The root layout is not the only place you can attach a font. If Fraunces only appears on /marketing/*, import it in app/marketing/layout.tsx instead. The App Router will only inject the font-face and preload on matching routes.
// app/marketing/layout.tsx
import { fraunces } from '@/app/fonts';
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className={fraunces.variable}>{children}</div>;
}
This was the single biggest INP win. The dashboard routes, which are behind auth and never render Fraunces, stopped downloading it entirely.
3. Use size-adjust instead of swap where you can tolerate FOIT
display: 'swap' is the safe default because it guarantees text is readable during load. But swap causes the repaint we described above. On routes where the font is preloaded and the connection is decent, display: 'optional' gives the browser a 100ms budget — if the font arrives, it uses it; if not, it falls back for the rest of the session and skips the swap entirely.
We used optional for body copy and kept swap for the display face where the brand cost of a fallback was too high. INP on interactions during load dropped noticeably.
4. Measure with attribution, not just Lighthouse
Lighthouse runs on a warm cache with a predictable throttle. It will not show you what a real user on a real Pixel 6a sees. The web-vitals library's attribution build tells you which element the interaction targeted and what phase of INP was slowest.
// app/vitals.tsx
'use client';
import { useEffect } from 'react';
import { onINP } from 'web-vitals/attribution';
export function Vitals() {
useEffect(() => {
onINP((metric) => {
const attribution = metric.attribution;
navigator.sendBeacon('/api/vitals', JSON.stringify({
value: metric.value,
target: attribution.interactionTarget,
type: attribution.interactionType,
loadState: attribution.loadState,
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
}));
});
}, []);
return null;
}
presentationDelay was the tell for us. When it was consistently the largest chunk of INP and correlated with loadState: 'loading', we knew paint work — not React — was the bottleneck.
The numbers, honestly
We are cautious about quoting benchmarks because your product is not our product. In our case, on the homepage, p75 INP on mobile dropped from around 320ms to around 180ms over two weeks after the changes rolled out. LCP improved by roughly 100ms because the image preload stopped competing with fonts. CLS was unchanged, which was the point — we did not want to give up the fallback metrics adjustment next/font provides.
Your mileage will vary based on how heavy your fonts are, how many variants you ship, and how much JS is hydrating at the same time. The pattern to watch for is: good desktop scores, poor mobile field data, and INP dominated by presentationDelay during the loading phase.
When next/font is still the right call
None of this means you should hand-roll font loading. The size-adjusted fallback alone is worth the trade. The point is that the defaults are optimised for a good baseline, not for a heavily branded marketing site with two display faces on a slow-device audience. Treat font imports like you treat client components — put them where they are used, not in the root by habit.
Where we'd start
If your INP field data is worse than your lab data, wire up web-vitals/attribution first. You cannot fix what you cannot see. Then audit app/layout.tsx for font imports that belong deeper in the tree, drop unused variable axes, and flip preload: false on any face that isn't paint-critical. Do it in that order and you will usually find the win before you touch a single component. If you want a hand running the audit on a live site, that is the kind of work our team does on performance engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
The `use` Hook and Data Fetching in Client Components: Where It Helps and Where It Bites
React 19's `use` hook looks like the clean answer to promises in client components. It mostly is — until you hit the re-render trap, the Suspense boundary confusion, and the cache invalidation mess.

The Middleware Weight Problem: Why Our Edge Bundle Broke Production
Middleware runs on every request. We watched a 40KB dependency turn into 400ms of added latency across every route. Here's how we found it, fixed it, and set guardrails so it never happens again.

React Server Components and Third-Party Scripts: The Hydration Trap Nobody Warned Us About
Analytics, chat widgets, and A/B testing tools quietly break React Server Components in ways that don't show up until production. Here's the pattern we use to keep them from poisoning hydration and TTI.
