TechLead
Lesson 9 of 22
5 min read
Performance Engineering

Bundle Analysis

Visualize and audit your JavaScript bundles to identify bloat, duplicates, and optimization opportunities

Why Analyze Bundles?

Bundle analysis reveals what is inside your JavaScript bundles, how large each dependency is, and whether duplicate or unnecessary code is being shipped to users. Without regular bundle analysis, dependencies tend to grow unchecked, and small additions compound over time into significant performance problems.

Common Bundle Issues

  • Duplicate dependencies: Multiple versions of the same library bundled separately
  • Unused exports: Importing entire libraries when only a few functions are needed
  • Polyfill bloat: Shipping polyfills for features all target browsers support
  • Development artifacts: Debug code, source maps, or dev-only dependencies in production
  • Large dependencies: Heavy libraries where lighter alternatives exist

Next.js Bundle Analyzer

// Setup: npm install -D @next/bundle-analyzer

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,
});

module.exports = withBundleAnalyzer({
  // your existing next config
  reactStrictMode: true,
});

// Run analysis:
// ANALYZE=true npm run build

// This generates:
// - client.html: Client-side bundles visualization
// - nodejs.html: Server-side bundles visualization
// - edge.html: Edge runtime bundles (if applicable)

Automated Bundle Size Checks

// bundle-check.ts — Automated bundle size budget checking
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';

interface BundleBudget {
  pattern: RegExp;
  maxSizeKB: number;
  name: string;
}

interface BudgetResult {
  name: string;
  file: string;
  sizeKB: number;
  budgetKB: number;
  passed: boolean;
}

const budgets: BundleBudget[] = [
  { name: 'Main JS', pattern: /main-.*.js$/, maxSizeKB: 80 },
  { name: 'Framework', pattern: /framework-.*.js$/, maxSizeKB: 45 },
  { name: 'Page chunks', pattern: /pages/.*.js$/, maxSizeKB: 50 },
  { name: 'Vendor chunks', pattern: /chunks/.*.js$/, maxSizeKB: 100 },
];

function checkBundleBudgets(buildDir: string): BudgetResult[] {
  const results: BudgetResult[] = [];

  function walkDir(dir: string): string[] {
    const files: string[] = [];
    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
      const fullPath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        files.push(...walkDir(fullPath));
      } else if (entry.name.endsWith('.js')) {
        files.push(fullPath);
      }
    }
    return files;
  }

  const jsFiles = walkDir(path.join(buildDir, '.next', 'static'));

  for (const budget of budgets) {
    const matchingFiles = jsFiles.filter(f => budget.pattern.test(f));

    for (const file of matchingFiles) {
      const content = fs.readFileSync(file);
      const gzipped = zlib.gzipSync(content);
      const sizeKB = gzipped.length / 1024;

      results.push({
        name: budget.name,
        file: path.relative(buildDir, file),
        sizeKB: Math.round(sizeKB * 10) / 10,
        budgetKB: budget.maxSizeKB,
        passed: sizeKB <= budget.maxSizeKB,
      });
    }
  }

  return results;
}

// Usage in CI
const results = checkBundleBudgets(process.cwd());
const failures = results.filter(r => !r.passed);

console.log('Bundle Budget Results:');
results.forEach(r => {
  const status = r.passed ? 'PASS' : 'FAIL';
  console.log(`  [${status}] ${r.name}: ${r.sizeKB}KB / ${r.budgetKB}KB — ${r.file}`);
});

if (failures.length > 0) {
  console.error(`\n${failures.length} bundle(s) exceeded budget!`);
  process.exit(1);
}

Finding and Replacing Heavy Dependencies

// Common heavy dependencies and lighter alternatives

// moment.js (~300KB) -> date-fns (~12KB for common operations) or dayjs (~2KB)
// Before:
import moment from 'moment';
moment().format('YYYY-MM-DD');

// After:
import { format } from 'date-fns';
format(new Date(), 'yyyy-MM-dd');

// lodash (~70KB) -> lodash-es (tree-shakable) or native JS
// Before:
import _ from 'lodash';
_.uniq(arr);

// After:
const uniq = [...new Set(arr)];

// axios (~13KB) -> native fetch (0KB)
// Before:
import axios from 'axios';
const { data } = await axios.get('/api/data');

// After:
const data = await fetch('/api/data').then(r => r.json());

// uuid (~3KB) -> crypto.randomUUID() (0KB, built-in)
// Before:
import { v4 as uuid } from 'uuid';
const id = uuid();

// After:
const id = crypto.randomUUID();

// Dependency size audit script
import { execSync } from 'child_process';

function auditDependencies(): void {
  const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
  const deps = Object.keys(packageJson.dependencies || {});

  console.log('Dependency sizes (install size):');
  for (const dep of deps) {
    try {
      const info = JSON.parse(
        execSync(`npm info ${dep} --json 2>/dev/null`, { encoding: 'utf-8' })
      );
      const sizeKB = (info.dist?.unpackedSize || 0) / 1024;
      console.log(`  ${dep}: ${sizeKB.toFixed(0)}KB`);
    } catch {
      console.log(`  ${dep}: unable to fetch size`);
    }
  }
}

Bundle Analysis Workflow

  • Run bundle analyzer: Visualize what is in your bundles after every significant change
  • Set size budgets: Define maximum sizes per chunk and enforce in CI
  • Audit dependencies: Replace heavy libraries with lighter alternatives
  • Check for duplicates: Ensure only one version of each dependency is bundled
  • Track over time: Monitor bundle size trends to catch gradual bloat

Continue Learning