Bundle Analysis: Tools and Techniques That Cut Our React App Size by 70%

9 min read1748 words

Our React application was hemorrhaging performance. Initial bundle size hit 2.3MB, First Contentful Paint averaged 8.2 seconds on mobile, and users were abandoning the app before it even loaded.

Six months later, we reduced the bundle to 680KB - a 70% improvement - and slashed load times to under 2 seconds. Here's the systematic bundle analysis approach that transformed our application performance.

The Bundle Performance Crisis

Before diving into bundle analysis, our React dashboard application suffered from classic performance problems:

  • Initial bundle size: 2.3MB (uncompressed)
  • First Contentful Paint: 8.2s on slow 3G
  • Time to Interactive: 12.4s on mobile
  • Bounce rate: 43% of users left before the app loaded

The worst part? We had no visibility into what was causing these massive bundle sizes.

Setting Up Comprehensive Bundle Analysis

Webpack Bundle Analyzer: Your First Line of Defense

The webpack-bundle-analyzer creates an interactive treemap that visualizes every module in your bundle. This became our primary diagnostic tool.

// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
 
module.exports = (env, argv) => {
  const shouldAnalyze = process.env.ANALYZE === 'true';
 
  return {
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          // Separate vendor libraries
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            priority: 10,
          },
          // Large libraries get their own chunk
          react: {
            test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
            name: 'react-vendor',
            priority: 20,
          },
        },
      },
    },
 
    plugins: [
      shouldAnalyze && new BundleAnalyzerPlugin({
        analyzerMode: 'static',
        openAnalyzer: false,
        reportFilename: 'bundle-report.html',
        generateStatsFile: true,
        statsFilename: 'bundle-stats.json',
      }),
    ].filter(Boolean),
  };
};

Running ANALYZE=true npm run build generates an HTML report showing exactly where your bytes are going. The treemap immediately revealed our biggest problems:

  • Ant Design: 847KB (full library imported)
  • Moment.js: 329KB (with all locales)
  • Lodash: 234KB (entire library imported)
  • Duplicate React: 312KB (two versions loaded)

Next.js Built-in Analysis

For Next.js projects, the official bundle analyzer integrates seamlessly:

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});
 
module.exports = withBundleAnalyzer({
  experimental: {
    optimizePackageImports: ['antd', 'date-fns', 'lodash']
  }
});

This provides both client and server bundle analysis, crucial for SSR applications.

Tree Shaking: Eliminating Dead Code

Tree shaking removes unused exports from your final bundle, but only if you structure imports correctly.

Before: Bundle Bloat from Poor Imports

// ❌ Bad: Imports entire libraries (800KB+ total)
import _ from 'lodash';
import * as dateFns from 'date-fns';
import moment from 'moment';
import Antd from 'antd';
 
const { Button, Modal, Table } = Antd;
 
const processData = (data) => {
  return _.debounce(() => {
    return dateFns.format(moment(), 'YYYY-MM-DD');
  }, 300);
};

After: Selective Imports

// ✅ Good: Selective imports (25KB total)
import debounce from 'lodash/debounce';
import { format } from 'date-fns';
import { Button, Modal, Table } from 'antd';
 
const processDataOptimized = (data) => {
  return debounce(() => {
    return format(new Date(), 'yyyy-MM-dd');
  }, 300);
};

This single change saved us 775KB in the final bundle.

Configuring Babel for Better Tree Shaking

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      modules: false, // Enable tree shaking
      useBuiltIns: 'usage',
      corejs: 3
    }]
  ],
  plugins: [
    ['babel-plugin-import', {
      libraryName: 'antd',
      libraryDirectory: 'es',
      style: true
    }, 'antd']
  ]
};

With this configuration, import { Button } from 'antd' automatically becomes import Button from 'antd/es/button', dramatically reducing bundle size.

Strategic Code Splitting Implementation

Code splitting breaks your application into smaller chunks that load on demand. We implemented three levels of splitting:

1. Route-Based Splitting

import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
 
// Each route becomes a separate chunk
const HomePage = lazy(() => import('./pages/HomePage'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
 
export default function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

This reduced our initial bundle from 2.3MB to 890KB, moving route-specific code into separate chunks.

2. Component-Based Splitting

Heavy components that aren't immediately visible get lazy loaded:

export function Dashboard({ user }) {
  const [showChart, setShowChart] = useState(false);
  const [ChartComponent, setChartComponent] = useState(null);
 
  const loadChart = async () => {
    if (!ChartComponent) {
      // Only load Chart.js when user requests it
      const { default: Chart } = await import('./components/HeavyChart');
      setChartComponent(() => Chart);
    }
    setShowChart(true);
  };
 
  return (
    <div>
      <h1>Dashboard</h1>
      <button onClick={loadChart}>Load Analytics Chart</button>
      
      {showChart && ChartComponent && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <ChartComponent data={user.analytics} />
        </Suspense>
      )}
    </div>
  );
}

This pattern removed Chart.js (127KB) from our initial bundle, loading it only when users accessed the analytics section.

3. Library-Level Code Splitting

We created a library loader pattern for heavy dependencies:

export class LibraryLoader {
  static async loadDateLibrary() {
    const { default: dayjs } = await import('dayjs');
    await import('dayjs/plugin/relativeTime');
    return dayjs;
  }
  
  static async loadChartLibrary() {
    const { Chart, registerables } = await import('chart.js');
    Chart.register(...registerables);
    return Chart;
  }
}

This approach deferred 340KB of date and charting libraries until actually needed.

Performance Measurement and Monitoring

Key Metrics We Tracked

I established a performance monitoring system focusing on these metrics:

  • Bundle Size: Parsed and gzipped sizes for each chunk
  • First Contentful Paint (FCP): Time to visible content
  • Time to Interactive (TTI): When the app becomes usable
  • Largest Contentful Paint (LCP): Main content visibility
  • Total Blocking Time (TBT): Script processing delays

Before and After Results

| Metric | Before | After | Improvement | |--------|---------|--------|-------------| | Initial Bundle | 2.3MB | 680KB | 70% reduction | | FCP (Mobile) | 8.2s | 1.9s | 77% improvement | | TTI (Mobile) | 12.4s | 3.1s | 75% improvement | | LCP | 9.1s | 2.3s | 75% improvement |

CI/CD Bundle Monitoring

We integrated bundle size monitoring into our CI/CD pipeline to prevent performance regressions:

# .github/workflows/bundle-analysis.yml
name: Bundle Size Analysis
 
on:
  pull_request:
    branches: [main]
 
jobs:
  bundle-analysis:
    runs-on: ubuntu-latest
    
    steps:
      - name: Build and analyze
        run: npm run build:analyze
        
      - name: Check bundle size limits
        uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}

This automatically fails builds if bundle size increases beyond our thresholds and posts bundle analysis comments on pull requests.

Real-World Bundle Optimization Wins

Component Library Migration

Our biggest single improvement came from switching component libraries:

Before (Ant Design):

  • Bundle size: 847KB
  • Tree shaking: Limited
  • Load impact: 2.1s additional FCP

After (Radix UI + Tailwind):

  • Bundle size: 127KB
  • Tree shaking: Excellent
  • Load impact: 0.3s additional FCP

This migration alone saved 720KB and improved FCP by 1.8 seconds.

Dependency Audit Results

I conducted a comprehensive dependency audit that revealed surprising bloat:

  • Moment.js → date-fns: 329KB → 34KB (89% reduction)
  • Full Lodash → Selective imports: 234KB → 18KB (92% reduction)
  • Duplicate React versions: Eliminated 312KB of duplicate code
  • Unused polyfills: Removed 89KB of IE11 polyfills we didn't need

Advanced Loading Strategies

We implemented intelligent preloading for frequently accessed routes:

export function useSmartPreloading() {
  useEffect(() => {
    // Preload likely-needed components on hover
    const preloadUserProfile = () => import('./pages/UserProfile');
    const preloadAnalytics = () => import('./components/Analytics');
    
    const profileLink = document.querySelector('[data-route="profile"]');
    profileLink?.addEventListener('mouseenter', preloadUserProfile);
    
    // Preload during idle time
    if ('requestIdleCallback' in window) {
      requestIdleCallback(preloadAnalytics);
    }
    
    return () => {
      profileLink?.removeEventListener('mouseenter', preloadUserProfile);
    };
  }, []);
}

This strategy reduced perceived load times by preloading components users were likely to navigate to next.

Modern Bundler Comparison for 2025

Webpack 5 vs Vite vs Turbopack

After testing all three bundlers with our application:

Webpack 5:

  • Mature ecosystem with extensive plugin support
  • Excellent code splitting and chunk optimization
  • Build time: 2.1 minutes for production
  • Bundle analysis: Requires plugin setup

Vite:

  • Lightning-fast development experience
  • Excellent tree shaking with Rollup
  • Build time: 47 seconds for production
  • Bundle analysis: Simple plugin integration

Turbopack (Preview):

  • Rust-based performance improvements
  • Built-in bundle analysis dashboard
  • Build time: 23 seconds for production
  • Still experimental but promising

For our production application, Vite provided the best balance of build speed, bundle optimization, and developer experience.

Common Bundle Analysis Mistakes to Avoid

Ignoring Duplicate Dependencies

Bundle analyzers reveal duplicate dependencies that package managers sometimes create:

# Check for duplicates
npm ls --depth=0
npx npm-check-duplicates

We found two versions of React (16.14.0 and 17.0.2) and three versions of Lodash being bundled simultaneously.

Importing Development Dependencies in Production

// ❌ Bad: Includes dev tools in production
import { devTools } from './utils/devTools';
 
if (process.env.NODE_ENV === 'development') {
  devTools.enable();
}

Even though the code is conditionally executed, the entire devTools module gets bundled. Use dynamic imports instead:

// ✅ Good: Only bundles dev tools in development
if (process.env.NODE_ENV === 'development') {
  import('./utils/devTools').then(({ devTools }) => {
    devTools.enable();
  });
}

Not Configuring Tree Shaking Properly

Many libraries require specific configuration to enable tree shaking:

// package.json
{
  "sideEffects": false, // Enable aggressive tree shaking
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js" // Prefer ES modules
}

Automated Bundle Health Monitoring

I created a custom bundle analysis script that runs after every build:

// scripts/bundle-analysis.js
function analyzeBundleSize() {
  const stats = JSON.parse(fs.readFileSync('dist/bundle-stats.json'));
  
  const analysis = {
    totalSize: stats.assets.reduce((sum, asset) => sum + asset.size, 0),
    largestModules: Object.values(stats.modules)
      .sort((a, b) => b.size - a.size)
      .slice(0, 10),
    recommendations: generateOptimizationTips(stats)
  };
  
  if (analysis.totalSize > BUNDLE_SIZE_LIMIT) {
    process.exit(1); // Fail build if bundle too large
  }
  
  return analysis;
}

This script automatically identifies bundle bloat and suggests specific optimizations based on the current bundle composition.

The Long-Term Performance Strategy

Bundle optimization isn't a one-time effort. We established these ongoing practices:

Weekly Bundle Audits

Every sprint, we review bundle analyzer reports and identify optimization opportunities. This prevents gradual size creep as new features get added.

Performance Budgets

We set strict limits for different bundle categories:

  • Main bundle: 250KB maximum
  • Vendor bundle: 400KB maximum
  • Total initial load: 650KB maximum

Team Education

I created bundle analysis guidelines for the development team:

  1. Always use selective imports from large libraries
  2. Lazy load components that aren't immediately visible
  3. Review bundle reports before merging large features
  4. Prefer lightweight alternatives when possible

The investment in bundle analysis and optimization transformed our application performance and user experience. Users now experience sub-2-second load times, and our bounce rate dropped from 43% to 12%.

Bundle analysis isn't just about smaller files - it's about creating web applications that respect users' time and data constraints while delivering exceptional performance.