Performance Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Analyze With @next/bundle-analyzer: Make bundle size visible by running the analyzer in CI and in local
ANALYZE=truebuilds; remember the treemap shows uncompressed bytes, so cross-check against gzip/brotli transfer size in the Network panel before celebrating wins. - Ban Barrel File Re-Exports:
index.tsfiles withexport *silently defeat tree-shaking even on ES modules unless you set"sideEffects": falseinpackage.json; prefer deep imports or mark packages side-effect-free so the bundler can prune unused code. - Measure CWV in Production: Dev-mode Core Web Vitals are two to five times worse than production because of unminified code, React dev warnings, and source maps — always gate targets (
LCP < 2.5s,INP < 200ms,CLS < 0.1) against a production build. - CLS Accumulates Session-Wide: CLS is cumulative across the entire user session, so modals, toasts, and consent banners appearing 30 seconds in still count — reserve space for deferred UI and test layout shifts after first paint, not just during load.
- Parallelize With Sibling Suspense: Each
Suspenseboundary with its own async Server Component fetches in parallel automatically, so splitting siblings into separate boundaries gives you parallelism without writing aPromise.all— one top-level boundary collapses that back into a waterfall. - Kill N+1 With Prisma include/select: Iterating a list with
.map(async row => prisma.x.findUnique(...))issues one query per row; collapse to a single JOIN viainclude/select, and preferPromise.allSettledwhen one partial rejection should not discard every other result. - Always Provide sizes With next/image: Without
sizes, the browser assumes100vwand downloads the largest srcset variant (often 3840px) for a thumbnail; pass a breakpoint-aware string matching your layout and addpriorityonly to the one LCP image so bandwidth is spent where it matters. - Self-Host Fonts With next/font: Replace Google Fonts
<link>tags withnext/font/google(ornext/font/local) so fonts ship from the same origin with immutable cache headers and auto-generated fallback metrics that eliminate FOUT and CLS; variable fonts save about 100 KB over loading multiple weight files. - Memoize After Profiling: Reach for
useMemo,useCallback, andReact.memoonly after the profiler shows a real cost; prematurely memoizing cheap components adds comparison overhead that frequently exceeds the work it saves. - memo Is Useless With children: A component receiving
children(or an inline object/function prop) rebuilds that value on every parent render, soReact.memonever shortcuts — either hoist the children, use the children-pattern state wrapper, or accept that memo won't help there. - Clean Up Every Effect Allocation: Any
useEffectthat starts a fetch, adds a listener, sets a timer, opens a WebSocket, or initializes a chart library must return a cleanup; Strict Mode's double-mount exposes missing cleanup rather than causing it, and leaked intervals/listeners retain the entire component state via closure. - Cancel Fetches With AbortController: Wire each effect-initiated fetch to an
AbortControllerand callabort()in the cleanup, so dependency changes or unmounts don't leave stale responses overwriting newer state or blocking GC of large JSON payloads. - Know the Four Cache Layers: Next.js has Request Memoization (per render), Data Cache (cross-request fetch), Full Route Cache (prerendered HTML), and Router Cache (client in-memory); any
cookies()/headers()orcache: "no-store"call disables the Full Route Cache for the whole route, so isolate that code in its own Suspense child. - Cache DB Calls With unstable_cache: The Data Cache only wraps the Fetch API, so Prisma/Drizzle calls bypass it — wrap them in
unstable_cache(fn, keyParts, { tags, revalidate })with unique descriptive key parts, and rememberrevalidatePath("/x")does not touch child routes unless you pass"layout". - Run Lighthouse With Median: Single Lighthouse runs vary by 10-15 points on shared CI infra, so run at least three times (
numberOfRuns: 3) and compare the median; set CI thresholds lower than your local production numbers to avoid flaky failures. - Profile Production Builds Only: React DevTools Profiler in dev is 2-5x slower than production because of dev-only warnings and reconciler paths; build with
NODE_ENV=production, usereact-dom/profiling, close the Components tab during recording, and compareactualDurationvsbaseDurationto see if memoization is actually paying off. - Colocate State and Use the Children Pattern: Move state into the smallest component that actually uses it and pass the static subtree as
childrento a state-owning wrapper; children are created in the parent's scope, so the stateful wrapper can re-render without re-rendering the static tree — nomemoneeded. - Split Contexts by Concern: A single context carrying both state and dispatch re-renders every consumer on any change; split into separate contexts (state vs actions) or use Zustand/
useSyncExternalStoreso fine-grained subscribers only re-render when their specific slice moves. - Let the React Compiler Memoize: Enabling
experimental.reactCompiler: true(plusbabel-plugin-react-compiler) inserts finer-grained memoization than hand-writtenuseMemo/useCallback/memo— adopt the compiler before ripping out manual memoization so you never leave a gap where nothing is memoized. - Keep Render Pure for the Compiler: The compiler assumes render is deterministic and side-effect-free, so logging,
Date.now()/Math.random()during render, or reads from mutable globals either stop firing or cache stale — useuseSyncExternalStorefor external mutable sources. - Push "use client" to the Leaves: Adding
"use client"at a layout contaminates the entire subtree and adds 100+ KB of hydration JS; keep the directive at the smallest interactive leaf and pass Server Components throughchildreninto Client Component wrappers like<ThemeProvider>. - Pass Serializable Props Across the Boundary: Dates, Maps, Sets, class instances, and callbacks cannot cross the Server→Client boundary cleanly — convert dates to ISO strings and replace callbacks with Server Actions; also never import
fs,prisma, or other server-only modules inside a"use client"file. - Select With useShallow in Zustand:
useStore(s => s)subscribes to the entire store, anduseStore(s => ({ a: s.a, b: s.b }))creates a fresh object each update causing spurious re-renders; wrap inuseShallowfromzustand/react/shallowto compare properties instead of identity. - Stream With Granular Suspense: Place one Suspense boundary per logical UI section with a skeleton that matches the final content's dimensions (to keep CLS at zero), and pair each boundary with an error boundary so one failing section does not crash the whole page — avoid so many tiny boundaries that the UI "pops in" incoherently.
- Enforce Bundle Budgets in CI: Add bundle-size budgets to the build (Webpack
performance.hints: "error", size-limit, or Next.js'sanalyzeoutput diffed against main) so regressions fail the build instead of silently shipping; pair that with a Lighthouse CI check so both bytes and user-perceived metrics gate merges.