Bundle Analyze
Bundle size rules enforced on 18 production projects. The 100KB gzip rule per component chunk exists because it maps directly to a ~300ms FCP regression on median mobile.
100KB Rule
Any dynamically-imported component chunk must be under 100KB gzip. (BLOCKING)
Measure with:
# Vite
npx vite build --mode production
# Output includes per-chunk sizes
# Next.js
ANALYZE=true next build
# Requires @next/bundle-analyzer
Vite manualChunks Configuration
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// Vendor chunk: React ecosystem
if (id.includes('node_modules/react') ||
id.includes('node_modules/react-dom') ||
id.includes('node_modules/scheduler')) {
return 'react-vendor'
}
// Routing chunk
if (id.includes('node_modules/react-router') ||
id.includes('node_modules/@remix-run')) {
return 'router'
}
// UI library chunk (keep separate — large)
if (id.includes('node_modules/@radix-ui') ||
id.includes('node_modules/lucide-react')) {
return 'ui'
}
// Data/validation chunk
if (id.includes('node_modules/zod') ||
id.includes('node_modules/@tanstack')) {
return 'data'
}
// Charts (often 200-400KB — must be lazy)
if (id.includes('node_modules/recharts') ||
id.includes('node_modules/chart.js') ||
id.includes('node_modules/d3')) {
return 'charts'
}
// Date handling (use date-fns tree-shaking, not moment)
if (id.includes('node_modules/date-fns')) {
return 'dates'
}
},
},
},
// Warn if chunk exceeds 150KB gzip (will fail CI at 200KB)
chunkSizeWarningLimit: 150,
},
})
Next.js Dynamic Imports
// components/HeavyChart.tsx — loaded only when visible
import dynamic from 'next/dynamic'
// Basic lazy load with spinner fallback
const HeavyChart = dynamic(() => import('./HeavyChartImpl'), {
loading: () => <div className="h-64 animate-pulse rounded-lg bg-gray-100" />,
ssr: false, // charts often use window — disable SSR
})
// Named export pattern
const DataTable = dynamic(
() => import('./DataTable').then(mod => mod.DataTable),
{ loading: () => <TableSkeleton /> }
)
// Conditional import — only load on client after interaction
export function DashboardPage() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button => setShowChart(true)}>Show Analytics</button>
{showChart && <HeavyChart />}
</div>
)
}
Rule: Any component over 50KB must be dynamically imported. (BLOCKING for Next.js)
Common Bundle Killers
| Package | Problem | Replacement | Savings |
|---|---|---|---|
moment |
329KB min | date-fns (tree-shaken) |
~250KB |
lodash |
531KB min | lodash-es (tree-shaken) or native |
~400KB |
@mui/material (full) |
600KB+ | @mui/material/Button (path import) |
~400KB |
react-icons (full) |
100KB+ | lucide-react or individual SVGs |
~80KB |
highlight.js (full) |
900KB | highlight.js/lib/core + specific langs |
~700KB |
xlsx |
750KB | papaparse for CSV, exceljs for xlsx |
~600KB |
pdfjs-dist (full) |
2.5MB | Dynamically imported, worker in public/ | Use lazy |
@fullcalendar/* |
400KB+ | Must be dynamic import | Use lazy |
Import Discipline
// BLOCKED: barrel imports from large packages
import { Button, Input, Dialog, Table, Badge } from '@/components/ui'
// If ui/index.ts re-exports 50 components, bundler may not tree-shake properly
// REQUIRED: direct imports for large packages
import { format, parseISO } from 'date-fns'
import { he } from 'date-fns/locale'
// NOT: import dateFns from 'date-fns'
// BLOCKED: default import of lodash
import _ from 'lodash'
// REQUIRED: named imports from lodash-es
import { debounce, throttle } from 'lodash-es'
// BLOCKED: importing entire icon sets
import * as Icons from 'lucide-react'
// REQUIRED: named imports only
import { ChevronDown, Search, X } from 'lucide-react'
CI Bundle Size Check
# .github/workflows/bundle-size.yml
name: Bundle Size
on: [pull_request]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
# Fail if any JS chunk exceeds 200KB gzip
- name: Check chunk sizes
run: |
python3 -c "
import os, gzip, sys
MAX_GZIP_KB = 200
failed = []
for root, dirs, files in os.walk('.next/static/chunks'):
for f in files:
if not f.endswith('.js'):
continue
path = os.path.join(root, f)
with open(path, 'rb') as fh:
data = fh.read()
compressed = len(gzip.compress(data))
kb = compressed / 1024
if kb > MAX_GZIP_KB:
failed.append(f'{f}: {kb:.0f}KB gzip')
if failed:
print('Bundle size limit exceeded (200KB gzip):')
for f in failed:
print(f' {f}')
sys.exit(1)
else:
print('All chunks within size limits.')
"
Adjust MAX_GZIP_KB per project. 200KB is the hard CI limit; 100KB is the soft warning threshold.
Lighthouse Performance Budget
// budget.json — colocate with lighthouse CI config
[
{
"resourceSizes": [
{ "resourceType": "script", "budget": 300 },
{ "resourceType": "total", "budget": 1000 },
{ "resourceType": "image", "budget": 500 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 }
],
"timings": [
{ "metric": "first-contentful-paint", "budget": 2000 },
{ "metric": "largest-contentful-paint", "budget": 4000 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "total-blocking-time", "budget": 300 }
]
}
]