DEV Community

楊東霖
楊東霖

Posted on • Originally published at devplaybook.cc

JavaScript Bundle Size Optimization: From 2MB to 200KB — A Practical Guide

JavaScript Bundle Size Optimization: From 2MB to 200KB

A 2MB JavaScript bundle is a performance emergency. On a 4G connection it takes 2-3 seconds to download and parse — before your app renders anything. Here's a systematic approach to cutting bundle size dramatically.

Step 1: Analyze Before You Optimize

Never guess. Measure first.

Webpack Bundle Analyzer

npm install --save-dev webpack-bundle-analyzer

# webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
      reportFilename: 'bundle-report.html',
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Run npm run build and open bundle-report.html. You'll see a treemap of every dependency's contribution to your bundle. Common offenders: moment.js (330KB), lodash (70KB), date-fns (34KB per locale).

Vite Bundle Analysis

npm install --save-dev rollup-plugin-visualizer

# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Bundlephobia for Quick Checks

Before adding any npm package, check bundlephobia.com for the bundle cost. Example: lodash costs 70KB — lodash-es with tree shaking costs 0-70KB depending on what you import.


Step 2: Replace Heavy Libraries

Moment.js → date-fns or Temporal API

// Before: moment.js (330KB)
import moment from 'moment';
const formatted = moment(date).format('MMMM Do YYYY');

// After: date-fns (tree-shakeable, ~1KB for this function)
import { format } from 'date-fns';
const formatted = format(date, 'MMMM do yyyy');

// Even better: Temporal API (zero bundle cost, native 2026)
const formatted = new Temporal.PlainDate
  .from(date)
  .toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
Enter fullscreen mode Exit fullscreen mode

Lodash → Native JavaScript

// Before: lodash (70KB)
import _ from 'lodash';
const result = _.groupBy(users, 'department');
const unique = _.uniqBy(items, 'id');
const sorted = _.sortBy(data, ['name', 'age']);

// After: native JS (0KB)
const result = Object.groupBy(users, u => u.department);  // ES2024
const unique = [...new Map(items.map(i => [i.id, i])).values()];
const sorted = data.toSorted((a, b) =>
  a.name.localeCompare(b.name) || a.age - b.age
);
Enter fullscreen mode Exit fullscreen mode

If you genuinely need lodash, import individual functions:

// Partial import with tree shaking
import groupBy from 'lodash/groupBy';
import uniqBy from 'lodash/uniqBy';
Enter fullscreen mode Exit fullscreen mode

Axios → Fetch API

// Before: axios (14KB)
import axios from 'axios';
const { data } = await axios.get('/api/users');

// After: fetch (0KB, native)
const data = await fetch('/api/users').then(r => r.json());

// With error handling
async function fetchUsers() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Tree Shaking — Make It Actually Work

Tree shaking removes unused code, but only works with ES modules (import/export syntax).

Common Tree Shaking Failures

// ❌ CommonJS: can't tree shake
const { pick } = require('lodash');

// ✅ ESM: fully tree-shakeable
import { pick } from 'lodash-es';

// ❌ Re-exporting entire library defeats tree shaking
export * from 'some-library';

// ✅ Named re-exports preserve tree shaking
export { Button, Input, Modal } from 'some-library';
Enter fullscreen mode Exit fullscreen mode

Webpack Tree Shaking Config

// webpack.config.js
module.exports = {
  mode: 'production',  // Enables tree shaking automatically
  optimization: {
    usedExports: true,  // Mark unused exports
    sideEffects: false, // Trust package.json sideEffects field
  },
};
Enter fullscreen mode Exit fullscreen mode

Add "sideEffects": false to your package.json to tell bundlers your code is side-effect free:

{
  "sideEffects": ["*.css", "*.scss"]
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Code Splitting

Don't ship code users haven't requested yet.

Route-Based Splitting (React)

// Before: all routes in one bundle
import Dashboard from './Dashboard';
import Settings from './Settings';
import AdminPanel from './AdminPanel';

// After: each route loaded on demand
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const AdminPanel = lazy(() => import('./AdminPanel'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Imports for Features

// Load heavy features only when needed
async function handleExport() {
  // xlsx is 200KB — only load when user clicks Export
  const { default: XLSX } = await import('xlsx');
  const workbook = XLSX.utils.book_new();
  // ...
}

// Load syntax highlighting only on code pages
async function highlightCode(code, language) {
  const { highlight } = await import('highlight.js');
  return highlight(code, { language }).value;
}
Enter fullscreen mode Exit fullscreen mode

Webpack SplitChunksPlugin

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // Separate vendor chunks for better caching
      react: {
        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
        name: 'react',
        priority: 20,
      },
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        priority: 10,
        reuseExistingChunk: true,
      },
    },
  },
},
Enter fullscreen mode Exit fullscreen mode

Step 5: Compression

Modern compression is dramatically effective on JavaScript.

Brotli Compression (Best)

# nginx.conf
gzip on;
gzip_static on;

brotli on;
brotli_static on;
brotli_comp_level 6;
brotli_types text/javascript application/javascript application/json;
Enter fullscreen mode Exit fullscreen mode

A 500KB JavaScript file typically compresses to:

  • Gzip: ~150KB (70% reduction)
  • Brotli: ~120KB (76% reduction)

Serve pre-compressed .br files for maximum efficiency.

Vite/Webpack Compression Plugin

// vite.config.ts
import viteCompression from 'vite-plugin-compression';

export default {
  plugins: [
    viteCompression({ algorithm: 'brotliCompress' }),
    viteCompression({ algorithm: 'gzip' }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Step 6: Optimize Images and Assets in JS

Sometimes the "JavaScript bundle" includes base64-encoded assets.

// webpack.config.js — prevent base64 inlining for large assets
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif|svg)$/,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024, // Only inline files under 4KB
          },
        },
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 7: Measure the Real-World Impact

Bundle size and runtime performance aren't the same thing. Measure both.

// Performance Observer API: measure parsing time
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name.includes('chunk')) {
      console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
    }
  }
});
observer.observe({ type: 'resource', buffered: true });
Enter fullscreen mode Exit fullscreen mode

Core Web Vitals Targets

Metric Good Needs Improvement Poor
LCP <2.5s 2.5-4s >4s
FID/INP <100ms 100-300ms >300ms
CLS <0.1 0.1-0.25 >0.25

Bundle size primarily affects LCP (Largest Contentful Paint) and INP (Interaction to Next Paint).


Bundle Size Budget

Enforce limits in CI to prevent regressions:

// package.json
{
  "bundlesize": [
    {
      "path": "./dist/js/main.*.js",
      "maxSize": "100 kB"
    },
    {
      "path": "./dist/js/vendors.*.js",
      "maxSize": "200 kB"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
# GitHub Actions
- name: Check bundle size
  run: npx bundlesize
Enter fullscreen mode Exit fullscreen mode

Quick Wins Checklist

  • [ ] Remove unused dependencies (npx depcheck)
  • [ ] Replace moment.js with date-fns
  • [ ] Replace lodash with native JS or lodash-es
  • [ ] Enable production mode in webpack/Vite
  • [ ] Add code splitting for routes
  • [ ] Enable Brotli/gzip compression on the server
  • [ ] Audit with webpack-bundle-analyzer
  • [ ] Set bundle size budgets in CI

Related Tools


Summary

Bundle size optimization follows a consistent pattern: measure → identify biggest offenders → replace or split → compress → enforce budgets.

The highest-impact steps are replacing moment.js, adding route-based code splitting, and enabling Brotli compression. Together, these typically achieve 60-80% bundle size reduction before touching any application code.

Start with the bundle analyzer. The treemap will immediately reveal which dependencies are worth replacing — it's almost always a few large libraries that can be swapped for smaller alternatives or modern native APIs.


Level Up Your Dev Workflow

Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers.

🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included.

Top comments (0)