Bundle analysis, dynamic() for code splitting, advanced Image configuration with remotePatterns and custom loaders, Script strategy deep dive, optimizePackageImports for barrel files, and memory leak detection.
P-10 — Performance Optimisation for Practitioners
Who this is for: Engineers who have deployed a Next.js app and are now staring at Lighthouse scores or slow load times and wondering where to start. This module is a structured approach to Next.js performance — how to audit, what to fix first, and what "optimized" actually looks like at the end.
Start With the Build Output
Before running Lighthouse, before installing a bundle analyzer, read the build output. next build prints a table that tells you exactly how Next.js categorized every route in your app:
Route (app) Size First Load JS
┌ ○ / 4.2 kB 92.4 kB
├ ○ /about 1.1 kB 89.3 kB
├ ● /blog 3.7 kB 91.8 kB
├ λ /blog/[slug] 2.4 kB 90.5 kB
├ λ /api/users 0 B 0 B
└ ● /sitemap.xml 0 B 0 B
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
λ (Dynamic) server-rendered on demand
The symbols matter. ○ is fully static — best performance, served from CDN, zero server compute. ● is statically generated with data — good performance, generated at build time. λ is dynamic — server-rendered on every request, hits your server for every page load.
Your job is to make as many routes ○ or ● as possible and only use λ where you genuinely need per-request data. The most common mistake is accidentally making routes dynamic by calling cookies(), headers(), or Date.now() in a component that didn't need to be dynamic.
If you see a route you expected to be static showing as λ, trace upward through the component tree. Something in the render path is opting into dynamic rendering. Use export const dynamic = 'force-static' to make Next.js throw an error during build if something tries to go dynamic — this surfaces the culprit immediately.
Bundle Analyzer Setup
The bundle analyzer shows you exactly what's in your JavaScript bundles and what's making them large. Setup is two steps:
bashnpm install --save-dev @next/bundle-analyzer
ts// next.config.ts import bundleAnalyzer from '@next/bundle-analyzer' const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }) export default withBundleAnalyzer({ // your existing config })
Run it with ANALYZE=true npm run build. Two HTML files open in your browser — client.html and server.html — showing treemaps of your bundles. Client is what users download; server is what runs in Node.js (less critical for end-user performance, more critical for cold start times).
Look for the large rectangles. Common offenders: moment.js (replace with date-fns or Intl), lodash without tree-shaking (replace with individual lodash-es imports), large chart libraries loaded on pages that have a small chart, icon libraries where you're importing all 1000 icons to use 5, and faker.js accidentally included in production (yes, this happens).
dynamic() for Code Splitting
next/dynamic is a wrapper around React.lazy with server-rendering capabilities. You use it to defer the loading of a component until it's actually needed:
tsximport dynamic from 'next/dynamic' // Client Component that's only needed when user clicks "Open Editor" const RichTextEditor = dynamic(() => import('./RichTextEditor'), { loading: () => <div className="h-48 animate-pulse bg-muted rounded" />, }) // Browser-only library — cannot run on the server at all const ConfettiComponent = dynamic(() => import('./Confetti'), { ssr: false, }) // Heavy chart library — load only on the analytics page const AnalyticsChart = dynamic(() => import('./AnalyticsChart'), { loading: () => <ChartSkeleton />, ssr: false, // recharts has issues with SSR })
The ssr: false option is specifically for libraries that break on the server — they reference window, document, or navigator at module load time before React hydration. Common culprits: react-pdf, canvas-based libraries, WebGL wrappers, and some mapping libraries.
A practical rule: any component that requires a library over 50KB and isn't visible in the initial viewport should be dynamically imported. The page renders faster, and users who never trigger that component never download it.
Barrel Files Are Killing Your Bundle
This is the hidden performance killer in almost every large Next.js codebase. You have a component library organized like this:
src/components/
Button.tsx
Input.tsx
Modal.tsx
DataTable.tsx ← this imports ag-grid, which is 300KB
index.ts ← export * from './Button'; export * from './DataTable'; ...
When you write import { Button } from '@/components', you're importing from the barrel file index.ts. Webpack/Turbopack has to evaluate that entire barrel, which pulls in DataTable, which pulls in ag-grid, which is now in your bundle even though you only wanted Button.
The fix has two parts. First, import directly when possible: import { Button } from '@/components/Button'. Second, use optimizePackageImports in next.config.ts to tell Next.js which packages to optimize at the framework level:
tsexperimental: { optimizePackageImports: ['lucide-react', '@heroicons/react', 'date-fns', '@acme/ui'], },
This is particularly important for icon libraries. lucide-react has over 1,400 icons. Without optimizePackageImports, importing import { ArrowRight } from 'lucide-react' pulls in all 1,400. With it, Next.js tree-shakes at the import level automatically.
Server Components = Free Bundle Reduction
This is the most underappreciated performance property of the App Router. Every component that you don't mark with 'use client' is a Server Component. Server Components never appear in the client JavaScript bundle. Never. The code runs on the server, produces HTML, and that's all the client receives.
This means a Server Component can import a 2MB markdown parser, a 500KB syntax highlighter, or a full-featured data processing library — and none of that code is shipped to the browser. The user never downloads it. You get the functionality without the bundle cost.
The practical implication: your default should be Server Component. Reach for 'use client' only when you need interactivity (event listeners, state, effects), browser-only APIs, or context from a provider. Any component that just fetches data and renders HTML should stay a Server Component. Most UI components that don't have interactive behavior can stay as Server Components.
When you add 'use client' to a component, everything it imports also becomes part of the client bundle. This is why component architecture matters for performance — a 'use client' boundary at the wrong level can drag large dependencies into the bundle.
Image Performance in Detail
Next.js <Image> handles the hard parts of image optimization — format conversion, resizing, lazy loading, and srcset generation — but you have to tell it the context correctly:
tsximport Image from 'next/image' // Hero image — LCP element, load immediately <Image src="/hero.jpg" alt="Product hero" width={1200} height={600} priority // disables lazy loading, adds preload link sizes="100vw" // full viewport width on all screens placeholder="blur" blurDataURL={hero.blurHash} // show blurred placeholder while loading /> // Gallery thumbnail — not LCP, lazy load is fine <Image src={product.image} alt={product.name} width={400} height={400} sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" />
The sizes attribute tells the browser which image to download at each viewport width. Without it, the browser defaults to 100vw, which means it downloads a full-viewport-width image for every image on the page. For a product grid with 12 items, that's 12 full-resolution images when you only needed thumbnails.
priority should be used on the LCP (Largest Contentful Paint) element — usually the hero image or the first above-the-fold product image. It adds a <link rel="preload"> to the document head, which tells the browser to start downloading the image before the JavaScript runs.
Font Optimisation
next/font handles font optimisation automatically — it downloads fonts at build time and serves them from your own domain, eliminating the extra DNS lookup and round-trip to Google Fonts. It also inlines the font-face declarations directly in the <head>:
tsx// app/layout.tsx import { Inter, Fraunces } from 'next/font/google' const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap', }) const fraunces = Fraunces({ subsets: ['latin'], variable: '--font-display', axes: ['SOFT', 'WONK'], // variable font axes display: 'optional', // don't show fallback if font loads quickly })
Variable fonts (Inter, Fraunces, Geist) are a single font file that contains the full weight and style range. Use them instead of loading separate files for each weight — one HTTP request instead of four.
display: 'optional' is the most aggressive option — it tells the browser to only use the custom font if it loads within a very short window. It eliminates Cumulative Layout Shift from font swaps but means some users might see the system font. display: 'swap' shows the fallback then swaps when the font loads — higher CLS risk but wider custom font coverage.
Third-Party Scripts
Third-party scripts — analytics, chat widgets, A/B testing tools, marketing pixels — are the most common source of performance problems on marketing sites. They block the main thread and delay Time to Interactive.
Next.js's <Script> component has a strategy prop that controls when scripts load:
tsximport Script from 'next/script' // Loads and executes before page is interactive — only for critical scripts <Script src="/critical.js" strategy="beforeInteractive" /> // Loads after page is interactive — for most third-party scripts <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" /> // Loads during browser idle time — for non-critical scripts <Script src="https://chat.example.com/widget.js" strategy="lazyOnload" />
afterInteractive is the right default for most analytics and marketing scripts. lazyOnload is appropriate for chat widgets and other scripts that don't affect the initial user experience. Almost nothing should use beforeInteractive — if a third party tells you their script must be in <head>, push back, because they're asking you to make every user wait for their script before the page becomes interactive.
Memory Leak Patterns
The most pernicious performance bugs in long-running Next.js apps are memory leaks. The server process grows over time and eventually needs to be restarted. Three common patterns:
Event listeners added in server-side code that are never cleaned up. EventEmitter instances with on() calls in module scope accumulate listeners across requests. Use once() instead of on() where possible, and clean up on() listeners with corresponding off() calls.
Caches that grow without bounds. A module-level Map used as a cache is fine — until it isn't pruned and grows to hold the last 100,000 unique request parameters. Implement a maximum size with an LRU eviction policy, or use Redis for any cache that needs to survive server restarts and scale beyond one process.
setInterval calls without corresponding clearInterval. If you call setInterval in a module that's hot-reloaded during development, each reload creates a new interval without clearing the old one. In development you'll see doubled log messages; in production you'll see memory and CPU drift upward over time.
The Turbopack Filesystem Cache
Turbopack stores its incremental build cache in .next/cache/turbopack. This cache is what makes subsequent next dev startups fast — instead of recompiling everything from scratch, Turbopack reads the cache and only recompiles files that changed.
The cache can become corrupted after major Next.js version upgrades, after changing next.config.ts in ways that affect the module graph, or after some monorepo dependency changes. Symptoms: inexplicable build errors, components that don't reflect recent changes, type errors that don't match the actual code.
The fix is to delete the cache and rebuild:
bashrm -rf .next/cache/turbopack npm run dev
This is also worth doing when you're debugging a performance regression — if you're unsure whether an issue is real or a cache artifact, a clean build removes the variable.
In CI, don't delete this cache between runs. Cache .next/cache in your CI system (GitHub Actions, CircleCI, etc.) and restore it based on a hash of package-lock.json and next.config.ts. This dramatically speeds up CI build times without introducing correctness issues, since Turbopack validates cache entries against the current config and file hashes.