Performanceadvanced

JavaScript Bundle Size Optimization — The Complete Guide

Master bundle optimization: code splitting, tree shaking, lazy loading, and analysis tools. Reduce JavaScript payload and speed up your web apps.

16 min read·Published Apr 8, 2026
performancebundle-sizecode-splittingtree-shaking

Why Bundle Size Matters

Every kilobyte of JavaScript you ship has a cost. The browser must download, parse, compile, and execute it. On a mid-range mobile device over a 3G connection, 1MB of JavaScript can take over 10 seconds to become interactive.

JavaScript Cost Pipeline:
+----------+    +--------+    +---------+    +---------+
| Download | -> | Parse  | -> | Compile | -> | Execute |
| (network)|    | (CPU)  |    | (CPU)   |    | (CPU)   |
+----------+    +--------+    +---------+    +---------+

1MB JS on different connections:
+------------------+----------+----------+----------+
| Connection       | Download | Parse    | Total    |
+------------------+----------+----------+----------+
| Fast 3G (1.5Mbps)| 5.3s    | 2-4s     | 7-9s     |
| 4G (9Mbps)       | 0.9s    | 2-4s     | 3-5s     |
| Wi-Fi (30Mbps)   | 0.3s    | 2-4s     | 2-4s     |
+------------------+----------+----------+----------+

Note: Parse/compile times are per-device, not per-connection.
A $200 phone is 2-5x slower than a flagship.

Analyzing Your Bundle

Before optimizing, you need to know what you are shipping.

webpack-bundle-analyzer

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

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

module.exports = withBundleAnalyzer({
  // your next config
});
# Run the analyzer
ANALYZE=true npm run build

This opens an interactive treemap showing every module in your bundle, sized proportionally.

source-map-explorer

npm install --save-dev source-map-explorer

# Generate source maps and analyze
npx source-map-explorer .next/static/chunks/*.js

Manual size checking

# Check individual file sizes
ls -lh .next/static/chunks/*.js | sort -k5 -h

# Check gzipped sizes (what users actually download)
for f in .next/static/chunks/*.js; do
  size=$(wc -c < "$f")
  gzip_size=$(gzip -c "$f" | wc -c)
  echo "$f: ${size}B -> ${gzip_size}B gzipped"
done

Import cost awareness

// Check what a single import costs
// moment.js — 72KB min+gzip (includes ALL locales)
import moment from 'moment';

// date-fns — 2KB for a single function
import { format } from 'date-fns';

// Side-by-side comparison:
// +------------------+-----------+-----------+
// | Library          | Full Size | Tree-shaken|
// +------------------+-----------+-----------+
// | moment           | 72KB      | 72KB      |
// | date-fns         | 75KB      | 2KB       |
// | dayjs            | 2KB       | 2KB       |
// | Intl.DateTimeFormat| 0KB     | 0KB (native)|
// +------------------+-----------+-----------+

Code Splitting

Code splitting breaks your application into smaller chunks that load on demand. Instead of shipping one massive bundle, you ship only the code needed for the current page.

Route-Based Splitting

Next.js does this automatically. Each page becomes its own chunk.

Without code splitting:
+--------------------------------------------+
|              bundle.js (500KB)              |
| Home + About + Dashboard + Settings + ...  |
+--------------------------------------------+
User downloads 500KB on every page.

With route-based splitting:
+----------+ +----------+ +----------+ +----------+
| home.js  | | about.js | | dash.js  | | settings |
|  (40KB)  | |  (25KB)  | | (120KB)  | |  (35KB)  |
+----------+ +----------+ +----------+ +----------+
User downloads only what they need.
// pages/index.tsx — automatically code-split by Next.js
export default function Home() {
  return <h1>Home</h1>;
}

// pages/dashboard.tsx — separate chunk, loaded only when navigated to
export default function Dashboard() {
  return <h1>Dashboard</h1>;
}

Component-Level Splitting with Dynamic Imports

// Bad: Heavy chart library loaded even if chart is below the fold
import { LineChart } from 'recharts';

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <LineChart data={data} />
    </div>
  );
}

// Good: Dynamic import — chart code loads only when needed
import dynamic from 'next/dynamic';

const LineChart = dynamic(
  () => import('recharts').then((mod) => mod.LineChart),
  {
    loading: () => <div>Loading chart...</div>,
    ssr: false, // Charts often need window/document
  }
);

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <LineChart data={data} />
    </div>
  );
}

React.lazy for Client-Side Splitting

import { lazy, Suspense } from 'react';

// Component is loaded only when it first renders
const HeavyEditor = lazy(() => import('./HeavyEditor'));
const MarkdownPreview = lazy(() => import('./MarkdownPreview'));

function EditorPage() {
  const [showPreview, setShowPreview] = useState(false);

  return (
    <div>
      <Suspense fallback={<div>Loading editor...</div>}>
        <HeavyEditor />
      </Suspense>

      <button onClick={() => setShowPreview(true)}>
        Show Preview
      </button>

      {showPreview && (
        <Suspense fallback={<div>Loading preview...</div>}>
          <MarkdownPreview />
        </Suspense>
      )}
    </div>
  );
}

Interaction-Based Splitting

Load code only when the user interacts with a feature.

function SearchPage() {
  const [FilterPanel, setFilterPanel] = useState(null);

  const handleOpenFilters = async () => {
    // Load the filter component on first click
    const mod = await import('./FilterPanel');
    setFilterPanel(() => mod.default);
  };

  return (
    <div>
      <SearchBar />
      <button onClick={handleOpenFilters}>Filters</button>

      {FilterPanel && <FilterPanel />}
    </div>
  );
}

Prefetching Chunks

// Prefetch on hover — load before the user clicks
function NavLink({ href, children }) {
  const handleMouseEnter = () => {
    // Next.js router prefetches automatically, but for custom chunks:
    import(/* webpackPrefetch: true */ './HeavyPage');
  };

  return (
    <a href={href} onMouseEnter={handleMouseEnter}>
      {children}
    </a>
  );
}
// Webpack magic comments for prefetch/preload
// Prefetch: low priority, load during idle time
const HeavyModule = () => import(/* webpackPrefetch: true */ './HeavyModule');

// Preload: high priority, load in parallel with current chunk
const CriticalModule = () => import(/* webpackPreload: true */ './CriticalModule');

Tree Shaking

Tree shaking eliminates unused exports from your bundle during the build step. It relies on ES module static analysis — the bundler can determine at compile time which exports are used.

How Tree Shaking Works

// math.js — ES module with named exports
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }

// app.js — only uses add
import { add } from './math';
console.log(add(2, 3));

// After tree shaking, subtract, multiply, divide are removed
// Final bundle only contains the add function

Why Tree Shaking Fails

// Problem 1: CommonJS modules cannot be tree-shaken
const lodash = require('lodash'); // Entire library included
lodash.get(obj, 'a.b.c');

// Fix: Use ES module imports
import { get } from 'lodash-es'; // Only 'get' is included
get(obj, 'a.b.c');

// Problem 2: Side effects prevent tree shaking
// utils.js
export function helper() { return 42; }
console.log('utils loaded'); // <-- Side effect! Module can't be removed

// Problem 3: Barrel files re-export everything
// components/index.js
export { Button } from './Button';
export { Modal } from './Modal';       // Modal is 50KB
export { DataGrid } from './DataGrid'; // DataGrid is 200KB

// Even if you only import Button, some bundlers include everything
import { Button } from './components'; // May pull in Modal + DataGrid

Fixing Tree Shaking

// package.json — Mark your package as side-effect free
{
  "name": "my-library",
  "sideEffects": false
}

// Or specify which files have side effects
{
  "sideEffects": [
    "*.css",
    "./src/polyfills.js"
  ]
}
// next.config.js — Enable experimental optimizations
module.exports = {
  experimental: {
    optimizePackageImports: [
      'lodash-es',
      '@heroicons/react',
      'date-fns',
      'lucide-react',
    ],
  },
};
// Use direct file imports for libraries that don't tree shake well
// Bad: Pulls in entire icon set
import { SearchIcon } from '@heroicons/react/24/outline';

// Good: Direct file import
import SearchIcon from '@heroicons/react/24/outline/SearchIcon';

Verifying Tree Shaking

// webpack.config.js — Add stats to see what's included
module.exports = {
  stats: {
    usedExports: true,    // Shows which exports are used
    optimizationBailout: true, // Shows why tree shaking failed
  },
};
# Check if a specific module is tree-shaken
# Look for /* unused harmony export */ comments in the output
npx webpack --mode production --stats-used-exports

Lazy Loading Components

The Loading Waterfall Problem

Without lazy loading:
Page Load: [===== JS (500KB) ======]
                                    | First Paint
                                    | Interactive

With lazy loading:
Page Load: [= Critical JS (80KB) =]
                                    | First Paint + Interactive
           [====== Lazy chunks load in background ======]

Pattern: Lazy Load Below-the-Fold Content

import dynamic from 'next/dynamic';

// Above the fold — loaded immediately
import { HeroSection } from '@/components/HeroSection';
import { FeaturedProducts } from '@/components/FeaturedProducts';

// Below the fold — loaded when scrolled into view
const ReviewSection = dynamic(() => import('@/components/ReviewSection'), {
  loading: () => <ReviewSkeleton />,
});

const RelatedProducts = dynamic(() => import('@/components/RelatedProducts'), {
  loading: () => <ProductGridSkeleton />,
});

const Newsletter = dynamic(() => import('@/components/Newsletter'), {
  loading: () => <NewsletterSkeleton />,
});

function ProductPage({ product }) {
  return (
    <>
      <HeroSection product={product} />
      <FeaturedProducts />
      {/* These load as user scrolls */}
      <ReviewSection productId={product.id} />
      <RelatedProducts category={product.category} />
      <Newsletter />
    </>
  );
}

Pattern: Lazy Load with Intersection Observer

import { useEffect, useRef, useState, lazy, Suspense } from 'react';

function useLazyComponent(importFn) {
  const [Component, setComponent] = useState(null);
  const ref = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          // Load the component when it enters the viewport
          importFn().then((mod) => {
            setComponent(() => mod.default);
          });
          observer.disconnect();
        }
      },
      { rootMargin: '200px' } // Start loading 200px before visible
    );

    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, [importFn]);

  return { ref, Component };
}

// Usage
function Page() {
  const { ref, Component: HeavyChart } = useLazyComponent(
    () => import('./HeavyChart')
  );

  return (
    <div>
      <h1>Dashboard</h1>
      <div ref={ref}>
        {HeavyChart ? <HeavyChart /> : <ChartSkeleton />}
      </div>
    </div>
  );
}

Third-Party Script Optimization

Third-party scripts are one of the biggest bundle bloaters. Analytics, ads, chat widgets, A/B testing tools — they add up fast.

Audit Third-Party Impact

Typical third-party costs:
+------------------------+-----------+------------------+
| Script                 | Size (gz) | Main Thread Time |
+------------------------+-----------+------------------+
| Google Analytics (GA4) | 30KB      | 50-100ms         |
| Google Tag Manager     | 33KB      | 100-200ms        |
| Intercom chat widget   | 200KB+    | 300-500ms        |
| Full Story             | 60KB      | 200-400ms        |
| Stripe.js              | 40KB      | 50-100ms         |
| reCAPTCHA              | 150KB+    | 200-400ms        |
+------------------------+-----------+------------------+

Loading Strategies for Third-Party Scripts

// next/script provides built-in loading strategies
import Script from 'next/script';

function Layout({ children }) {
  return (
    <>
      {/* afterInteractive (default) — loads after page is interactive */}
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"
        strategy="afterInteractive"
      />

      {/* lazyOnload — loads during idle time, after everything else */}
      <Script
        src="https://widget.intercom.io/widget/xxxxx"
        strategy="lazyOnload"
      />

      {/* worker — experimental, runs in a web worker */}
      <Script
        src="https://analytics.example.com/tracker.js"
        strategy="worker"
      />

      {children}
    </>
  );
}

Self-Hosting Third-Party Scripts

// Instead of loading from third-party CDN (blocks DNS + connection)
// Download and serve from your own domain

// next.config.js — proxy third-party through your domain
module.exports = {
  async rewrites() {
    return [
      {
        source: '/a/:path*',
        destination: 'https://analytics.example.com/:path*',
      },
    ];
  },
};

Facade Pattern — Load on Interaction

// Instead of loading a 200KB chat widget on every page,
// show a fake button that loads the real widget on click

function ChatWidgetFacade() {
  const [loaded, setLoaded] = useState(false);

  if (loaded) {
    return <RealChatWidget />;
  }

  return (
    <button
      onClick={() => setLoaded(true)}
      aria-label="Open chat"
      style={{
        position: 'fixed',
        bottom: '20px',
        right: '20px',
        width: '60px',
        height: '60px',
        borderRadius: '50%',
        background: '#0066ff',
        border: 'none',
        cursor: 'pointer',
        color: 'white',
        fontSize: '24px',
      }}
    >
      Chat
    </button>
  );
}

// RealChatWidget loads the actual Intercom/Drift/etc script
const RealChatWidget = dynamic(
  () => import('./ChatWidget'),
  { ssr: false }
);

Polyfill Management

Shipping polyfills for features all modern browsers support wastes bandwidth.

The Problem

// babel.config.js — targeting old browsers adds polyfills
{
  presets: [
    ['@babel/preset-env', {
      targets: '> 0.25%, not dead', // Includes IE11!
      useBuiltIns: 'usage',
      corejs: 3,
    }]
  ]
}
// Result: Polyfills for Promise, Symbol, Array.from, etc.
// Added ~80KB of code modern browsers don't need

Modern Solution: Differential Serving

<!-- Serve modern code to modern browsers, polyfilled code to legacy -->
<script type="module" src="/modern.js"></script>
<script nomodule src="/legacy.js"></script>
// next.config.js — Next.js handles this automatically
// But you can configure the browserslist target
// .browserslistrc
// last 2 versions
// > 1%
// not dead
// not ie 11

Replace Heavy Polyfills with Native APIs

// Bad: Using a polyfill for Intl.NumberFormat
import { formatNumber } from 'format-number-polyfill'; // 15KB

// Good: Native API (supported in all modern browsers)
const formatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
});
formatter.format(1234.56); // "$1,234.56"

// Bad: Using a polyfill for URL parsing
import urlParse from 'url-parse'; // 8KB

// Good: Native URL API
const url = new URL('https://example.com/path?q=test');
url.searchParams.get('q'); // "test"

Advanced Optimization Techniques

Chunk Splitting Strategy

// next.config.js — Custom webpack config for chunk optimization
module.exports = {
  webpack(config, { isServer }) {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          // Separate vendor libraries into their own chunk
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendor',
            chunks: 'all',
            priority: 10,
          },
          // Group commonly used components
          common: {
            minChunks: 2,
            priority: 5,
            reuseExistingChunk: true,
          },
          // Separate large libraries into individual chunks
          react: {
            test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
            name: 'react',
            chunks: 'all',
            priority: 20,
          },
        },
      };
    }
    return config;
  },
};

Replace Heavy Libraries

+--------------------+--------+-------------------+--------+
| Heavy Library      | Size   | Lightweight Alt   | Size   |
+--------------------+--------+-------------------+--------+
| moment             | 72KB   | dayjs             | 2KB    |
| lodash             | 72KB   | lodash-es (tree)  | 2-5KB  |
| axios              | 13KB   | native fetch      | 0KB    |
| classnames         | 1KB    | clsx              | 0.3KB  |
| uuid               | 3KB    | crypto.randomUUID | 0KB    |
| numeral            | 16KB   | Intl.NumberFormat | 0KB    |
| chalk (in browser) | 10KB   | (remove)          | 0KB    |
+--------------------+--------+-------------------+--------+
// Before: 3 heavy libraries (87KB)
import moment from 'moment';
import _ from 'lodash';
import axios from 'axios';

const date = moment().format('YYYY-MM-DD');
const filtered = _.filter(items, { active: true });
const res = await axios.get('/api/data');

// After: 0 extra bytes
const date = new Intl.DateTimeFormat('en-CA').format(new Date());
const filtered = items.filter(item => item.active);
const res = await fetch('/api/data').then(r => r.json());

Module/nomodule Pattern for CSS-in-JS

// If using styled-components or emotion, extract critical CSS
// next.config.js
module.exports = {
  compiler: {
    styledComponents: true, // Enables SSR for styled-components
  },
};

// Or consider switching to zero-runtime CSS
// Tailwind CSS — 0KB runtime, only used classes ship
// CSS Modules — 0KB runtime, scoped by default
// Vanilla Extract — 0KB runtime, type-safe

Dynamic Import with Named Exports

// When a module has multiple named exports, import only what you need
// Bad: Imports the entire module
const mod = await import('./heavyModule');
mod.specificFunction();

// Good: Destructure to hint at tree shaking
const { specificFunction } = await import('./heavyModule');
specificFunction();

// Even better: Create thin wrapper files
// ./charts/LineChart.js
export { LineChart } from 'recharts'; // Re-export only what's needed

// Usage
const { LineChart } = await import('./charts/LineChart');

Bundle Budgets

Set hard limits on bundle size and fail the build if exceeded.

Performance Budget Configuration

// next.config.js — experimental performance budgets
module.exports = {
  experimental: {
    // Warn if a page's JavaScript exceeds these sizes
    largePageDataWarning: true,
  },
};
// Custom budget check in CI
// scripts/check-bundle-size.js
const fs = require('fs');
const path = require('path');

const BUDGET = {
  'pages/index': 100 * 1024,     // 100KB
  'pages/dashboard': 200 * 1024, // 200KB
  total: 500 * 1024,             // 500KB total
};

function checkBudget() {
  const buildDir = path.join(process.cwd(), '.next/static/chunks/pages');
  const files = fs.readdirSync(buildDir);
  let total = 0;

  for (const file of files) {
    const filePath = path.join(buildDir, file);
    const stats = fs.statSync(filePath);
    total += stats.size;

    const pageName = `pages/${file.replace('.js', '')}`;
    if (BUDGET[pageName] && stats.size > BUDGET[pageName]) {
      console.error(
        `BUDGET EXCEEDED: ${pageName} is ${stats.size}B (limit: ${BUDGET[pageName]}B)`
      );
      process.exit(1);
    }
  }

  if (total > BUDGET.total) {
    console.error(`TOTAL BUDGET EXCEEDED: ${total}B (limit: ${BUDGET.total}B)`);
    process.exit(1);
  }

  console.log(`Bundle size check passed. Total: ${total}B`);
}

checkBudget();
// package.json — Add to CI pipeline
{
  "scripts": {
    "build": "next build",
    "check-bundle": "node scripts/check-bundle-size.js",
    "ci": "npm run build && npm run check-bundle"
  }
}

Real-World Optimization Walkthrough

Before Optimization

Bundle Analysis (total: 742KB gzipped)
+--------------------------------------------+
| node_modules/moment        | 72KB  | 9.7%  |
| node_modules/lodash        | 70KB  | 9.4%  |
| node_modules/recharts      | 150KB | 20.2% |
| node_modules/react-icons   | 45KB  | 6.1%  |
| node_modules/axios         | 13KB  | 1.8%  |
| node_modules/date-fns      | 38KB  | 5.1%  |
| App code                   | 354KB | 47.7% |
+--------------------------------------------+

After Optimization

// 1. Replace moment with dayjs (-70KB)
// Before
import moment from 'moment';
moment().format('YYYY-MM-DD');

// After
import dayjs from 'dayjs';
dayjs().format('YYYY-MM-DD');

// 2. Replace lodash with lodash-es + targeted imports (-65KB)
// Before
import _ from 'lodash';
_.debounce(fn, 300);

// After
import { debounce } from 'lodash-es';
debounce(fn, 300);

// 3. Lazy load recharts (-150KB from initial load)
const LineChart = dynamic(
  () => import('recharts').then(m => m.LineChart),
  { ssr: false, loading: () => <ChartSkeleton /> }
);

// 4. Replace react-icons with direct SVG imports (-43KB)
// Before
import { FiSearch, FiHome, FiUser } from 'react-icons/fi';

// After: Create your own icon components with inline SVG
function SearchIcon(props) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
      <circle cx="11" cy="11" r="8" />
      <line x1="21" y1="21" x2="16.65" y2="16.65" />
    </svg>
  );
}

// 5. Replace axios with fetch (-13KB)
// Before
const { data } = await axios.get('/api/data');
// After
const data = await fetch('/api/data').then(r => r.json());
After Optimization (total: 215KB gzipped)
+--------------------------------------------+
| node_modules/dayjs         | 2KB   | 0.9%  |
| node_modules/lodash-es     | 5KB   | 2.3%  |
| node_modules/recharts      | 0KB   | 0%    | (lazy loaded)
| Custom SVG icons           | 2KB   | 0.9%  |
| App code                   | 206KB | 95.8% |
+--------------------------------------------+
Saved: 527KB (71% reduction)

Key Takeaways

  • Analyze before optimizing — use webpack-bundle-analyzer or source-map-explorer
  • Code split by route (automatic in Next.js) and by component (dynamic imports)
  • Tree shaking only works with ES modules — avoid CommonJS in frontend code
  • Replace heavy libraries with lighter alternatives or native APIs
  • Lazy load below-the-fold content, charts, and third-party widgets
  • Use the facade pattern for chat widgets and heavy embeds
  • Set bundle budgets in CI to prevent regression
  • Every kilobyte of JavaScript has a download, parse, and execute cost
  • Polyfill only what you need — modern browsers handle most APIs natively
  • Audit third-party scripts regularly — they grow silently over time

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles