Next.js Performance: Images, Fonts, and Caching
I show which next/image props actually change what the browser downloads, how next/font eliminates layout shift by default, and how caching strategy directly controls your page's TTFB.

Switching from <img> to <Image> is a start, but most examples stop there. The three props that actually change what the browser downloads — sizes, priority, and placeholder — rarely appear in tutorials. Fonts have the same gap: next/font does more than swap the import, but only if you call it from the right file. And if you reach for cache: 'no-store' “to be safe,” every page render hits your database whether it needs to or not.
This post covers the patterns that move the needle. I’ll show what each one does, where most implementations get it wrong, and how the three systems connect to the same browser metrics.
Series: Part 3 of 4. Start with Part 1: React Performance: Finding and Fixing Slow Components
On this page
- Images: the sizes prop changes which file the browser downloads
- The LCP image needs priority, not lazy loading
- Fonts: how next/font eliminates layout shift by default
- Caching as a latency dial, not just a freshness setting
- Three mistakes that quietly undo the optimizations
- These three systems affect the same metrics
Images: the sizes prop changes which file the browser downloads
next/image automatically generates a srcset — a list of the same image at multiple widths — so the browser can pick the right one for the device. But the browser needs a hint about how wide the image will actually be on the screen. Without that hint, it defaults to assuming the image takes the full viewport width.
That assumption is wrong for most images. A product thumbnail in a four-column grid is 25vw wide, not 100vw. Without the sizes prop, the browser downloads a 1440px file for a grid image that will only render at 360px. The sizes prop fixes this:
// No sizes prop: browser assumes 100vw, downloads the largest srcset entry
<Image src="/product.jpg" width={400} height={400} alt="Product" />
// With sizes: browser picks the right srcset entry for the layout
<Image
src="/product.jpg"
width={400}
height={400}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
alt="Product"
/>
The value is a CSS media query string. Read it right to left: at large screens, this image takes 25% of the viewport. Below 1200px, it takes 50%. Below 768px, it goes full width. Next.js uses this to choose which width from the srcset to suggest.
One more pattern worth knowing: placeholder="blur". For statically imported images, blurDataURL is generated automatically at build time. For remote images, you have to supply a tiny base64-encoded version yourself. The blur placeholder shows something while the image loads, which matters more for perceived performance than actual load time — but it’s a one-liner for local images:
import productImage from '/public/product.jpg'
// Static import: blurDataURL is generated automatically
<Image
src={productImage}
alt="Product"
sizes="(max-width: 768px) 100vw, 25vw"
placeholder="blur"
/>
The LCP image needs priority, not lazy loading
The default loading behaviour for next/image is lazy. The browser defers loading the image until it gets close to the viewport. For below-the-fold images, that’s correct — there’s no reason to download content the user hasn’t scrolled to yet.
The hero image is different. It’s on screen immediately. Setting it to lazy means the browser discovers it in the HTML, then waits before fetching it — directly delaying the Largest Contentful Paint (LCP).
The priority prop tells Next.js to inject a <link rel="preload"> in the <head>, so the browser starts fetching the image before it even parses the <img> tag in the body:
// Hero image: set priority so the browser fetches it immediately
<Image
src="/hero.jpg"
width={1600}
height={900}
alt="Dashboard overview"
priority
/>
The mistake I see most often is setting priority on every above-the-fold image. When multiple images are preloaded, they compete for bandwidth during the most critical phase of page load — the moment the browser is also parsing JavaScript and CSS. Set priority on at most one image per page: the one that is, or is closest to, the LCP element. If you’re unsure which it is, run Lighthouse in Chrome DevTools and look at the LCP element annotation.
Check this before moving on
- Your hero/LCP image has
priorityset and noloadingprop - Below-the-fold images have neither
prioritynorloading="eager" - Every
<Image>withfillor a responsive layout has asizesprop
Fonts: how next/font eliminates layout shift by default
When you load a font with a plain @font-face declaration, the browser shows text in a fallback system font until the custom font downloads. When the custom font arrives and swaps in, the text shifts position. That shift is what shows up as Cumulative Layout Shift (CLS) in Core Web Vitals.
next/font addresses this with two defaults most developers don’t notice:
1. Self-hosting. Whether you use next/font/google or next/font/local, the font files are served from your own domain. There’s no DNS lookup to an external server, no round-trip to fetch the font file over a separate connection. The font file arrives earlier.
2. Metric-adjusted fallback. The adjustFontFallback option defaults to true. It instructs Next.js to generate a modified version of a system font — adjusted to have the same character width, line-height, and spacing metrics as your custom font. When the browser swaps from the fallback to the custom font, the text glyphs are already the right size, so nothing shifts.
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'], // load only the characters you need
display: 'swap', // show text in fallback while loading
// adjustFontFallback: true is the default — keeps CLS near zero
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}
Local fonts use the same pattern:
import localFont from 'next/font/local'
const myFont = localFont({
src: './fonts/MyFont-Regular.woff2',
display: 'swap',
})
Subsets matter. next/font/google automatically subsets the font file, but only for the character sets you declare. subsets: ['latin'] is correct for English content. If you declare subsets: ['latin', 'cyrillic', 'greek'] for a site that only shows English text, you’re downloading font data the browser will never use.
Caching as a latency dial, not just a freshness setting
The data fetching post covers the caching API in full: fetch options, React.cache, unstable_cache, revalidatePath, and revalidateTag. This section is about how those choices affect measured page speed — specifically, the difference between a cached route and a dynamically rendered one.
A route that serves from the Full Route Cache responds with pre-built HTML. On a CDN, that can be under 10ms TTFB. A dynamically rendered route re-runs every Server Component on every request, queries the database, and returns HTML after however long that takes — typically 50ms to several hundred milliseconds depending on your infrastructure. Those are not the same user experience.
The cache strategy that gives you the best performance while keeping content current depends on how often your data actually changes:
// Time-based: content that changes on a schedule (e.g. blog listing updates hourly)
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 },
})
// Tag-based: content that changes on user action (publish, edit, delete)
// Cache indefinitely; call revalidateTag('posts') in a Server Action after a write
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'], cache: 'force-cache' },
})
The tag-based pattern gives you the maximum cache TTL — the page is served from the Full Route Cache until a mutation triggers revalidateTag. That combination means fast TTFB and fresh content; the only cost is calling revalidateTag from the right place in your write path.
One detail worth knowing: next/image has its own caching system, separate from data caching. When Next.js processes an uploaded image through the optimisation API, it stores the converted WebP or AVIF output on disk. The minimumCacheTTL option in next.config.js controls how long that cached output is kept (default: 4 hours). For images that don’t change, raising this to a day or more reduces the number of re-encode operations.
Three mistakes that quietly undo the optimizations
1. Using fill without a sizes prop. The fill prop makes the image expand to fill its parent container — useful for hero sections and cards. But the browser still needs to know how wide that container is relative to the viewport. Without sizes, it assumes 100vw even if the container is a narrow sidebar or a grid cell:
// fill without sizes: browser assumes the container is 100vw wide
<Image src="/thumbnail.jpg" fill alt="..." />
// fill with sizes: browser knows the container is 33vw in a three-column grid
<Image
src="/thumbnail.jpg"
fill
sizes="(max-width: 768px) 100vw, 33vw"
alt="..."
/>
2. Loading the font in a page component instead of the root layout. When next/font is called in app/layout.tsx, the font preloads on every route in the application. When it’s called in app/dashboard/page.tsx, it only preloads on that specific page. Other pages flash the unstyled system font before the custom font loads. Load fonts you want globally in the root layout.
3. Using cache: 'no-store' on fetches that don’t need it. This option tells Next.js the route must be dynamically rendered — no caching allowed. It’s the right call for personalized data (user dashboard, shopping cart, session-specific content). It’s the wrong call for a public blog listing or a products page that updates a few times a day. Every request hits the database, TTFB stays high, and the Full Route Cache is bypassed for the entire route.
These three systems affect the same metrics
LCP, CLS, and TTFB aren’t separate problems. They’re three symptoms that can each trace back to images, fonts, or caching — sometimes all three at once.
A slow LCP often comes from either a missing priority prop on the hero image or a high TTFB from a dynamically rendered route that could be cached. CLS almost always traces back to images without declared dimensions or fonts without metric-adjusted fallbacks. TTFB is directly controlled by caching: a cache hit returns pre-built HTML; a cache miss re-renders the page from scratch.
The practical check: run Lighthouse in Chrome DevTools and look at which of the three Core Web Vitals metrics is worst. If LCP is the problem, check whether the hero image has priority and whether the page’s TTFB is fast. If CLS is the problem, check that every <Image> has both width and height declared, and verify the font is loaded via next/font. If TTFB is the problem, look at whether the route is being served from cache or rendered on every request.
None of these changes require a redesign. Most are a prop added here, an import moved there, or a revalidate number chosen with intent rather than left at the default.

Image sizing, font metrics, and cache layers — three systems that each affect a different Core Web Vital.