MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Performance: Bundle Analysis and Optimization

Analyze and optimize JS bundles: tree shaking, code splitting, dynamic imports, and analysis tools.

PerformanceBundlingJavaScriptFrontend

By MinhVo

Introduction

Bundle size is one of the most impactful factors in web performance, directly affecting load times, time-to-interactive, and user experience on every device and network condition. A bloated JavaScript bundle can add seconds to your load time on mobile networks, costing you users and revenue. Studies consistently show that a one-second delay in page load time leads to a 7% reduction in conversions, 11% fewer page views, and a 16% decrease in customer satisfaction.

Modern web applications often ship megabytes of JavaScript—far more than necessary. The culprit isn't just application code; it's the accumulation of dependencies, unused code, duplicate packages, and inefficient bundling strategies. Bundle analysis and optimization is the systematic process of understanding what's in your bundles, why it's there, and how to reduce it without sacrificing functionality.

This guide covers the complete workflow: from analyzing your bundles with specialized tools to implementing tree shaking, code splitting, dynamic imports, and advanced optimization techniques that can reduce your bundle size by 50-80%.

Bundle size impact visualization

Understanding Bundle Analysis and Optimization: Core Concepts

What Are JavaScript Bundles?

A JavaScript bundle is a single file (or a small set of files) that contains all the JavaScript code your application needs to run. Bundlers like webpack, Rollup, Vite, esbuild, and Parcel take your source code, resolve dependencies, and combine everything into optimized output files.

The bundling process involves several stages:

  1. Dependency Resolution: Starting from entry points, the bundler traces all import and require() statements to build a dependency graph.
  2. Transformation: Source code is transformed through loaders and plugins (Babel for JSX/TypeScript, PostCSS for styles, etc.).
  3. Tree Shaking: Dead code elimination removes unused exports from ES module dependencies.
  4. Code Splitting: The dependency graph is split into chunks based on dynamic imports, entry points, and optimization rules.
  5. Minification: Variable names are shortened, whitespace removed, and dead code eliminated.
  6. Output: Final bundles are written to disk with source maps, content hashes, and other metadata.

Bundle Metrics That Matter

Understanding bundle metrics helps you make informed optimization decisions:

First Load Bundle Size: The total size of JavaScript that must be downloaded before the page becomes interactive. This is the most critical metric for user experience.

Parse and Compile Time: JavaScript must be parsed and compiled before execution. On mobile devices, this can take 2-5x longer than on desktop. A 1MB bundle might take 1-2 seconds to parse on a mid-range Android phone.

Unused Code Percentage: The proportion of shipped code that isn't executed on a given page. Industry benchmarks suggest most sites ship 30-50% unused JavaScript.

Coverage per Route: How much of your total JavaScript is actually used on each page. Single-page applications often have significant coverage variation between routes.

The Cost of JavaScript

JavaScript is the most expensive resource on the web. Unlike images or CSS, JavaScript must be downloaded, parsed, compiled, and executed. Each of these steps has a cost:

  • Download: 100KB of JavaScript gzips to approximately 30KB, but still requires network transfer.
  • Parse: The browser must parse the JavaScript into an Abstract Syntax Tree (AST).
  • Compile: The AST is compiled into bytecode for the JavaScript engine.
  • Execute: The bytecode runs, potentially triggering more downloads and computations.

On a Moto G4 phone, 1MB of JavaScript takes approximately 2.9 seconds to process. On an iPhone 8, it takes about 0.7 seconds. These differences directly impact your users' experience.

Performance metrics dashboard

Architecture and Design Patterns

Module System Design

Effective bundle optimization starts with how you structure your modules. The module system you use (CommonJS vs ES Modules) directly affects your bundler's ability to optimize:

ES Modules (ESM) are the foundation for tree shaking. Because import and export are static declarations, bundlers can analyze the dependency graph at build time and determine exactly which exports are used:

// âś… Tree-shakeable ES Module export
export function formatDate(date) {
  return new Intl.DateTimeFormat('en-US').format(date);
}
 
export function formatCurrency(amount) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount);
}

CommonJS (CJS) modules are dynamic—require() calls can happen anywhere and resolve at runtime. This makes static analysis difficult and often prevents tree shaking:

// ❌ Not tree-shakeable CommonJS
const utils = require('./utils');
module.exports = { formatDate: utils.formatDate };

Bundle Splitting Strategy

A well-designed splitting strategy balances cacheability with HTTP connection overhead:

Vendor Splitting: Separate third-party dependencies into their own chunks. These change less frequently than application code, enabling long-term caching:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        },
        common: {
          minChunks: 2,
          chunks: 'all',
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
};

Route-Based Splitting: Split by application routes so users only download code for the pages they visit:

const routes = {
  home: () => import('./pages/Home'),
  dashboard: () => import('./pages/Dashboard'),
  settings: () => import('./pages/Settings'),
  analytics: () => import('./pages/Analytics')
};

Feature-Based Splitting: Split infrequently used features into separate chunks that load on demand:

// Only load the editor when the user needs it
async function openEditor(content) {
  const { Editor } = await import('./features/Editor');
  return new Editor(content);
}

Dependency Management Architecture

Organize dependencies to minimize bundle impact:

src/
├── core/           # Always-loaded, minimal core
├── features/       # Feature modules, loaded on demand
├── shared/         # Shared utilities (small, tree-shakeable)
└── vendor/         # Vendor-specific wrappers

This architecture enables progressive loading—the core loads first, then features load as needed. Shared utilities are small and tree-shakeable, preventing the "shared chunk becomes huge" anti-pattern.

Step-by-Step Implementation

Setting Up Bundle Analysis

First, install and configure your analysis tools:

# Install webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
 
# Or for Vite
npm install --save-dev rollup-plugin-visualizer
 
# Or standalone analysis
npm install --save-dev source-map-explorer

Configure webpack to generate analysis reports:

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
module.exports = {
  plugins: [
    process.env.ANALYZE && new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
      generateStatsFile: true,
      statsFilename: 'stats.json'
    })
  ].filter(Boolean)
};

For Vite, use the visualizer plugin:

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
 
export default {
  plugins: [
    visualizer({
      open: true,
      filename: 'bundle-stats.html',
      gzipSize: true,
      brotliSize: true
    })
  ]
};

Implementing Tree Shaking

Tree shaking requires specific conditions to work effectively:

// âś… Named exports (tree-shakeable)
export function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}
 
export function formatPrice(amount) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount);
}
 
export function validateCart(cart) {
  return cart.items.length > 0 && cart.total > 0;
}
// ❌ Default export with side effects (not tree-shakeable)
export default {
  calculateTotal(items) {
    console.log('Calculating...'); // Side effect
    return items.reduce((sum, item) => sum + item.price, 0);
  },
  formatPrice(amount) {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD'
    }).format(amount);
  }
};

Ensure your package.json specifies the module entry point:

{
  "name": "my-utils",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "sideEffects": false
}

The "sideEffects": false flag tells bundlers that any export not imported by your code can be safely removed.

Advanced Code Splitting with React

React applications benefit from route-based code splitting with loading states:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
 
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
 
// Preload on hover/focus for instant navigation
function PreloadLink({ to, children, ...props }) {
  const handleMouseEnter = () => {
    switch (to) {
      case '/dashboard':
        import('./pages/Dashboard');
        break;
      case '/analytics':
        import('./pages/Analytics');
        break;
      case '/settings':
        import('./pages/Settings');
        break;
    }
  };
 
  return (
    <Link to={to} onMouseEnter={handleMouseEnter} {...props}>
      {children}
    </Link>
  );
}
 
function LoadingFallback() {
  return (
    <div className="loading-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-content" />
    </div>
  );
}
 
function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingFallback />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Dynamic Imports for Features

Load heavy features only when needed:

class FeatureLoader {
  constructor() {
    this.cache = new Map();
  }
 
  async load(feature) {
    if (this.cache.has(feature)) {
      return this.cache.get(feature);
    }
 
    let module;
    switch (feature) {
      case 'editor':
        module = await import('./features/Editor');
        break;
      case 'chart':
        module = await import('./features/Chart');
        break;
      case 'pdf-export':
        module = await import('./features/PDFExport');
        break;
      case 'video-player':
        module = await import('./features/VideoPlayer');
        break;
      default:
        throw new Error(`Unknown feature: ${feature}`);
    }
 
    this.cache.set(feature, module);
    return module;
  }
 
  preload(feature) {
    // Start loading without waiting
    this.load(feature).catch(() => {});
  }
}
 
// Usage
const featureLoader = new FeatureLoader();
 
// Preload on user intent
document.querySelector('[data-feature="editor"]')
  ?.addEventListener('mouseenter', () => {
    featureLoader.preload('editor');
  });
 
// Load when needed
async function openEditor() {
  const { Editor } = await featureLoader.load('editor');
  new Editor(document.getElementById('editor-container'));
}

Bundle optimization workflow

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Platform Optimization

A major e-commerce platform reduced their JavaScript payload from 2.1MB to 850KB through systematic bundle optimization:

Before: Single vendor bundle with all dependencies, no code splitting, CommonJS modules throughout.

After:

  • Route-based code splitting reduced first-load by 60%
  • Tree shaking eliminated 40% of lodash (switched to lodash-es)
  • Dynamic import of checkout and product configurator modules
  • Vendor splitting improved cache hit rates from 45% to 89%

The result: Time-to-Interactive dropped from 8.2 seconds to 3.1 seconds on 3G networks, and mobile conversion rates increased by 15%.

Use Case 2: SaaS Dashboard Application

A SaaS analytics dashboard was shipping 3MB of JavaScript including a full charting library, date picker, and rich text editor—most of which weren't needed on the initial page load.

Optimization Strategy:

  1. Split the charting library into a separate chunk, loaded only when the user navigates to analytics
  2. Replaced the 200KB date picker with a 15KB native date input polyfill
  3. Implemented virtual scrolling to avoid rendering thousands of DOM nodes
  4. Used Web Workers for data processing to keep the main thread responsive

Results: First Contentful Paint improved from 4.2s to 1.8s. The main bundle dropped from 3MB to 420KB, with the charting library loaded asynchronously when needed.

Use Case 3: Content Publishing Platform

A publishing platform with 50+ page types was bundling all page-specific code into a single application bundle, regardless of which page the user visited.

Solution: Implemented route-based code splitting with shared chunks for common components. Each page type became a separate chunk, and common utilities were extracted into a shared chunk that's cached across routes.

Impact: Average page load decreased by 2.5 seconds. Bandwidth consumption dropped by 65% for users who only visit one or two page types (the majority).

Best Practices for Production

  1. Set and enforce bundle budgets: Define maximum sizes for your bundles and enforce them in CI/CD. Use tools like size-limit or webpack's performance option to fail builds that exceed budgets.

  2. Audit dependencies regularly: Use npm ls or tools like depcheck to identify unused dependencies. Check bundle impact of new dependencies before adding them using bundlephobia.com.

  3. Prefer tree-shakeable libraries: Choose libraries that support ES modules and tree shaking. For example, use lodash-es instead of lodash, date-fns instead of moment, and rxjs/operators instead of the entire RxJS library.

  4. Use dynamic imports for heavy features: Load rich text editors, charting libraries, PDF generators, and other heavy features only when the user actually needs them. Preload on user intent (hover, focus) for instant availability.

  5. Implement proper caching strategies: Use content hashes in filenames for long-term caching. Split vendor code separately from application code since vendors change less frequently.

  6. Monitor bundle size in CI: Track bundle size changes on every pull request using tools like bundlewatch or size-limit. This prevents gradual bundle bloat from creeping in.

  7. Analyze production bundles, not development: Development builds include source maps, hot reloading, and other overhead. Always analyze your production build to understand what users actually download.

  8. Consider module federation for micro-frontends: If you're building a micro-frontend architecture, use webpack's Module Federation to share dependencies between independently deployed applications, avoiding duplicate loading.

Common Pitfalls and Solutions

PitfallImpactSolution
Importing entire libraries10x larger bundlesUse named imports: import { debounce } from 'lodash-es'
No code splittingSlow initial loadImplement route-based and feature-based splitting
Duplicate dependenciesWasted bandwidthUse npm dedupe, check with npm ls
CommonJS in dependenciesPrevents tree shakingUse ESM alternatives or configure bundler for CJS tree shaking
Ignoring source mapsCan't analyze productionGenerate and analyze source maps with source-map-explorer
Side effect importsPrevents eliminationMark side-effect-free modules in package.json

Performance Optimization

Webpack Advanced Configuration

// webpack.production.js
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
 
module.exports = {
  mode: 'production',
  devtool: 'source-map',
  
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          parse: { ecma: 2020 },
          compress: {
            ecma: 5,
            comparisons: false,
            inline: 2,
            drop_console: true,
            drop_debugger: true,
            pure_funcs: ['console.log']
          },
          mangle: { safari10: true },
          output: { ecma: 5, comments: false, ascii_only: true }
        },
        parallel: true,
        extractComments: false
      }),
      new CssMinimizerPlugin()
    ],
    
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: 25,
      minSize: 20000,
      maxSize: 244000,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            const packageName = module.context.match(
              /[\\/]node_modules[\\/](.*?)([\\/]|$)/
            )[1];
            return `vendor.${packageName.replace('@', '')}`;
          }
        },
        common: {
          minChunks: 2,
          priority: -10,
          reuseExistingChunk: true
        }
      }
    },
    
    runtimeChunk: 'single',
    moduleIds: 'deterministic',
    chunkIds: 'deterministic'
  },
  
  plugins: [
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 10240,
      minRatio: 0.8
    }),
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      threshold: 10240,
      minRatio: 0.8,
      filename: '[path][base].br'
    })
  ]
};

Vite Optimization Configuration

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
 
export default defineConfig({
  plugins: [react()],
  
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react-dom')) {
              return 'vendor.react';
            }
            if (id.includes('lodash') || id.includes('date-fns')) {
              return 'vendor.utils';
            }
            if (id.includes('chart') || id.includes('d3')) {
              return 'vendor.charts';
            }
            return 'vendor';
          }
        }
      }
    },
    
    chunkSizeWarningLimit: 500,
    sourcemap: true,
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true
      }
    }
  }
});

Comparison with Alternatives

FeatureWebpackVite (Rollup)esbuildParcel
Bundle sizeGoodExcellentGoodGood
Tree shakingGoodExcellentGoodModerate
Code splittingExcellentGoodBasicGood
Build speedModerateVery FastExtremely FastFast
Plugin ecosystemMassiveGrowingLimitedModerate
ConfigurationComplexMinimalMinimalZero-config
Dev experienceHMRNative ESMFastAuto-config
Production optimizationExcellentExcellentGoodGood
Module federationYesPluginNoNo
Learning curveSteepGentleGentleGentle

Testing Strategies

Bundle Size Testing

// .size-limit.json
[
  {
    "name": "Main bundle",
    "path": "dist/main.*.js",
    "limit": "50 KB",
    "gzip": true
  },
  {
    "name": "Vendor bundle",
    "path": "dist/vendor.*.js",
    "limit": "100 KB",
    "gzip": true
  },
  {
    "name": "Total JavaScript",
    "path": "dist/**/*.js",
    "limit": "200 KB",
    "gzip": true
  }
]
// package.json
{
  "scripts": {
    "size": "size-limit",
    "size:why": "size-limit --why"
  }
}

Coverage Analysis

// scripts/analyze-coverage.js
const puppeteer = require('puppeteer');
 
async function analyzeCoverage(url) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  
  await page.coverage.startJSCoverage();
  await page.goto(url, { waitUntil: 'networkidle0' });
  const coverage = await page.coverage.stopJSCoverage();
  
  let totalBytes = 0;
  let usedBytes = 0;
  
  for (const entry of coverage) {
    totalBytes += entry.text.length;
    for (const range of entry.ranges) {
      usedBytes += range.end - range.start;
    }
  }
  
  const unusedPercent = ((totalBytes - usedBytes) / totalBytes * 100).toFixed(1);
  
  console.log(`Total JS: ${(totalBytes / 1024).toFixed(1)}KB`);
  console.log(`Used JS: ${(usedBytes / 1024).toFixed(1)}KB`);
  console.log(`Unused: ${unusedPercent}%`);
  
  await browser.close();
  
  return { totalBytes, usedBytes, unusedPercent };
}
 
analyzeCoverage('http://localhost:3000');

Future Outlook

Bundle optimization continues to evolve with new tools and techniques:

Import Attributes (TC39 Stage 3) enable bundlers to make better decisions about how to handle different types of imports, including CSS modules, JSON, and WebAssembly.

ES Module CDN services like esm.sh and Skypack serve pre-optimized ES module versions of npm packages, enabling browsers to load modules directly without bundling for development.

HTTP/3 and Early Hints reduce the performance impact of multiple small bundles. With HTTP/3's multiplexing and Early Hints' preloading, the "one big bundle" strategy becomes less important, enabling more aggressive code splitting.

AI-Powered Optimization tools are emerging that analyze user behavior patterns to predict which code should be preloaded and which can be deferred, optimizing the loading strategy based on actual usage data.

Conclusion

Bundle analysis and optimization is not a one-time task but an ongoing discipline. The JavaScript ecosystem constantly evolves, and new dependencies can silently bloat your bundles if you're not monitoring.

Key takeaways from this guide:

  1. Analyze before optimizing: Use tools like webpack-bundle-analyzer, source-map-explorer, and Chrome DevTools Coverage to understand what's in your bundles and what's actually used.

  2. Tree shaking requires ES modules: Ensure your code and dependencies use ES module syntax for effective dead code elimination. Mark packages as sideEffects: false when possible.

  3. Code splitting is essential: Implement route-based and feature-based splitting to reduce initial load. Users should only download code for the features they use.

  4. Dependency management is critical: Audit dependencies regularly, prefer tree-shakeable alternatives, and avoid importing entire libraries when you only need a few functions.

  5. Set and enforce budgets: Use CI/CD tools to track bundle sizes and prevent regression. A 5KB increase today becomes 500KB over a year of unchecked growth.

The performance of your web application directly impacts your users and your business. By implementing the strategies in this guide, you can ensure your bundles are lean, your load times are fast, and your users have the best possible experience.

Start analyzing your bundles today. The insights you gain will guide every optimization decision you make.