Bundle Analysis
Patterns for analyzing and optimizing JavaScript bundle size.
Overview#
Bundle analysis helps you understand what's in your JavaScript bundles. This pattern covers:
- Next.js bundle analyzer setup
- Identifying large dependencies
- Code splitting strategies
- Tree shaking optimization
- Import cost awareness
Prerequisites#
npm install @next/bundle-analyzerCode Example#
Bundle Analyzer Setup#
1// next.config.js
2const withBundleAnalyzer = require('@next/bundle-analyzer')({
3 enabled: process.env.ANALYZE === 'true'
4})
5
6/** @type {import('next').NextConfig} */
7const nextConfig = {
8 // Your existing config
9}
10
11module.exports = withBundleAnalyzer(nextConfig)# Run analysis
ANALYZE=true npm run buildPackage Import Optimization#
1// next.config.js
2/** @type {import('next').NextConfig} */
3const nextConfig = {
4 experimental: {
5 optimizePackageImports: [
6 'lucide-react',
7 '@radix-ui/react-icons',
8 'date-fns',
9 'lodash',
10 '@heroicons/react',
11 'recharts'
12 ]
13 }
14}
15
16module.exports = nextConfigSmart Imports#
1// AVOID: Importing entire libraries
2// This pulls in the entire library
3import { format, parse, addDays, subDays, isAfter } from 'date-fns'
4import { debounce, throttle, merge, cloneDeep } from 'lodash'
5import { Search, Menu, X, ChevronDown, ChevronUp } from 'lucide-react'
6
7// BETTER: Import specific functions
8import { format } from 'date-fns/format'
9import { parseISO } from 'date-fns/parseISO'
10import debounce from 'lodash/debounce'
11import throttle from 'lodash/throttle'
12
13// With optimizePackageImports, this is automatically tree-shaken:
14import { Search, Menu } from 'lucide-react'Dynamic Imports for Heavy Libraries#
1// components/Charts.tsx
2'use client'
3
4import dynamic from 'next/dynamic'
5
6// Heavy charting library - only load when needed
7const ResponsiveContainer = dynamic(
8 () => import('recharts').then(mod => mod.ResponsiveContainer),
9 { ssr: false }
10)
11
12const LineChart = dynamic(
13 () => import('recharts').then(mod => mod.LineChart),
14 { ssr: false }
15)
16
17const Line = dynamic(
18 () => import('recharts').then(mod => mod.Line),
19 { ssr: false }
20)
21
22// Or load all at once
23const Recharts = dynamic(
24 () => import('recharts'),
25 {
26 ssr: false,
27 loading: () => <div className="h-64 animate-pulse bg-gray-200" />
28 }
29)Code Splitting by Route#
1// Next.js App Router automatically splits by route
2// Each page.tsx becomes its own chunk
3
4// app/
5// |-- page.tsx -> (main bundle ~50KB)
6// |-- dashboard/
7// | |-- page.tsx -> (dashboard bundle ~30KB)
8// |-- admin/
9// | |-- page.tsx -> (admin bundle ~40KB)
10
11// Check bundle sizes
12// After build, check .next/analyze/client.htmlConditional Feature Bundles#
1// lib/features.ts
2export const loadEditorBundle = () =>
3 import('@/features/editor').then(mod => mod.Editor)
4
5export const loadAnalyticsBundle = () =>
6 import('@/features/analytics').then(mod => mod.Analytics)
7
8export const loadAdminBundle = () =>
9 import('@/features/admin').then(mod => mod.AdminPanel)
10
11// components/FeatureLoader.tsx
12'use client'
13
14import { Suspense, lazy } from 'react'
15
16const featureComponents = {
17 editor: lazy(() => import('@/features/editor')),
18 analytics: lazy(() => import('@/features/analytics')),
19 admin: lazy(() => import('@/features/admin'))
20}
21
22export function FeatureLoader({
23 feature,
24 enabled
25}: {
26 feature: keyof typeof featureComponents
27 enabled: boolean
28}) {
29 if (!enabled) return null
30
31 const Component = featureComponents[feature]
32
33 return (
34 <Suspense fallback={<FeatureSkeleton />}>
35 <Component />
36 </Suspense>
37 )
38}Webpack Bundle Analysis Script#
1// scripts/analyze-bundle.ts
2import fs from 'fs'
3import path from 'path'
4
5interface ChunkInfo {
6 name: string
7 size: number
8 modules: string[]
9}
10
11function analyzeBundle() {
12 const statsPath = path.join(process.cwd(), '.next/stats.json')
13
14 if (!fs.existsSync(statsPath)) {
15 console.log('Run: ANALYZE=true npm run build')
16 return
17 }
18
19 const stats = JSON.parse(fs.readFileSync(statsPath, 'utf-8'))
20
21 // Find largest chunks
22 const chunks: ChunkInfo[] = stats.chunks
23 .map((chunk: any) => ({
24 name: chunk.names[0] || 'unknown',
25 size: chunk.size,
26 modules: chunk.modules?.map((m: any) => m.name) || []
27 }))
28 .sort((a: ChunkInfo, b: ChunkInfo) => b.size - a.size)
29
30 console.log('\nLargest chunks:')
31 chunks.slice(0, 10).forEach((chunk, i) => {
32 console.log(`${i + 1}. ${chunk.name}: ${(chunk.size / 1024).toFixed(2)} KB`)
33 })
34
35 // Find largest node_modules
36 const moduleSizes = new Map<string, number>()
37
38 stats.modules?.forEach((module: any) => {
39 const match = module.name?.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/)
40 if (match) {
41 const pkg = match[1]
42 moduleSizes.set(pkg, (moduleSizes.get(pkg) || 0) + module.size)
43 }
44 })
45
46 console.log('\nLargest dependencies:')
47 Array.from(moduleSizes.entries())
48 .sort((a, b) => b[1] - a[1])
49 .slice(0, 10)
50 .forEach(([pkg, size], i) => {
51 console.log(`${i + 1}. ${pkg}: ${(size / 1024).toFixed(2)} KB`)
52 })
53}
54
55analyzeBundle()Size Limit Configuration#
1// package.json
2{
3 "scripts": {
4 "size": "size-limit",
5 "size:why": "size-limit --why"
6 },
7 "size-limit": [
8 {
9 "path": ".next/static/chunks/main-*.js",
10 "limit": "100 KB"
11 },
12 {
13 "path": ".next/static/chunks/pages/_app-*.js",
14 "limit": "50 KB"
15 },
16 {
17 "path": ".next/static/chunks/framework-*.js",
18 "limit": "50 KB"
19 }
20 ]
21}npm install --save-dev size-limit @size-limit/preset-appAlternative Lighter Libraries#
1// Instead of moment.js (300KB)
2// Use date-fns (tree-shakeable) or dayjs (2KB)
3import dayjs from 'dayjs'
4
5// Instead of lodash (70KB full)
6// Use individual functions or native JS
7import debounce from 'lodash/debounce' // Just the function
8
9// Instead of axios (13KB)
10// Use native fetch or ky (3KB)
11import ky from 'ky'
12
13// Instead of uuid (7KB)
14// Use crypto.randomUUID() (native)
15const id = crypto.randomUUID()
16
17// Instead of classnames (1KB)
18// Use clsx (228B) or cn utility
19import { clsx } from 'clsx'Monitoring Bundle Size in CI#
1# .github/workflows/bundle-size.yml
2name: Bundle Size
3
4on: [pull_request]
5
6jobs:
7 size:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 - uses: actions/setup-node@v4
12 with:
13 node-version: '20'
14 cache: 'npm'
15
16 - run: npm ci
17 - run: npm run build
18
19 - name: Check bundle size
20 uses: preactjs/compressed-size-action@v2
21 with:
22 repo-token: "${{ secrets.GITHUB_TOKEN }}"
23 pattern: ".next/static/**/*.js"Usage Instructions#
- Run
ANALYZE=true npm run buildto generate bundle analysis - Review the treemap visualization in
.next/analyze/ - Identify and optimize the largest dependencies
- Use dynamic imports for heavy, rarely-used features
- Set up bundle size budgets in CI
Best Practices#
- Analyze regularly - Check bundle size on each PR
- Set budgets - Define and enforce size limits
- Prefer smaller alternatives - Use lighter libraries
- Dynamic import heavy code - Split non-critical features
- Tree shake - Enable and verify tree shaking works
- Remove unused code - Audit and remove dead dependencies
- Monitor trends - Track bundle size over time
Related Patterns#
- Lazy Loading - Load on demand
- Optimization - Performance tips
- Code Splitting - Split bundles
- Profiling - Find bottlenecks