ention.
Core Solution
Optimizing bundle size requires a layered approach that addresses build configuration, module boundaries, dependency management, and delivery strategy. The following implementation targets a modern TypeScript frontend using Vite, but the principles apply to Webpack, Rollup, and esbuild.
Step 1: Baseline Audit & Chunk Graph Analysis
Before modifying configuration, establish a measurable baseline. Use rollup-plugin-visualizer to generate a treemap of chunk composition and dependency weight.
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react(), visualizer({
filename: 'dist/stats.html',
open: false,
gzipSize: true,
brotliSize: true,
})],
build: {
sourcemap: 'hidden',
rollupOptions: {
output: {
manualChunks: undefined, // Disable defaults to see raw module graph
},
},
},
});
Run vite build and open dist/stats.html. Identify modules exceeding 50KB, duplicate dependencies, and heavy third-party libraries. This audit dictates where splitting and pruning will yield maximum ROI.
Step 2: Explicit Tree-Shaking & Side Effects Control
Bundlers only eliminate unused exports if modules are marked as side-effect free. Many libraries ship CommonJS or omit sideEffects declarations, causing conservative bundling.
// package.json (example for a utility library)
{
"name": "@acme/utils",
"sideEffects": false,
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
},
"./lodash/*": {
"import": "./dist/lodash/*.esm.js"
}
}
}
In your application, enforce explicit imports:
// β Pulls entire library
import { debounce, throttle, chunk } from 'lodash';
// β
Only includes used functions
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import chunk from 'lodash/chunk';
For TypeScript projects, configure tsconfig.json to output ES modules:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2020"
}
}
Step 3: Strategic Code Splitting
Route-level splitting aligns chunk boundaries with user navigation. Component-level splitting should be reserved for heavy, non-critical UI (modals, charts, editors).
// routes.tsx
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
export const router = createBrowserRouter([
{ path: '/dashboard', element: <Dashboard /> },
{ path: '/settings', element: <Settings /> },
{ path: '/reports', element: <Reports /> },
]);
Configure Vite to split vendor dependencies and route chunks:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['@headlessui/react', 'framer-motion'],
analytics: ['@amplitude/analytics-browser', 'posthog-js'],
},
},
},
},
});
Architecture rationale: Splitting by vendor isolates stable dependencies that change infrequently, maximizing HTTP cache longevity. Route-level chunks ensure users only download code for visited pages. Avoid splitting every component; excessive chunks increase HTTP request overhead and negate compression gains.
Step 4: Dependency Pruning & Modernization
Replace heavy libraries with lightweight or native alternatives. Audit dependencies quarterly.
| Heavy Library | Lightweight Alternative | Size Reduction |
|---|
moment | date-fns or Intl.DateTimeFormat | ~70% |
lodash | Native ES2020+ or lodash-es | ~60% |
chart.js | d3 (tree-shaken) or uPlot | ~50% |
uuid | crypto.randomUUID() (native) | ~90% |
Implement deduplication to prevent multiple versions of the same package:
// vite.config.ts
export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom'],
},
});
Step 5: Compression & Delivery Strategy
Enable Brotli compression at the build level and configure CDN caching headers.
// vite.config.ts
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240, // Compress files > 10KB
}),
],
});
Pair with HTTP/2 or HTTP/3 and immutable caching for hashed assets:
# nginx.conf example
location ~* \.(js|css|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Content-Encoding br;
}
Architecture rationale: Compression reduces transfer size by 30-50% over Gzip. Immutable caching ensures that only changed chunks are re-downloaded. HTTP/2 multiplexing eliminates head-of-line blocking, making multiple small chunks viable without performance penalties.
Pitfall Guide
-
Over-splitting into micro-chunks: Creating dozens of sub-5KB chunks increases HTTP request overhead, DNS lookups, and TLS handshakes. The browser's main thread spends more time coordinating chunk loading than executing code. Best practice: Keep chunks above 20KB after compression. Use manualChunks to group related modules.
-
Assuming tree-shaking works automatically: Many libraries use side effects (global polyfills, CSS imports, IIFE wrappers) that prevent dead code elimination. If sideEffects is missing or set to true, bundlers include the entire module. Best practice: Audit node_modules for missing sideEffects flags. Use import paths that target ESM builds. Prefer libraries with explicit tree-shaking support.
-
Ignoring third-party vendor bloat: Analytics SDKs, UI component kits, and date/time libraries are frequent culprits. Teams bundle them once and forget. Best practice: Load non-critical third-party scripts via async or defer. Use dynamic imports for heavy UI components. Replace monolithic kits with headless alternatives or native APIs.
-
Dynamic imports on the critical path: Using lazy() for above-the-fold components causes layout shift, increases INP, and delays interactivity. Best practice: Reserve dynamic imports for below-the-fold content, modals, or user-initiated actions. Preload critical routes using <link rel="modulepreload"> or React.lazy with Suspense fallbacks that reserve layout space.
-
Disabling source maps in production for debugging: While source maps increase build size, disabling them entirely hinders error tracking. Best practice: Use sourcemap: 'hidden' in Vite. Upload maps to Sentry or Datadog via CI. Keep production payloads minimal while retaining debuggability.
-
Not mocking dev-only code: console.log, React DevTools hooks, and strict mode checks often leak into production builds if not explicitly stripped. Best practice: Use define in Vite to replace dev constants. Configure terser or esbuild to drop console.* calls. Validate with NODE_ENV=production builds.
-
Relying on DevTools throttling for validation: Chrome's network throttling simulates latency but not real CPU constraints on low-end devices. Best practice: Test on physical devices or use Lighthouse CI with throttlingMethod: 'simulate'. Monitor Real User Monitoring (RUM) data for accurate TTI and LCP metrics.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing site / static content | Single chunk + Brotli + aggressive CDN caching | Minimal interactivity; caching dominates performance | Low infra cost, high cache hit rate |
| SaaS dashboard / data-heavy app | Route-level splitting + vendor isolation + dynamic heavy components | Users navigate selectively; defer non-critical UI | Moderate build complexity, high TTI improvement |
| E-commerce / conversion-critical | Critical CSS inlined + route splitting + preload + RUM monitoring | LCP and INP directly impact revenue; caching must be precise | Higher initial dev time, significant conversion lift |
| Internal tool / low-traffic app | Conservative splitting + sourcemaps enabled | Debuggability outweighs payload size; internal users tolerate slower loads | Minimal optimization cost, faster dev iteration |
Configuration Template
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
react(),
visualizer({
filename: 'dist/stats.html',
open: false,
gzipSize: true,
brotliSize: true,
}),
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240,
}),
],
build: {
sourcemap: 'hidden',
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['@headlessui/react', 'framer-motion'],
analytics: ['@amplitude/analytics-browser', 'posthog-js'],
},
},
},
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
__DEV__: false,
},
resolve: {
dedupe: ['react', 'react-dom'],
},
});
Quick Start Guide
- Install audit plugins: Run
npm i -D rollup-plugin-visualizer vite-plugin-compression and add them to vite.config.ts as shown in the template.
- Enable route splitting: Replace static imports in your router with
lazy(() => import('./path')) and wrap routes in <Suspense fallback={<Loading />}>.
- Configure chunk boundaries: Add
manualChunks to isolate vendor, ui, and third-party dependencies. Run vite build and verify chunk sizes in dist/stats.html.
- Enforce in CI: Add a build step that fails if initial JS exceeds 200KB. Use
ls -la dist/assets/*.js | awk '{sum += $5} END {print sum/1024 "KB"}' or integrate @rollup/plugin-size-snapshot for automated budget checks.