Performance & Web Vitals
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were
checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and
TypeScript 5.1 or later. It covers the App Router and points out the Pages Router equivalent wherever it differs:
useReportWebVitals and next/dynamic exist in both, the bundle analyzer and the memory options are
router-independent, and Instant Insights exist only for the App Router. Caching matters in one place: Instant Insights
need the opt-in Cache Components model (cacheComponents: true), and that section says so; every other example
runs unchanged in the previous model, which is what the examples use unless stated otherwise. Every example was
built with next build and run with next start (Next.js 16.3.8, Node.js 24.16, macOS) in a scratch copy of the
Bookshelf catalogue that the rest of this section grows, measured in headless Chrome on localhost, and each one is
followed by the official page it derives from.
The numbers on this page come from a laptop, a local server and synthetic workloads (for example, one millisecond of busy work per list row). They show the direction and the order of magnitude of each technique, not what your users will see; field data is the only real measurement, which is why the page starts with how to collect it.
A Performance Map
Next.js already does a lot without being asked: Server Components send no JavaScript for their own code, routes are
code-split by segment, <Link> prefetches, pages are prerendered, and results are cached. The production checklist
calls these the automatic optimizations. What remains for you is to measure real users, then fix the specific
metric that is bad. The diagram maps each Core Web Vital to the tools this page covers.
Performance work is a loop: measure, change one thing, measure again. The sections below follow that order.
Core Web Vitals
The Core Web Vitals are the three user-centred metrics that Google uses to describe the experience of a page. Their "good" thresholds, from the web.dev vitals page, are assessed at the 75th percentile of page loads, segmented by mobile and desktop.
| Metric | Measures | Good when | Typical Next.js levers |
|---|---|---|---|
LCP (Largest Contentful Paint) |
Loading: when the largest visible element is painted |
2.5 seconds or less after the page starts loading |
Static or cached shell, streaming, |
INP (Interaction to Next Paint) |
Responsiveness: the latency of the clicks, taps and key presses during the whole visit |
200 milliseconds or less |
React Compiler, |
CLS (Cumulative Layout Shift) |
Visual stability: how much visible content moves unexpectedly |
0.1 or less |
Dimensions on |
INP replaced FID (First Input Delay) as the responsiveness Core Web Vital in 2024. FID only timed the delay before
the browser started handling the first interaction; INP considers all of them. Next.js 16.3.8’s
useReportWebVitals still lists FID next to INP, TTFB, FCP, LCP and CLS, so FID values keep arriving in your
callback. Ignore them and alert on INP.
LCP: Preload the Element That Is the LCP
The largest element is often a hero image. If it is discovered late (after CSS or JavaScript), LCP suffers. In
Next.js 16 the preload prop of next/image inserts a <link rel="preload"> in the <head>; it replaces the
deprecated priority prop.
import Image from 'next/image'
export default function Page() {
return (
<main>
<h1>Dune</h1>
{/* The hero image is the LCP element: preload it and do not lazy-load it. */}
<Image
src="/img/cover.png"
alt="Cover of Dune"
width={800}
height={400}
preload
sizes="(min-width: 800px) 800px, 100vw"
/>
</main>
)
}
curl on the built page shows the effect in the first bytes of the HTML: a <link rel="preload" as="image"> with
the same responsive imageSrcSet and imageSizes as the <img>, so the browser starts the right download before it
parses the body.
<link rel="preload" as="image" imageSrcSet="/_next/image?url=%2Fimg%2Fcover.png&w=640&q=75 640w, ... 3840w" imageSizes="(min-width: 800px) 800px, 100vw"/>
<img alt="Cover of Dune" width="800" height="400" decoding="async" data-nimg="1" sizes="(min-width: 800px) 800px, 100vw" srcSet="..." src="/_next/image?url=%2Fimg%2Fcover.png&w=3840&q=75"/>
The official page recommends preload only when exactly one image is the LCP element at every viewport size; with
several candidate images use loading="eager" or fetchPriority="high" instead. The full next/image story
(sizes, remotePatterns, loaders) belongs to the images page of this section.
Source: next/image: preload, web.dev: LCP.
CLS: Reserve Space Before the Bytes Arrive
A layout shift happens when something above existing content gets its size late. The classic cause is an image
without dimensions. The two routes below serve the same image through a deliberately slow endpoint (700 ms); only the
second one declares width, height and height: auto, which is the aspect-ratio reservation next/image emits
for you.
// app/shift/page.tsx - no dimensions: the browser reserves 0 px until the bytes arrive
export default function Page() {
return (
<main>
<h1>Dune</h1>
<img src="/api/slow-image" alt="Cover" />
<section style={{ minHeight: 500 }}>
<p>Reviews and the buy button sit below the image and get pushed down.</p>
</section>
</main>
)
}
// app/stable/page.tsx - dimensions declared up front
export default function Page() {
return (
<main>
<h1>Dune</h1>
<img
src="/api/slow-image"
alt="Cover"
width={800}
height={400}
style={{ maxWidth: '100%', height: 'auto' }}
/>
<section style={{ minHeight: 500 }}>
<p>Reviews and the buy button stay where they are.</p>
</section>
</main>
)
}
Reported by useReportWebVitals (next section) in headless Chrome with a 900 x 700 viewport: CLS 0.43, rated
poor, for the first route and CLS 0, rated good, for the second. The same reasoning applies to fonts (next/font
self-hosts the font files and removes the external request, which reduces layout shift), to embeds and ads (reserve a
box), and to skeletons, which must have the size of the content they stand in for.
INP: Keep the Main Thread Free After an Interaction
INP is the time from an interaction until the browser can paint the next frame. A handler that triggers an expensive
render makes it long. The example below renders 300 rows that each cost about 1 ms to render, and the input that
filters them is updated synchronously (BlockingSearch) or through useDeferredValue (DeferredSearch), which lets
React finish the urgent update (the input) first and render the rows afterwards, interruptibly.
'use client'
import { memo, useDeferredValue, useState } from 'react'
function SlowRow({ text }: { text: string }) {
const end = performance.now() + 1 // simulate 1 ms of rendering work per row
while (performance.now() < end) {}
return <li>{text}</li>
}
const Rows = memo(function Rows({ query }: { query: string }) {
const rows = Array.from({ length: 300 }, (_, i) => `Book ${i} ${query}`)
return (
<ul>
{rows.map((r) => (
<SlowRow key={r} text={r} />
))}
</ul>
)
})
export function BlockingSearch() {
const [query, setQuery] = useState('')
return (
<>
<input aria-label="blocking" value={query} onChange={(e) => setQuery(e.target.value)} />
<Rows query={query} />
</>
)
}
export function DeferredSearch() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input aria-label="deferred" value={query} onChange={(e) => setQuery(e.target.value)} />
<Rows query={deferredQuery} />
</>
)
}
Typing four characters with Playwright and reading the INP reported by the page gave 320 to 336 ms
(needs-improvement) for BlockingSearch and 40 ms (good) for DeferredSearch. The memo wrapper matters: without
it, the urgent render re-renders Rows with the old deferred value and the benefit disappears. The next section shows
how the React Compiler removes the need to write memo by hand. The hooks themselves are explained in
React: Performance and Concurrent Features.
Source: React: useDeferredValue, web.dev: INP.
Measuring: useReportWebVitals
Lab tools (Lighthouse, the browser performance panel) run one simulated load. The production checklist asks you to run
Lighthouse in an incognito window against next build plus next start and to pair it with field data, the
Core Web Vitals of real visits. Next.js gives you the field data through the useReportWebVitals hook from
next/web-vitals.
The hook needs 'use client', so put it in a small component and import that component from the root layout; this
keeps the client boundary confined to a component that renders nothing. The callback must have a stable identity
(a module-level function): new function references passed to the hook are called with the metrics collected so far, so
an inline arrow function re-registered on every render reports duplicates.
// app/_components/web-vitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]
// Module-level function: the reference never changes, so the hook does not
// re-register it and metrics are not reported twice.
function sendToAnalytics(metric: Metric) {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
path: location.pathname,
})
if (!navigator.sendBeacon('/api/vitals', new Blob([body], { type: 'application/json' }))) {
fetch('/api/vitals', { body, method: 'POST', keepalive: true })
}
}
export function WebVitals() {
useReportWebVitals(sendToAnalytics)
return null
}
// app/layout.tsx (inside <body>)
import { WebVitals } from '@/app/_components/web-vitals'
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
)
}
navigator.sendBeacon is used because it survives the page being hidden or unloaded, which is exactly when CLS, LCP
and INP are finalized. On the receiving side, treat the body as untrusted input: validate it and never log or store
more than you need.
// app/api/vitals/route.ts
import { z } from 'zod'
const Metric = z.object({
id: z.string().max(100),
name: z.enum(['TTFB', 'FCP', 'LCP', 'FID', 'CLS', 'INP']),
value: z.number().finite(),
rating: z.enum(['good', 'needs-improvement', 'poor']),
navigationType: z.string().max(40).optional(),
path: z.string().max(200),
})
export async function POST(request: Request) {
const parsed = Metric.safeParse(await request.json().catch(() => null))
if (!parsed.success) return new Response(null, { status: 400 })
// Replace with your analytics client; here the line is just logged.
console.log('[vitals]', JSON.stringify(parsed.data))
return new Response(null, { status: 204 })
}
After next build and next start, loading the home page in headless Chrome and navigating away produced these lines
in the server log (values shortened; the browser sends one beacon per metric as each one is finalized):
[vitals] {"id":"v4-1791043958254-7455041829785","name":"FCP","value":40,"rating":"good","navigationType":"navigate","path":"/en"}
[vitals] {"id":"v4-1791043958254-1802385338956","name":"TTFB","value":6.4,"rating":"good","navigationType":"navigate","path":"/en"}
[vitals] {"id":"v4-1791043958254-5422671562081","name":"LCP","value":40,"rating":"good","navigationType":"navigate","path":"/en"}
[vitals] {"id":"v4-1791043958255-4863743834602","name":"CLS","value":0,"rating":"good","navigationType":"navigate","path":"/en"}
A request with a malformed body gets 400. The id is unique per page load, which lets an analytics back end group
the metrics of one visit and, for example, compute percentiles itself.
| Property | Meaning |
|---|---|
|
Unique identifier of the metric within the current page load. |
|
|
|
The measured value (usually milliseconds; CLS is unitless) and the change since the previous report of the same metric. |
|
|
|
|
|
The underlying |
Measure with the production build. next dev compiles on demand and runs unminified React with extra checks, so its
numbers do not describe production. If you use Google Analytics, the id lets you build metric distributions
manually; CLS needs scaling to an integer there.
Client Instrumentation
The instrumentation-client.js|ts file at the project root (next to app/) runs in the browser before the
application’s frontend code starts. It is the place to initialize an analytics or error-tracking SDK as early as
possible. A one-line file that logged performance.now() printed about 290 ms in headless Chrome and left a
performance.mark that was visible to the page, which confirms that it runs before the app’s own code.
// instrumentation-client.ts
// Runs in the browser before the app's frontend code (and hydration) starts.
performance.mark('app-boot')
console.log('[instrumentation-client] boot at', Math.round(performance.now()), 'ms')
Keep this file tiny: whatever it imports delays hydration.
Source: useReportWebVitals, Analytics, instrumentation-client.
The Pages Router
In the Pages Router the hook goes in pages/_app. Built and run in a Pages Router app, the page below reported
FCP, TTFB, LCP, FID, INP and CLS.
// pages/_app.tsx
import type { AppProps } from 'next/app'
import { useReportWebVitals } from 'next/web-vitals'
function reportMetric(metric: Parameters<Parameters<typeof useReportWebVitals>[0]>[0]) {
const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating })
navigator.sendBeacon('/api/vitals', new Blob([body], { type: 'application/json' }))
}
export default function MyApp({ Component, pageProps }: AppProps) {
useReportWebVitals(reportMetric)
return <Component {...pageProps} />
}
The Pages Router documentation also describes three custom metrics that measure how long Next.js itself takes:
Next.js-hydration, Next.js-route-change-to-render and Next.js-render. They are delivered to the older
reportWebVitals function exported from pages/_app. In the same app, replacing the hook with that export produced
exactly those three metrics (for example Next.js-hydration at 3.6 ms) and none of the Core Web Vitals, so use the hook
for the Core Web Vitals and the export only if you want the framework’s own timings.
// pages/_app.tsx - the legacy export, which receives the Next.js-* custom metrics
import type { AppProps, NextWebVitalsMetric } from 'next/app'
export function reportWebVitals(metric: NextWebVitalsMetric) {
navigator.sendBeacon(
'/api/vitals',
new Blob([JSON.stringify({ name: metric.name, value: metric.value })], { type: 'application/json' }),
)
}
export default function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
The React Compiler
The React Compiler is a build-time tool that memoizes components and values automatically, which reduces the need for
useMemo, useCallback and memo. Support in Next.js became stable in 16.0 (after the compiler’s 1.0 release): the
reactCompiler option moved out of experimental, but it is not enabled by default, because it makes compilation
slower: the compiler runs as a Babel plugin, and Next.js uses an SWC optimization to hand it only the files that
contain JSX or hooks.
npm install -D babel-plugin-react-compiler
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactCompiler: true,
}
export default nextConfig
The build with the option on compiles the project, and the client chunks contain the compiler’s memo-cache code
(searching .next/static for memo_cache_sentinel finds it; babel-plugin-react-compiler was 1.0.0 in this test).
The measurement below uses the INP page from earlier, with one more variant, PlainSearch: it defers the query like
DeferredSearch, but Rows is a plain function without memo.
'use client'
import { useDeferredValue, useState } from 'react'
function SlowRow({ text }: { text: string }) {
const end = performance.now() + 1 // 1 ms of work per row
while (performance.now() < end) {}
return <li>{text}</li>
}
// No memo(), no useMemo(): the component is written the plain way.
function Rows({ query }: { query: string }) {
const rows = Array.from({ length: 300 }, (_, i) => `Book ${i} ${query}`)
return (
<ul>
{rows.map((r) => (
<SlowRow key={r} text={r} />
))}
</ul>
)
}
export function PlainSearch() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input aria-label="plain" value={query} onChange={(e) => setQuery(e.target.value)} />
<Rows query={deferredQuery} />
</>
)
}
| Variant | reactCompiler: false |
reactCompiler: true |
|---|---|---|
|
328 ms, |
32 ms, |
|
40 ms, |
32 ms, |
|
336 ms, |
328 ms, |
The compiler memoized the <Rows /> element, so the urgent render reused it, exactly as the hand-written memo does.
It does not make a blocking update cheap: BlockingSearch still needs deferral, a smaller list or virtualization.
The compiler is a multiplier on code that follows the Rules of React, not a substitute for design; code that breaks
the rules is skipped or can misbehave, which is why the React documentation has an incremental adoption guide.
Opt-In by Annotation
To adopt the compiler component by component, switch it to annotation mode and mark what you want with the "use memo"
directive ("use no memo" opts a component or hook out).
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactCompiler: {
compilationMode: 'annotation',
},
}
export default nextConfig
function Rows({ query }: { query: string }) {
'use memo' // opt in (compilationMode: 'annotation')
// ...
}
With annotation mode and no directive the PlainSearch INP stayed at 320 ms; with 'use memo' on Rows and
PlainSearch it was rated good, so the mode does what it says.
The Rust Compiler (Experimental)
Next.js 16.3 adds an experimental Rust port of the compiler that runs inside Turbopack instead of through Babel. It
is opt-in on top of reactCompiler and not recommended for production yet. The release blog reports 34 percent (cold)
and 46 percent (warm) less time from next dev to a ready page on a large application, with smaller gains if you
still run Babel for other transforms.
// next.config.ts (experimental)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactCompiler: true,
experimental: {
turbopackRustReactCompiler: true,
},
}
export default nextConfig
Source: reactCompiler, turbopackRustReactCompiler, React: React Compiler, React: Incremental adoption.
The Pages Router documents the same reactCompiler option; the compiler is a build transform over your components, not
a router feature.
Lazy Loading
Lazy loading defers JavaScript until it is needed, which shortens the initial download and parse and therefore helps
LCP and INP. It applies to Client Components and to libraries: Server Components are already code-split by route
segment and stream. Next.js offers two ways: next/dynamic (a composite of React.lazy() and <Suspense> that works
in both routers, which eases incremental migration) and React.lazy() with <Suspense> directly. Plain dynamic
import() covers libraries.
The example loads a dialog component only when it is first rendered and the fuse.js search library only on the first
keystroke.
// app/lazy/review-dialog.tsx
'use client'
export default function ReviewDialog({ onClose }: { onClose: () => void }) {
return (
<dialog open aria-label="Write a review">
<p>Review form (imagine a rich-text editor here)</p>
<button onClick={onClose}>Close</button>
</dialog>
)
}
// app/lazy/lazy-shelf.tsx
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
// Separate client chunk, downloaded only the first time the dialog is rendered.
const ReviewDialog = dynamic(() => import('./review-dialog'), {
loading: () => <p>Loading editor...</p>,
})
const titles = ['Dune', 'Neuromancer', 'Solaris']
export function LazyShelf() {
const [open, setOpen] = useState(false)
const [results, setResults] = useState<string[]>([])
return (
<>
<input
placeholder="Search"
onChange={async (e) => {
const { value } = e.currentTarget
// fuse.js is fetched on first keystroke, not with the page.
const Fuse = (await import('fuse.js')).default
setResults(new Fuse(titles).search(value).map((r) => r.item))
}}
/>
<p data-testid="results">{results.join(', ')}</p>
<button onClick={() => setOpen(true)}>Write a review</button>
{open && <ReviewDialog onClose={() => setOpen(false)} />}
</>
)
}
Recording the script requests of the built page in headless Chrome: the initial load fetched nine scripts (467,441
bytes decoded, 139,466 bytes on the wire, which includes the framework and the app shell); typing sol fetched one
more script of 26,057 bytes (the fuse.js chunk) and the search answered Solaris; clicking the button fetched a
433-byte chunk and showed the dialog. The dialog chunk is tiny here, but the mechanism is what counts: its size
would be whatever the real editor weighs.
By default a dynamically imported Client Component is still prerendered on the server (SSR) and only its JavaScript
is deferred. To skip the server render, pass ssr: false, but only from a Client Component.
'use client'
import dynamic from 'next/dynamic'
const Map = dynamic(() => import('./map'), { ssr: false }) // browser-only widget
In a Server Component the same option fails the build. This is the output of next build for a Server Component page
that did it:
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.
If a Server Component dynamically imports a Server Component, only the Client Components below it are lazy-loaded.
If it dynamically imports a Client Component, the official page notes that automatic code splitting is currently not
supported; import it from a Client Component instead, as in the example. The other options are a loading component
(shown while the chunk loads) and importing a named export by returning it from the promise:
dynamic) ⇒ import('../components/hello').then((mod) ⇒ mod.Hello.
Bundler magic comments control how a dynamic import(), require() or new Worker() is handled:
webpackIgnore: true or turbopackIgnore: true leave the expression untouched for runtime resolution, and
turbopackOptional: true suppresses the build error for a module that may not exist. They do not work on static
import statements, and webpackOptional is not supported.
Move Work to the Server
The largest client bundles often come from libraries that only turn data into markup: syntax highlighting, charts,
markdown. If the result does not depend on browser APIs or interaction, render it in a Server Component and send only
the output. The experiment uses marked (18.0.14) on two routes with the same input.
// app/markdown/client/client-markdown.tsx
'use client'
import { marked } from 'marked'
// The parser is shipped to every visitor although the output is static.
export function ClientMarkdown({ source }: { source: string }) {
return <article dangerouslySetInnerHTML={{ __html: marked.parse(source) as string }} />
}
// app/markdown/server/page.tsx
import { marked } from 'marked'
export default async function Page() {
// Runs on the server only: the marked package never reaches the browser.
const html = await marked.parse('# Dune\n\nA *desert* planet and a **spice**.')
return <article dangerouslySetInnerHTML={{ __html: html }} />
}
The client route loaded 507,024 bytes of JavaScript (decoded, 150,977 bytes encoded); the server route loaded 462,414
bytes (137,563 encoded), about 45 kB less decoded, and curl shows the finished <article><h1>Dune</h1>… in the
HTML. Both pages are static here, so nothing is parsed at request time either. The input of dangerouslySetInnerHTML
is trusted in this example; if the markdown comes from users, sanitize the HTML first.
Source: Package bundling: Heavy client workloads, Server and Client Components: Reducing JS bundle size. The decision rules for the boundary are in Server and Client Components.
Bundle Analysis
You cannot reduce what you cannot see. Next.js has two analyzers, and which one you use depends on the bundler.
| Tool | Bundler | Status |
|---|---|---|
|
Turbopack (the default for dev and build since Next.js 16) |
Experimental, available since 16.1. It stays labelled experimental in 16.3.8. |
|
Webpack only |
A plugin enabled by an environment variable such as |
next experimental-analyze
The command runs an analysis of a production build with Turbopack (it does not produce an application build), then starts a local server (default port 4000) with an interactive treemap. You can filter by route, by client or server environment and by type (JavaScript, CSS, JSON), search by file, and click a module to see its size and the import chain that explains why it is there, across server-to-client component boundaries and dynamic imports.
npx next experimental-analyze
To save the analysis for a pull request or to compare before and after, write it to disk instead:
npx next experimental-analyze --output
cp -r .next/diagnostics/analyze ./analyze-before-refactor
Run on the scratch app, the output command printed the following and wrote about 3.3 MB of static files, with an
index.html, per-route data and the build’s chunk information:
Analyzing a production build...
✓ Analyze completed in 1886ms. Results written to .../.next/diagnostics/analyze.
To explore the analyze results interactively, run `next experimental-analyze` without `--output`.
| Option | Effect |
|---|---|
|
The project to analyze (default: the current directory). |
|
Write the files to |
|
Port for the analyzer UI (default 4000, or the |
|
Disable name mangling; for debugging only. |
|
Enable production profiling for React; may affect performance. |
@next/bundle-analyzer (Webpack)
If you build with next build --webpack, the older plugin produces a report of every package in the client and server
bundles. It has to be installed and wired into the configuration.
npm install @next/bundle-analyzer
ANALYZE=true npm run build
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {}
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)
What to Look For
Once you find a large module, the fix depends on why it is there:
-
A package with hundreds of exports (icons, utilities):
optimizePackageImports, below. -
Work that only produces markup: a Server Component, as in the previous section.
-
A widget that is not needed on first paint:
next/dynamicorimport(). -
A server-only dependency that should not be bundled: list it in
serverExternalPackages. -
A dependency you can drop: fewer dependencies also reduce memory usage.
The official guide also names tools for judging a dependency before adding it: Import Cost, Package Phobia, Bundle Phobia and bundlejs.
optimizePackageImports
Importing a name from a barrel file (an index that re-exports hundreds or thousands of modules) can make the bundler
load and process the whole barrel in development and in production. experimental.optimizePackageImports tells
Next.js to resolve named imports to the individual modules instead, so you keep the convenient import syntax and only
load the modules you use.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ['icon-library'],
},
}
export default nextConfig
Next.js already optimizes a list of popular packages by default, so they do not need to be listed. The list on the
official page for 16.3.8 includes lucide-react, date-fns, lodash-es, ramda, antd, react-bootstrap,
@headlessui/react, @heroicons/react/, @mui/material, @mui/icons-material, recharts, rxjs, react-use,
@tabler/icons-react, react-icons/, effect, @effect/* and a few more; check the page for the current list.
Note that the page still labels the option experimental and not recommended for production.
To measure it, a local package icon-barrel was created with 400 one-component modules re-exported from index.js,
installed from a tarball (so it lives in node_modules), and one icon was imported from a Client Component. With
the default configuration the page loaded 481,343 bytes of decoded JavaScript; with optimizePackageImports:
['icon-barrel'] it loaded 463,388 bytes, about 18 kB less, which is roughly the baseline of an empty page. The
encoded (compressed) size did not change measurably (137,914 against 138,537 bytes), and the dev-server timings were
too noisy to conclude anything. In other words, on a small synthetic barrel the production gain is modest, because
Turbopack already removes unused exports from the output. The documented benefit, avoiding the work of loading
thousands of modules from large packages, grows with the size of the barrel; always confirm it with the analyzer
before adding a package to the list.
Memory Usage
Memory matters in two places: the machine that builds (CI runners run out of RAM, typically in the TypeScript step or while generating static pages) and the machine that serves. The memory guide lists these techniques.
| Technique | What it does | Notes |
|---|---|---|
Fewer dependencies |
Less code to bundle, type-check and keep in memory |
Use the bundle analyzer to find candidates. |
|
Prints memory reports during the build (heap use, garbage collection) and takes heap snapshots automatically near the memory limit |
Since 14.2. Slows the build. Send |
|
Writes a |
Looks for leaks and hot allocation sites. |
|
Exposes the inspector, so DevTools can take heap snapshots ( |
Heap snapshots are saved in the project root when requested with |
|
Reduces the maximum memory of Webpack, at a small cost in compile time |
Since 15.0, experimental, Webpack only. |
Webpack build worker ( |
Runs the Webpack compilation in a separate Node.js worker |
On by default since 14.1 when there is no custom Webpack configuration. Webpack only. |
Disable the Webpack cache in production builds |
Stores generated modules in memory instead of disk and memory |
Done through a custom |
|
Skips the type check in the build, which can use a lot of memory |
Only if CI already type-checks, and promote only builds whose checks passed; it can ship type errors. |
|
Generating source maps costs memory |
Next.js uses source maps by default in the prerender phase. |
|
The server stops preloading every page’s modules at startup |
Lower initial footprint; the footprint converges once all pages have been requested. |
Running the debug build on the scratch app printed a report at each phase. This is an excerpt (the numbers describe a three-page app, with Turbopack persisting its cache, and are only an illustration of the format):
⚠ Memory debugging mode is enabled. Note: This will affect performance.
- Heap snapshots will be automatically generated when the process reaches more than 70% of the memory limit and again when the process is just about to run out of memory.
- To manually generate a heap snapshot, send the process a SIGUSR2 signal: `kill -SIGUSR2 55503`
***************************************
Memory usage report at "Finished build":
- RSS: 1457.69 MB
- Heap Used: 32.92 MB
- Heap Total Allocated: 58.63 MB
- Heap Max: 4288.00 MB
- Percentage Heap Used: 0.77%
***************************************
Note the gap between the resident set size (RSS, which includes Turbopack’s native memory) and the V8 heap: the heap
is only one part of what a build uses. The --heap-prof run wrote two .heapprofile files, one for the main process
and one for a worker. Several of the options above are Webpack-only; Turbopack is the default in Next.js 16, so check
that an option applies to your bundler.
For development, Next.js 16.3 reduced next dev memory a lot: Turbopack uses up to 90 percent less memory in long
sessions, through the on-disk dev cache (introduced in 16.1) and memory eviction, both enabled by default.
Instant Insights
Instant Navigations, introduced in Next.js 16.3, aim to make client navigations feel as immediate as in a single-page
app: the browser prefetches the static or cached part of the destination (the static shell or App Shell) and shows
it at once while the remaining content streams into <Suspense> fallbacks. A navigation that waits on the server
instead feels slow after a link click. Instant Insights are the development tooling that finds
those navigations: with Cache Components enabled, every page and default segment is validated in development, and the
dev overlay reports code that would keep a navigation from being instant, with fixes.
Instant Navigations need the opt-in Cache Components model. partialPrefetching makes each visible <Link> prefetch the
destination’s shell by default; both flags are still opt-in in 16.3.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
The instant route segment config states the expectation for a segment (true to validate it, false to allow
blocking). Below are two pages, built with cacheComponents: true and partialPrefetching: true: the first awaits
uncached data outside <Suspense>, the second caches what can be cached and puts the live part in a boundary.
// app/instant-demo/slow/page.tsx
import { listBooks } from '@/lib/books'
export const instant = true
async function stockLevel() {
await new Promise((r) => setTimeout(r, 300)) // stands in for a live inventory query
return 3
}
export default async function Page() {
const books = await listBooks()
const stock = await stockLevel() // uncached, not under <Suspense>: blocks the navigation
return (
<main>
<h1>Catalogue</h1>
<p>{books.length} books, {stock} in stock</p>
</main>
)
}
// app/instant-demo/fast/page.tsx
import { Suspense } from 'react'
import { listBooks } from '@/lib/books'
export const instant = true
async function Catalogue() {
'use cache'
const books = await listBooks()
return <p>{books.length} books</p>
}
async function Stock() {
await new Promise((r) => setTimeout(r, 300)) // live data, streamed into the fallback
return <p>3 in stock</p>
}
export default function Page() {
return (
<main>
<h1>Catalogue</h1>
<Catalogue />
<Suspense fallback={<p>Checking availability...</p>}>
<Stock />
</Suspense>
</main>
)
}
With next dev, opening the slow page logged an error with the message below (the overlay shows the same text with
fix cards), and the fast page logged nothing:
Error: Route "/instant-demo/slow": Next.js encountered uncached data during prerendering.
`fetch(...)` or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] Cache the data access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
next build with Cache Components refuses the slow route outright (Next.js encountered uncached or runtime data
during prerendering), because a blocking page cannot be prerendered. After removing it, the build succeeded and the
HTML of the fast route, fetched with curl from next start, contained the cached part and the fallback in the
shell, with the live part streaming in afterwards:
<main><h1>Catalogue</h1><p>3<!-- --> books</p><!--$?--><template id="B:0"></template><p>Checking availability...</p><!--/$--></main>
A related validation message appeared when the same demo sat under a locale layout that awaited params without a
boundary: Next.js encountered URL data during prerendering or a navigation (see the instant-shell-url-data page in
the references). Reading params or searchParams in a shared layout can keep every navigation below it from being
instant, so push such reads into a <Suspense> boundary. Details of the primitives are in
Partial Prerendering and Cache Components and
Streaming and Suspense.
| Tool | Use |
|---|---|
Instant Insights (dev overlay) |
Surfaces navigations that are not instant, names the blocking component and offers fixes, including a prompt to hand to a coding agent. Automatic with Cache Components in development. |
|
|
Navigation Inspector (Next.js DevTools) |
Freezes the page at its initial loading state ("Pause on navigations") to look at the shell on a direct visit or the prefetched UI on a client navigation. Pair it with the React DevTools Suspense panel. |
|
Scopes Playwright assertions to the UI that is available immediately, so regressions fail in CI. Run it against |
Prefetching is not performed in development, so navigations feel slower in next dev than they will in production;
validation reflects what happens with next start. The scope of instant is also limited: it cannot be used in a
Client Component, and the only validation level today is a warning in development (no build-time level yet).
The Production Checklist, Performance Items
The official production checklist mixes performance, accessibility, security and SEO. The table keeps the performance items and maps them to the places in this section.
| Checklist item | What to verify | Where to read more |
|---|---|---|
Automatic optimizations |
Server Components, route-level code splitting, prefetching, prerendering and caching are on by default; opt out only on purpose. |
|
Client and Server Component boundaries |
|
|
Request-time APIs |
|
|
Layouts and |
Shared layouts give partial rendering on navigation; |
|
Data fetching |
Fetch on the server, avoid waterfalls with parallel fetching, stream with Suspense, call backend resources through Route Handlers only from Client Components, never from Server Components. |
|
Data caching |
Check what is cached and opt in deliberately; cache non- |
|
Static assets |
Serve images and files from |
|
Fonts, images and scripts |
|
This page: Core Web Vitals |
Lighthouse |
Run it in an incognito window on |
|
|
Send Core Web Vitals to your analytics. |
|
Bundle analysis |
Analyze client JavaScript, find large modules and dependencies. |
Two details of the official page are worth knowing. It still describes Partial Prerendering as experimental in a
"good to know" and recommends @next/bundle-analyzer in its bundle section, although Next.js 16 builds with Turbopack,
for which the analyzer is next experimental-analyze; treat the checklist as a list of topics and the dedicated
guides as the current instructions.
Source: Production checklist. Related pages of the same documentation: HTML and CSS: loading performance, HTML and CSS: build optimization and HTML and CSS: SEO.
What Changed Recently
| Version | Change |
|---|---|
15.0 |
|
16.0 |
|
16.1 |
|
16.3 |
Instant Navigations ( |
Also relevant: the INP metric replaced FID in Core Web Vitals in 2024, and the Next.js 14.2 and 14.1 memory flags
(--experimental-debug-memory-usage, the Webpack build worker by default) are still the documented tools.
Where the Books Are Outdated
Both requester-provided books were written for the Pages Router and predate everything on this page after Next.js 12 or 13.
-
The Packt book (Next.js 12) introduces performance through
reportWebVitalsexported from_app.js, and treats LCP, FID and CLS as the Core Web Vitals. Today the hook isuseReportWebVitalsfromnext/web-vitals(the export survives only for the threeNext.js-*custom metrics, as shown above), and INP replaced FID as the responsiveness metric in 2024. -
The Apress book (Next.js 13) analyzes bundles with
@next/bundle-analyzer, wired into a GitHub Action, and audits pages with the Lighthouse panel of Chrome. Lighthouse remains useful (it is in the production checklist), but the analyzer for the default Turbopack build is nownext experimental-analyze(16.1),@next/bundle-analyzeronly works with Webpack, and Lighthouse numbers are simulated, so complement them with the field data collected byuseReportWebVitals. -
Neither book covers the React Compiler (stable in Next.js 16),
optimizePackageImports, Server Components as a bundle-size tool, Cache Components, Instant Navigations or Instant Insights. Read them for the concepts of measuring and not for current configuration.
Common Pitfalls
-
Measuring
next dev. Usenext buildandnext start, preferably on production-like hardware and a real network profile. -
An inline callback in
useReportWebVitals. A new function reference is called with the metrics collected so far, so data is reported twice. Define the callback at module level. -
Importing the reporter in a Server Component without
'use client'. The hook fails; keep it in its own Client Component. -
Trusting an unvalidated analytics beacon. Validate the shape, limit sizes and do not store personal data.
-
ssr: falsein a Server Component. It is a build error; move the dynamic import into a Client Component. -
Lazy-loading the LCP element. The element that is largest above the fold must be preloaded or eager, not deferred.
-
preloadon several hero images. Only one image should be the LCP element at every viewport. -
Treating the React Compiler as a fix for a heavy render. It removes redundant re-renders; a render that is expensive every time still needs deferral, virtualization or less work.
-
Enabling the compiler without testing. Code that violates the Rules of React can misbehave; adopt gradually with
compilationMode: 'annotation'. -
Expecting
optimizePackageImportsto fix everything. Confirm the effect with the analyzer. -
Memory flags for the wrong bundler.
webpackMemoryOptimizationsand the Webpack cache tweaks do nothing for a Turbopack build. -
Disabling the type check to save memory and then shipping. Promote only builds whose checks passed elsewhere.
-
Evaluating instant navigations only in development. Prefetching is off in
next dev; verify withnext start. -
Reading
paramsorsearchParamsoutside a boundary in a shared layout. It blocks every navigation below it.
Related Material on This Site
-
Streaming and Suspense: shells, fallbacks and the LCP and CLS effects of boundaries.
-
Client-Side Rendering: lazy loading from the point of view of client rendering.
-
Hydration: what JavaScript the browser must run before the page responds.
-
Partial Prerendering and Cache Components: the static shell that Instant Navigations rely on.
-
Caching: what is cached and for how long.
-
React: Performance and Concurrent Features: memoization, transitions and the compiler from React’s side.
-
React: Code Splitting and Suspense:
React.lazyandSuspense.
The pages on images, fonts and scripts, styling, and metadata and SEO of this section cover the other performance
levers (next/image, next/font, next/script and the SEO side of rendering).