Build Optimization: The Multiplier Effect
Every kilobyte you remove from your JavaScript bundle has a multiplier effect: smaller download, faster parse, faster execution. On mobile networks, the difference between a 200KB and 600KB bundle can be the difference between a user staying or leaving.
This guide covers the techniques that matter most, with concrete before/after examples.
Understanding JavaScript Bundle Costs
JavaScript is the most expensive resource type: every byte must be downloaded, decompressed, parsed, compiled, and executed. Images are expensive to download but cheap to parse. JS is expensive at every step.
100KB of JavaScript (gzipped, ~30KB) costs:
1. Network: 30KB download
2. Decompress: CPU + memory
3. Parse: ~80ms on mid-tier phone
4. Compile: ~120ms on mid-tier phone
5. Execute: depends on code complexity
100KB of images: only the download cost
The benchmark: < 200KB gzipped JS for initial load is a reasonable target for most applications.
Webpack Code Splitting
Code splitting divides your bundle into chunks loaded on demand:
// webpack.config.js
module.exports = {
entry: {
main: './src/index.ts',
},
optimization: {
splitChunks: {
chunks: 'all', // Split async AND sync chunks
cacheGroups: {
// Vendor chunk: libraries that rarely change
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendors',
chunks: 'all',
priority: 10,
},
// React-specific chunk
react: {
test: /[\/]node_modules[\/](react|react-dom|react-router)[\/]/,
name: 'react-vendor',
chunks: 'all',
priority: 20, // Higher priority = takes precedence
},
// Large, infrequently-used libraries
charts: {
test: /[\/]node_modules[\/](chart.js|recharts|d3)[\/]/,
name: 'charts-vendor',
chunks: 'async', // Only when dynamically imported
priority: 20,
},
// Common code shared between multiple entry points
common: {
name: 'common',
minChunks: 2, // Used in at least 2 chunks
priority: 5,
reuseExistingChunk: true,
},
},
},
// Content hash for long-term caching
chunkIds: 'deterministic',
moduleIds: 'deterministic',
},
output: {
filename: '[name].[contenthash].js', // main.abc123.js
chunkFilename: '[name].[contenthash].js', // vendors.def456.js
},
}
Dynamic Imports: Route-Based Splitting
// React Router with route-level code splitting
import { lazy, Suspense } from 'react'
// Each route becomes a separate chunk
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() =>
import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
)
const AdminPanel = lazy(() =>
import(/* webpackChunkName: "admin" */ './pages/AdminPanel')
)
// Preload on hover (reduces perceived loading time)
function NavLink({ to, prefetch, children }) {
const handleMouseEnter = () => {
if (prefetch) {
// Webpack magic comment: starts downloading without rendering
import(/* webpackPrefetch: true */ `./pages/${to}`)
}
}
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
)
}
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
)
}
Tree Shaking: Remove Dead Code
Tree shaking eliminates unused exports. But it requires proper configuration:
// package.json — signal that your code is side-effect free
{
"sideEffects": false // Everything can be tree-shaken
// OR specify files with side effects:
"sideEffects": [
"*.css",
"src/polyfills.js"
]
}
// webpack.config.js — tree shaking only works in production mode
module.exports = {
mode: 'production', // Enables TerserPlugin (minification) + tree shaking
optimization: {
usedExports: true, // Mark used exports
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
dead_code: true, // Remove dead code
pure_funcs: ['console.log'], // Remove specific function calls
},
},
}),
],
},
}
Common Tree Shaking Failures
// ❌ Default export prevents tree shaking of utilities
// utils.js
export default {
formatDate,
formatCurrency,
formatBytes,
}
// ❌ Import entire module:
import utils from './utils'
utils.formatDate(date) // formatCurrency and formatBytes still bundled!
// ✅ Named exports enable tree shaking
// utils.js
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function formatBytes(bytes) { /* ... */ }
// ✅ Import only what you need
import { formatDate } from './utils'
// formatCurrency and formatBytes are tree-shaken away
// ❌ CommonJS (require) is not tree-shakeable
const { formatDate } = require('./utils') // Entire module is included
// ✅ ES modules (import) are tree-shakeable
import { formatDate } from './utils'
Bundle Analysis
# Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
# In webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
module.exports = {
plugins: [
process.env.ANALYZE && new BundleAnalyzerPlugin({
analyzerMode: 'static', // Generates HTML report
openAnalyzer: true,
reportFilename: 'bundle-report.html',
})
].filter(Boolean),
}
# Run:
ANALYZE=true webpack build
# For Vite:
npm install --save-dev rollup-plugin-visualizer
# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({
filename: 'stats.html',
open: true,
gzipSize: true,
brotliSize: true,
})
],
})
# Source Map Explorer (works with any bundler)
npm install --save-dev source-map-explorer
npx source-map-explorer dist/main.*.js
What to look for in the analyzer:
- Large libraries you didn't know were included (e.g., lodash included fully when you only use one function)
- Duplicate modules (same library bundled multiple times)
- Dev-only code in production bundle
- Unexpectedly large vendor chunks
Vite Build Optimization
// vite.config.ts
import { defineConfig, splitVendorChunkPlugin } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
splitVendorChunkPlugin(), // Splits vendor/app chunks automatically
],
build: {
target: 'es2020', // Modern browsers — smaller output
minify: 'esbuild', // Fast minification (esbuild) or thorough (terser)
sourcemap: true, // For debugging (don't deploy to public)
rollupOptions: {
output: {
// Manual chunk splitting
manualChunks: (id) => {
if (id.includes('node_modules')) {
// React ecosystem together
if (id.includes('react') || id.includes('react-dom')) {
return 'react'
}
// Heavy libraries as their own chunk
if (id.includes('monaco-editor')) return 'monaco'
if (id.includes('chart.js') || id.includes('recharts')) return 'charts'
if (id.includes('@tanstack')) return 'tanstack'
// Everything else: vendors
return 'vendors'
}
},
// Asset naming with content hash
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
},
},
// Warn if chunk exceeds this size
chunkSizeWarningLimit: 500, // KB
},
// Dependency pre-bundling
optimizeDeps: {
include: ['react', 'react-dom'], // Pre-bundle for faster dev
exclude: ['@vite/client', '@vite/env'], // Don't pre-bundle
},
})
Compression and Asset Optimization
// Gzip/Brotli compression in webpack
const CompressionPlugin = require('compression-webpack-plugin')
const zlib = require('zlib')
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
test: /.(js|css|html|svg)$/,
threshold: 10240, // Only compress files > 10KB
minRatio: 0.8, // Only compress if 20%+ savings
}),
new CompressionPlugin({
algorithm: 'brotliCompress',
test: /.(js|css|html|svg)$/,
compressionOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } },
filename: '[path][base].br',
deleteOriginalAssets: false,
}),
]
// Nginx: serve pre-compressed files
// nginx.conf
// gzip_static on;
// brotli_static on;
Measuring Build Performance
# Webpack build time analysis
npx webpack --profile --json > stats.json
# Upload to https://webpack.github.io/analyse/
# Which loaders/plugins are slow?
npm install --save-dev speed-measure-webpack-plugin
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = smp.wrap(webpackConfig)
# Vite build timing
vite build --reporter=verbose
# Track bundle size over time (CI)
npx bundlewatch --config .bundlewatchrc.js
# Fails CI if bundle exceeds defined thresholds
// .bundlewatchrc.js
{
"files": [
{ "path": "dist/assets/main-*.js", "maxSize": "200kB" },
{ "path": "dist/assets/vendors-*.js", "maxSize": "300kB" },
{ "path": "dist/assets/*.css", "maxSize": "50kB" }
],
"ci": {
"trackBranches": ["main"],
"repoBranchIsBaseline": true
}
}
Build optimization is an investment that pays dividends on every page load for the life of the application. The combination of route-based code splitting, tree shaking, vendor chunk separation, and compression typically reduces initial bundle size by 50-70% compared to unoptimized builds.
→ Convert numbers between binary, octal, decimal, and hexadecimal with the Integer Base Converter.