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-analyzer

Code 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 build

Package 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 = nextConfig

Smart 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.html

Conditional 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-app

Alternative 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#

  1. Run ANALYZE=true npm run build to generate bundle analysis
  2. Review the treemap visualization in .next/analyze/
  3. Identify and optimize the largest dependencies
  4. Use dynamic imports for heavy, rarely-used features
  5. 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