Front-end Engineering Lab

Library Splitting (Vendor Chunks)

Separate third-party libraries for better caching and performance

Library Splitting (Vendor Chunks)

Split third-party libraries (vendor code) from your application code for better caching and optimal bundle sizes.

🎯 Why Split Vendor Code?

Without Vendor Splitting:
main.js: 2.5 MB (app + vendors)
└─ Changes frequently
└─ Users redownload everything on every deploy

With Vendor Splitting:
main.js: 300 KB (app code only)
├─ Changes frequently
vendor.js: 500 KB (React, etc)
├─ Changes rarely → cached!
charts.js: 400 KB (Chart.js)
└─ Changes never → cached forever!

Result: 80% less redownloads on updates! ✅

📦 Webpack Configuration

Basic Vendor Split

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // React vendor chunk
        vendor: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'vendor',
          priority: 10
        },
        
        // Other node_modules
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'commons',
          priority: 5
        }
      }
    }
  }
};
// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: 25, // Allow more chunks
      minSize: 20000, // Min 20 KB per chunk
      cacheGroups: {
        // React core (rarely changes)
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
          name: 'vendor-react',
          priority: 40
        },
        
        // UI libraries
        ui: {
          test: /[\\/]node_modules[\\/](@radix-ui|@headlessui)[\\/]/,
          name: 'vendor-ui',
          priority: 35
        },
        
        // Charts (heavy!)
        charts: {
          test: /[\\/]node_modules[\\/](chart\.js|react-chartjs-2)[\\/]/,
          name: 'vendor-charts',
          priority: 30
        },
        
        // Date utilities
        dates: {
          test: /[\\/]node_modules[\\/](date-fns|dayjs|moment)[\\/]/,
          name: 'vendor-dates',
          priority: 25
        },
        
        // Utils
        utils: {
          test: /[\\/]node_modules[\\/](lodash|ramda|underscore)[\\/]/,
          name: 'vendor-utils',
          priority: 20
        },
        
        // Remaining vendors
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10
        }
      }
    }
  }
};

⚡ Vite Configuration

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // React vendor
          'vendor-react': ['react', 'react-dom', 'react-router-dom'],
          
          // UI libraries
          'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
          
          // Heavy libraries
          'vendor-charts': ['chart.js', 'react-chartjs-2'],
          'vendor-editor': ['slate', 'slate-react'],
          
          // Utilities
          'vendor-utils': ['lodash-es', 'date-fns'],
          
          // Icons
          'vendor-icons': ['lucide-react', '@heroicons/react']
        }
      }
    },
    
    // Chunk size warnings
    chunkSizeWarningLimit: 500, // Warn if chunk > 500 KB
  }
};

🎯 Strategy: Split by Update Frequency

// Group by how often they change
manualChunks: {
  // NEVER changes (cache forever)
  'vendor-stable': [
    'react',
    'react-dom'
  ],
  
  // RARELY changes (cache 1 year)
  'vendor-libs': [
    'lodash-es',
    'date-fns',
    'axios'
  ],
  
  // SOMETIMES changes (cache 1 month)
  'vendor-ui': [
    '@radix-ui/react-dialog',
    '@headlessui/react'
  ],
  
  // OFTEN changes (cache 1 week)
  'vendor-internal': [
    '@company/design-system',
    '@company/utils'
  ]
}

📊 Analyzing Bundle Size

Webpack Bundle Analyzer

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // Generates HTML file
      openAnalyzer: false,
      reportFilename: 'bundle-report.html'
    })
  ]
};
npm run build
# Opens bundle-report.html showing treemap

Vite Bundle Visualizer

npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({
      open: true,
      gzipSize: true,
      brotliSize: true,
    })
  ]
};

🎨 Dynamic Vendor Splitting

// Split vendors dynamically based on size
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // Node modules
          if (id.includes('node_modules')) {
            // Large libraries get their own chunk
            if (id.includes('chart.js')) {
              return 'vendor-charts';
            }
            if (id.includes('monaco-editor')) {
              return 'vendor-editor';
            }
            if (id.includes('@tensorflow')) {
              return 'vendor-ml';
            }
            
            // Group small vendors together
            return 'vendor';
          }
        }
      }
    }
  }
};

⚙️ Next.js Optimization

Next.js has good defaults, but you can optimize further:

// next.config.js
module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          // React/Next.js framework
          framework: {
            test: /[\\/]node_modules[\\/](react|react-dom|next)[\\/]/,
            name: 'framework',
            priority: 40
        },
          
          // Heavy libraries
          charts: {
            test: /[\\/]node_modules[\\/](chart\.js)[\\/]/,
            name: 'charts',
            priority: 30
          },
          
          // Default vendors
          vendors: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            priority: 20
          }
        }
      };
    }
    
    return config;
  }
};

🔍 Finding Heavy Dependencies

# Install dependency analysis tool
npm install -g cost-of-modules

# Run analysis
cost-of-modules

# Output:
# chart.js: 421 KB
# moment: 289 KB ← Replace with date-fns!
# lodash: 71 KB ← Use lodash-es + tree-shaking!

Replace Heavy Dependencies

// ❌ BAD: Moment.js (289 KB!)
import moment from 'moment';
moment().format('YYYY-MM-DD');

// ✅ GOOD: date-fns (13 KB!)
import { format } from 'date-fns';
format(new Date(), 'yyyy-MM-dd');

// ❌ BAD: Lodash (71 KB!)
import _ from 'lodash';
_.map(array, fn);

// ✅ GOOD: Lodash-es (tree-shakeable!)
import { map } from 'lodash-es';
map(array, fn);

🎯 Chunk Loading Strategy

// Prioritize chunk loading
export default {
  build: {
    rollupOptions: {
      output: {
        // Name chunks by priority
        chunkFileNames: (chunkInfo) => {
          if (chunkInfo.name === 'vendor-react') {
            return 'assets/critical-[name]-[hash].js';
          }
          if (chunkInfo.name.startsWith('vendor-')) {
            return 'assets/vendor-[name]-[hash].js';
          }
          return 'assets/[name]-[hash].js';
        }
      }
    }
  }
};

Preload Critical Vendors

<!-- index.html -->
<head>
  <!-- Preload critical vendor chunks -->
  <link rel="preload" href="/assets/critical-vendor-react.js" as="script">
  <link rel="preload" href="/assets/vendor-ui.js" as="script">
  
  <!-- Prefetch less critical -->
  <link rel="prefetch" href="/assets/vendor-charts.js" as="script">
</head>

📚 Best Practices

1. Target Chunk Sizes

Ideal chunk sizes:
- Main bundle: < 200 KB (gzipped)
- Vendor chunks: < 150 KB each (gzipped)
- Route chunks: < 100 KB each (gzipped)

If vendor > 150 KB → Split further!

2. Cache Strategy

Set cache headers:
- vendor-react.js: Cache 1 year (rarely changes)
- vendor-libs.js: Cache 6 months
- main.js: Cache 1 day (changes often)

3. Monitor Over Time

# Add to package.json
"scripts": {
  "build:analyze": "npm run build && webpack-bundle-analyzer stats.json"
}

# Run weekly
npm run build:analyze

# Check for:
# - Chunks > 500 KB (too big!)
# - Duplicate dependencies
# - Unused code

🏢 Real-World Examples

Shopify

// Shopify splits:
// - React core (stable)
// - Polaris UI (updates monthly)
// - Charts (lazy loaded)
// - App code (updates daily)

Stripe

// Stripe splits:
// - React/framework (cached forever)
// - Stripe.js (rarely changes)
// - Dashboard code (changes often)
// - Analytics libs (lazy loaded)

📚 Key Takeaways

  1. Split by update frequency - Cache what doesn't change
  2. Target < 150 KB per chunk - Gzipped size
  3. Analyze your bundle - Use bundle analyzer
  4. Replace heavy deps - Moment → date-fns, Lodash → Lodash-es
  5. Preload critical chunks - React, UI libs
  6. Prefetch heavy chunks - Charts, editors
  7. Monitor over time - Bundle size grows quickly

Vendor splitting typically saves 60-70% bandwidth on updates. Users only redownload your app code! 🚀

On this page