React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

best-practicessummaryreact-performance

Performance Best Practices

A condensed summary of the 25 most important best practices drawn from every page in this section.

  1. Analyze With @next/bundle-analyzer: Make bundle size visible by running the analyzer in CI and in local ANALYZE=true builds; remember the treemap shows uncompressed bytes, so cross-check against gzip/brotli transfer size in the Network panel before celebrating wins.
  2. Ban Barrel File Re-Exports: index.ts files with export * silently defeat tree-shaking even on ES modules unless you set "sideEffects": false in package.json; prefer deep imports or mark packages side-effect-free so the bundler can prune unused code.
  3. 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.
  4. 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.
  5. Parallelize With Sibling Suspense: Each Suspense boundary with its own async Server Component fetches in parallel automatically, so splitting siblings into separate boundaries gives you parallelism without writing a Promise.all — one top-level boundary collapses that back into a waterfall.
  6. 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 via include/select, and prefer Promise.allSettled when one partial rejection should not discard every other result.
  7. Always Provide sizes With next/image: Without sizes, the browser assumes 100vw and downloads the largest srcset variant (often 3840px) for a thumbnail; pass a breakpoint-aware string matching your layout and add priority only to the one LCP image so bandwidth is spent where it matters.
  8. Self-Host Fonts With next/font: Replace Google Fonts <link> tags with next/font/google (or next/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.
  9. Memoize After Profiling: Reach for useMemo, useCallback, and React.memo only after the profiler shows a real cost; prematurely memoizing cheap components adds comparison overhead that frequently exceeds the work it saves.
  10. memo Is Useless With children: A component receiving children (or an inline object/function prop) rebuilds that value on every parent render, so React.memo never shortcuts — either hoist the children, use the children-pattern state wrapper, or accept that memo won't help there.
  11. Clean Up Every Effect Allocation: Any useEffect that 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.
  12. Cancel Fetches With AbortController: Wire each effect-initiated fetch to an AbortController and call abort() in the cleanup, so dependency changes or unmounts don't leave stale responses overwriting newer state or blocking GC of large JSON payloads.
  13. 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() or cache: "no-store" call disables the Full Route Cache for the whole route, so isolate that code in its own Suspense child.
  14. 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 remember revalidatePath("/x") does not touch child routes unless you pass "layout".
  15. 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.
  16. 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, use react-dom/profiling, close the Components tab during recording, and compare actualDuration vs baseDuration to see if memoization is actually paying off.
  17. Colocate State and Use the Children Pattern: Move state into the smallest component that actually uses it and pass the static subtree as children to 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 — no memo needed.
  18. 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/useSyncExternalStore so fine-grained subscribers only re-render when their specific slice moves.
  19. Let the React Compiler Memoize: Enabling experimental.reactCompiler: true (plus babel-plugin-react-compiler) inserts finer-grained memoization than hand-written useMemo/useCallback/memo — adopt the compiler before ripping out manual memoization so you never leave a gap where nothing is memoized.
  20. 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 — use useSyncExternalStore for external mutable sources.
  21. 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 through children into Client Component wrappers like <ThemeProvider>.
  22. 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.
  23. Select With useShallow in Zustand: useStore(s => s) subscribes to the entire store, and useStore(s => ({ a: s.a, b: s.b })) creates a fresh object each update causing spurious re-renders; wrap in useShallow from zustand/react/shallow to compare properties instead of identity.
  24. 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.
  25. Enforce Bundle Budgets in CI: Add bundle-size budgets to the build (Webpack performance.hints: "error", size-limit, or Next.js's analyze output 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.