Bundle Optimization, Analysis and Turbopack
Why Bundle Size Matters
JavaScript bundle size directly affects two core metrics:
First Contentful Paint (FCP) and Time to Interactive (TTI): The browser must download, parse, and execute JavaScript before a page becomes interactive. On a 4G connection, 1 MB of JavaScript takes approximately 2โ3 seconds to parse โ JS parsing consumes more CPU than downloading. On mid-range and low-end devices, the same JS parse time can be 3โ5 times longer than on flagship devices.
Return visit performance: CDNs cache static assets, but if a bundle's content hash changes on every deployment (even if only one line changed), users must re-download the entire bundle. A good bundle splitting strategy keeps the majority of unchanged code cached long-term.
Next.js's App Router already applies extensive default optimizations (Server Components are never sent to the client by default, automatic code splitting, etc.), but there are many common pitfalls that can cause bundles to bloat unexpectedly.
@next/bundle-analyzer: Visualizing Bundle Composition
@next/bundle-analyzer generates an interactive treemap that lets you visually see which packages consume the most space:
npm install --save-dev @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from 'next'
import withBundleAnalyzer from '@next/bundle-analyzer'
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: true, // Automatically open the browser after analysis
})
const nextConfig: NextConfig = {
// ... other config
}
export default bundleAnalyzer(nextConfig)
# Run the analysis
ANALYZE=true npm run build
After analysis completes, two HTML files open: one for the client bundle (client.html) and one for the server bundle (server.html). The client bundle has the greatest impact on SEO and performance.
Reading the Bundle Analysis Treemap
Each rectangle in the treemap represents a module; its area represents its size; its color represents which chunk it belongs to. Focus on:
- Particularly large blocks: usually where the problem lies
- Unexpectedly present packages: for example, a server-only package appearing in the client bundle
- Duplicated packages: the same package included separately in multiple chunks
Common Sources of Bundle Bloat
Importing All of lodash
// Wrong: imports all of lodash (~70 KB gzipped)
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')
// Correct approach 1: per-function import
import sortBy from 'lodash/sortBy'
// Correct approach 2: use lodash-es (supports tree shaking)
import { sortBy } from 'lodash-es'
// Best approach: use native JS (zero bundle cost)
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name))
The moment.js Locale Bundle Problem
moment.js bundles all locale files by default, adding approximately 330 KB. The solution:
// next.config.ts
import webpack from 'webpack'
const nextConfig: NextConfig = {
webpack(config) {
// Only keep Chinese and English locales
config.plugins.push(
new webpack.ContextReplacementPlugin(
/moment[/\\]locale$/,
/zh|en/
)
)
return config
},
}
Or migrate directly to date-fns (tree-shaking friendly, import per function):
import { format, parseISO } from 'date-fns'
import { enUS } from 'date-fns/locale'
const formatted = format(parseISO(dateStr), 'MMMM d, yyyy', { locale: enUS })
Bulk Icon Library Imports
// Wrong: imports entire icon library (react-icons ~2 MB)
import { FaUser, FaHome, FaSearch } from 'react-icons/fa'
// This looks like it only imports 3 icons, but react-icons'
// package structure makes tree shaking effectiveness vary by build tool
// Safest: use direct path imports
import FaUser from 'react-icons/fa/FaUser'
A better solution is to inline SVG icon files directly, or use icon libraries designed for tree shaking like @heroicons/react:
import { MagnifyingGlassIcon, UserIcon } from '@heroicons/react/24/outline'
Tree Shaking Pitfalls
Tree shaking requires ES Module static import structure. The following break tree shaking:
Side effect imports: if a module executes side effects (modifying global variables, registering plugins), the bundler cannot safely eliminate it. Explicitly declare side-effect-free code in package.json:
// Your own library's package.json
{
"sideEffects": false
}
// Or only declare files that do have side effects
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}
Barrel files: this is the most common tree shaking killer in Next.js projects.
// components/index.ts (barrel file)
export { Button } from './Button'
export { Modal } from './Modal'
export { DataTable } from './DataTable' // DataTable depends on a heavy chart library
export { Chart } from './Chart'
// Consumer only imports Button
import { Button } from '@/components'
// But the bundler must import the entire components/index.ts
// So DataTable and Chart end up included too
optimizePackageImports: Automatic Barrel File Optimization
Next.js 15 has a built-in optimizePackageImports config that automatically handles barrel file problems for popular libraries:
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: [
'@mui/material',
'@mui/icons-material',
'lucide-react',
'date-fns',
'lodash-es',
// Add any other libraries in your project with barrel file issues
],
},
}
This config tells Next.js to apply special handling for these packages: even if you import from the barrel file, Next.js converts the import statement to a direct path import, achieving true on-demand loading.
Dynamic Imports: On-Demand Loading of Heavy Components
For heavy client components that are not needed on the first screen, use next/dynamic for deferred loading:
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded" />,
ssr: false, // If the component depends on browser APIs
})
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* HeavyChart's code is only downloaded when this component enters the viewport */}
<HeavyChart data={chartData} />
</div>
)
}
When to Use ssr: false
ssr: false tells Next.js to completely skip server-side rendering for this component, loading it only on the client. This is appropriate for:
- Components that use browser APIs like
window,document,navigator - Third-party libraries incompatible with SSR (certain Canvas libraries, WebGL libraries)
- Components that only appear after user interaction (rich text editors)
// Rich text editors are typically incompatible with SSR
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{
ssr: false,
loading: () => (
<div className="border rounded-lg p-4 min-h-[200px] bg-gray-50">
Loading editor...
</div>
),
}
)
Conditional Dynamic Imports
Some features are only needed by specific users (such as admin tools) โ you can conditionally decide whether to load them at all:
'use client'
import dynamic from 'next/dynamic'
import { useUser } from '@/hooks/useUser'
const AdminPanel = dynamic(() => import('./AdminPanel'))
export function ConditionalAdminPanel() {
const { user } = useUser()
if (!user?.isAdmin) return null
// AdminPanel's code is only downloaded for admins
return <AdminPanel />
}
Turbopack: The Next-Generation Bundler
Turbopack is a new generation bundler written in Rust by the Next.js team. In Next.js 15 it has stable support for development mode.
Enabling Turbopack in Development
# package.json
{
"scripts": {
"dev": "next dev --turbo"
}
}
Turbopack vs Webpack: Performance Comparison
Official benchmarks (based on large application tests):
| Metric | Webpack | Turbopack | Improvement |
|---|---|---|---|
| Cold start | ~15 s | ~2 s | ~7x |
| HMR (hot reload) | ~500ms | ~50ms | ~10x |
| Memory usage | ~1.5 GB | ~600 MB | ~60% |
Turbopack's speed advantage comes from two core design choices:
- Incremental computation: only recomputes what was affected by a change, rather than re-bundling the entire project
- Parallelism: Rust's concurrency capabilities let Turbopack fully utilize multi-core CPUs
Turbopack's Status in Next.js 15
As of Next.js 15, Turbopack:
- Development mode (
next dev --turbo): stable, suitable for production development workflows - Production builds (
next build --turbo): still under active development; works in some cases but is not recommended for production yet
// next.config.ts
const nextConfig: NextConfig = {
// Turbopack alias config (replaces webpack's resolve.alias)
turbo: {
resolveAlias: {
'react-native': 'react-native-web',
},
// Turbopack loader config (different syntax from webpack)
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
}
When to Switch to Turbopack
Good candidates for switching:
- Large projects where webpack dev startup exceeds 10 seconds
- Teams doing frequent UI iteration where HMR speed is severely hurting developer productivity
- Projects with minimal custom webpack loaders and plugins
Reasons to hold off:
- Heavy custom webpack configuration (Turbopack's API differs from webpack and requires migration)
- Dependency on webpack-specific features (some loaders don't yet have Turbopack equivalents)
- Need for stable production builds
Advanced Production Build Optimizations
Analyzing Next.js Build Output
next build
After the build completes, Next.js prints size statistics for every route:
Route (app) Size First Load JS
โ โ / 5.2 kB 87.4 kB
โ โ /blog 3.1 kB 85.3 kB
โ โ /blog/[slug] 2.8 kB 85.0 kB
โ โ /dashboard 15.2 kB 97.4 kB โ Warrants investigation
โ โ /login 4.1 kB 86.3 kB
โ (Static) prerendered as static content
โ (SSG) prerendered as static HTML
"First Load JS" is the total client JavaScript that needs to be downloaded when loading that route from the server, including shared chunks. Aim to keep first-screen JS under 100 KB. When a route exceeds this, audit which client components it has introduced.
Chunk Splitting Strategy: splitChunks Tuning
Next.js's default chunk splitting works well for most cases, but specific needs can be addressed:
// next.config.ts
const nextConfig: NextConfig = {
webpack(config, { isServer }) {
if (!isServer) {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
// Keep React-related packages in a separate chunk for long-term caching
framework: {
name: 'framework',
chunks: 'all',
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 40,
enforce: true,
},
// Split large third-party packages into individual chunks
lib: {
test: /[\\/]node_modules[\\/]/,
name(module: { context: string }) {
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)?.[1]
return `npm.${packageName?.replace('@', '')}`
},
priority: 30,
minChunks: 1,
reuseExistingChunk: true,
},
},
}
}
return config
},
}
Case Study: Reducing a Real Project's Bundle by 40%
Here is a step-by-step walkthrough of a real optimization:
Starting state: Dashboard page First Load JS: 234 KB
Step 1: Run bundle analyzer, identify the largest modules.
Discover moment.js (330 KB) and @mui/icons-material (fully imported) are consuming the majority of space.
Step 2: Replace moment.js.
Replace all moment calls with date-fns, saving approximately 280 KB (date-fns only bundles the functions you actually use).
Step 3: Fix icon library imports.
Change import { Add, Delete, Edit } from '@mui/icons-material' to:
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'
Saves approximately 60 KB.
Step 4: Convert the chart component to dynamic import.
The LineChart component in the dashboard depends on recharts (~80 KB). After converting to dynamic import, the library is only downloaded when the user scrolls to the chart area.
Final result: First Load JS drops from 234 KB to 138 KB (โ41%). TTI improves by approximately 800ms when measured on a mid-range device.
Summary
Bundle optimization is one of the highest-ROI performance activities in a Next.js project. The core workflow is: use @next/bundle-analyzer to discover problems, use dynamic imports to defer loading of heavy components, use optimizePackageImports to automatically address barrel file issues, and use lighter alternatives (date-fns instead of moment, lodash-es instead of lodash) to reduce base bundle size. Turbopack already delivers a noticeably faster development experience in dev mode, but production builds should still use the stable webpack path. Combining these techniques can reduce client JS bundle size by 30โ60% without changing any product functionality, directly translating to faster TTI and a better user experience.