JavaScript is the most expensive asset on the web byte-for-byte. A 200KB image and a 200KB JavaScript file do not have the same cost. The image just needs to be decoded and painted. The JavaScript needs to be parsed, compiled, and executed, all on the main thread, all blocking interactivity. On a mid-range phone, 1MB of JavaScript can take 3-5 seconds just to parse.
This is why bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together. optimization matters so much. Every kilobyte of JavaScript you ship has a direct, measurable impact on how fast your page becomes interactive.
Understanding your bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together.
Before optimizing, you need to see what is in your bundle. Vite and webpack both have visualization tools that show you exactly which libraries and files are contributing to the total size.
# Vite - generates a visual treemap of your bundle
npx vite build
npx vite-bundle-visualizer
# Webpack - similar visualization
npx webpack-bundle-analyzer dist/stats.jsonThe first time you run a bundle analyzerWhat is bundle analyzer?A tool that visualizes the size and composition of your JavaScript bundle to identify what to optimize., you will almost certainly find surprises. A library you imported once for a single function might be contributing 100KB. A barrel import (import { x } from './utils') might be pulling in 50 files when you need 1.
The cost of JavaScript vs other assets
To understand why bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together. optimization matters more than optimizing other asset types, consider how the browser processes each one:
| Asset type | Download | Parse | Compile | Execute | Blocks main thread |
|---|---|---|---|---|---|
| Image | Yes | Decode (off-thread) | No | No | No |
| CSS | Yes | Parse CSSOM | No | No | No (after paint) |
| JavaScript | Yes | Parse AST | JIT compile | Execute | Yes |
| Font | Yes | Decode (off-thread) | No | No | No |
JavaScript is the only asset that blocks the main thread during every processing stage. A 200KB JS file costs 5-10x more in time-to-interactive than a 200KB image.
Tree shakingWhat is tree shaking?A build tool feature that removes unused exported code from your final bundle, reducing the amount of JavaScript shipped to users.
Tree shaking is the process of removing unused code from your final bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together.. Modern bundlers (Vite, webpack, Rollup) do this automatically, but only when your code uses ES moduleWhat is es modules?The official JavaScript module standard using import and export - enabled in Node.js via "type": "module" in package.json. syntax.
// Tree-shakeable: bundler can see you only use 'debounce'
import { debounce } from './utils';
// NOT tree-shakeable: bundler can't analyze dynamic access
const utils = require('./utils');
const debounce = utils.debounce;| Pattern | Tree-shakeable? | Why |
|---|---|---|
import { fn } from 'lib' | Yes | Static analysis can track the reference |
import * as lib from 'lib' | Partially | Only if you use specific properties |
require('lib') | No | CommonJS is dynamic, cannot be statically analyzed |
import lib from 'lib' (default) | Depends | Works if the library is properly structured |
Barrel fileWhat is barrel file?An index.js that re-exports from multiple modules in one place so consumers can import everything from a single path - can hurt tree shaking if overused. problem
Barrel files (an index.ts that re-exports everything from a directory) can defeat tree shaking:
// utils/index.ts - barrel file
export { formatDate } from './date';
export { formatCurrency } from './currency';
export { parseCSV } from './csv'; // 50KB dependency
export { renderChart } from './chart'; // 200KB dependency
// Your code - you only need formatDate
import { formatDate } from './utils';
// But depending on the bundler, you might get ALL of the aboveThe fix is to import directly from the source file:
// Direct import - guaranteed to only include what you need
import { formatDate } from './utils/date';utils/index.ts with 20 exports, importing one function can pull in all 20 and their dependencies. Always check if a barrel import is hiding unnecessary code in your bundle.Replacing heavy libraries
One of the highest-leverage optimizations is replacing heavy libraries with lighter alternatives that do the same thing. AI tools default to the most popular library, which is often not the lightest.
| Heavy library | Size (minified) | Lighter alternative | Size (minified) | Savings |
|---|---|---|---|---|
| moment.js | 67KB | date-fns (per function) | 2-5KB | 93% |
| lodash (full) | 70KB | lodash-es (per function) | 1-3KB | 96% |
| chart.js | 200KB | lightweight-charts | 45KB | 78% |
| axios | 13KB | Native fetch | 0KB | 100% |
| uuid | 12KB | crypto.randomUUID() | 0KB | 100% |
| classnames | 1.5KB | Template literal | 0KB | 100% |
// What AI generates - 67KB for one date format
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');
// What you should use - 2KB
import { format } from 'date-fns';
const formatted = format(date, 'yyyy-MM-dd');
// Even better - 0KB, built into the platform
const formatted = date.toISOString().split('T')[0];moment.js, axios, and full lodash imports because they dominate the training data. Every time AI adds one of these, check if a lighter alternative or a native API does the job. fetch() is built into every modern browser, you almost never need axios. crypto.randomUUID() replaces the entire uuid package. structuredClone() replaces lodash's cloneDeep.Dynamic imports for heavy features
Not every feature needs to be in the initial bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together.. If a feature is only used by some users or only appears after an interaction, load it dynamically:
// Bad: PDF library (500KB) loaded for every user
import { PDFDocument } from 'pdf-lib';
// Good: loaded only when user clicks "Export PDF"
async function exportPDF(data) {
const { PDFDocument } = await import('pdf-lib');
const doc = await PDFDocument.create();
// ... generate PDF
}This pattern works for any heavy feature: rich text editors, chart libraries, image processing, CSV parsing, or anything that only a subset of users will trigger.
Decision table: when to dynamically import
| Scenario | Static import | Dynamic import |
|---|---|---|
| Used on every page load | Yes | No |
| Used by <20% of users | No | Yes |
| Behind a user interaction (click, toggle) | No | Yes |
| Below the fold | No | Yes |
| Core UI framework (React, routing) | Yes | No |
| Feature-specific heavy library | No | Yes |
Analyzing import cost
Before adding any dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself., check its cost:
# Check bundle size impact of a package
npx bundlephobia <package-name>
# Or use the website: https://bundlephobia.com| What to check | Why |
|---|---|
| Minified size | Raw download cost |
| Gzipped size | Actual transfer cost |
| Tree-shakeable? | Can you import just what you need? |
| Dependencies | Does it pull in other heavy packages? |
| Native alternative? | Does the browser already do this? |
Code splittingWhat is code splitting?Breaking your application into smaller JavaScript chunks that load on demand so users only download the code needed for the page they're viewing. strategies
There are three main strategies for splitting your bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together.:
Route-based splitting
Each page gets its own chunk. This is the highest-impact, lowest-effort strategy:
// Each route is a separate chunk - users only download
// the code for the page they are visiting
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));Component-based splitting
Heavy components that are not visible on initial load get their own chunks:
// Modal content, charts, editors - anything below the fold
// or behind a user action
const RichTextEditor = lazy(() => import('./RichTextEditor'));
const ChartDashboard = lazy(() => import('./ChartDashboard'));Library-based splitting
Configure your bundler to put large third-party libraries in separate chunks:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'chart': ['chart.js'],
}
}
}
}
});Practical workflow
Here is the workflow for optimizing a bundleWhat is bundle?A single JavaScript file (or set of files) that a build tool creates by combining all your source code and its imports together.:
- Measure: Run bundle analyzerWhat is bundle analyzer?A tool that visualizes the size and composition of your JavaScript bundle to identify what to optimize. to see current state
- Identify: Find the largest contributors
- Replace: Swap heavy libraries for lighter alternatives or native APIs
- Split: Move heavy features behind dynamic imports
- Verify: Run bundle analyzer again to confirm improvement
- Budget: Set a bundle size limit in your CI to prevent regression
// Bundle optimization patterns
// 1. Replace heavy libraries with native APIs
// Instead of axios (13KB):
const response = await fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
// Instead of uuid (12KB):
const id = crypto.randomUUID();
// Instead of lodash.cloneDeep (15KB):
const clone = structuredClone(original);
// 2. Dynamic import pattern
async function handleExport(format) {
if (format === 'pdf') {
const { generatePDF } = await import('./exporters/pdf');
await generatePDF(data);
} else if (format === 'csv') {
const { generateCSV } = await import('./exporters/csv');
generateCSV(data);
}
}
// 3. Vite manual chunks configuration
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react')) return 'react-vendor';
if (id.includes('chart')) return 'chart-vendor';
return 'vendor';
}
}
}
}
}
});