Heavy JS bundles cost more than just bandwidth — they cost parse time, compile time, and Time To Interactive on every page load. Mobile devices feel the parsing cost most: a 500 KB bundle can take 2-4 seconds to parse on a mid-range phone. The cure is auditing what's in the bundle, tree-shaking, replacing heavy libraries with lighter alternatives, and code-splitting per route. Typical sites cut bundle size 50-70% applying these techniques. For related fixes, see the JS Checker Fixes index.
# webpack npm install --save-dev webpack-bundle-analyzer # Rollup / Vite npm install --save-dev rollup-plugin-visualizer # Next.js ANALYZE=true npm run build
Tree-shaking requires ES modules and named imports. CommonJS imports keep everything.
// Bad: imports all of lodash (24 KB+ per chunk)
import _ from 'lodash';
_.debounce(...);
// Better: named import (still imports lodash internally, doesn't tree-shake)
import { debounce } from 'lodash';
// Best: lodash-es supports tree-shaking
import { debounce } from 'lodash-es';
// Or: per-function import
import debounce from 'lodash/debounce';
// Bad: moment.js — 232 KB minified+gzipped with all locales
import moment from 'moment';
moment().format('YYYY-MM-DD');
// Good: date-fns — only the functions you use, ~5-15 KB total
import { format } from 'date-fns';
format(new Date(), 'yyyy-MM-dd');
// package.json
{
"sideEffects": false, // your code has no side-effect imports
// OR list specific files that DO have side effects
"sideEffects": ["*.css", "./src/polyfills.js"]
}
| Heavy | Lighter alternative |
|---|---|
| moment (~232 KB) | date-fns (~15 KB), dayjs (~7 KB), Temporal (native) |
| lodash (~24 KB) | lodash-es with tree shaking, native methods |
| axios (~13 KB) | native fetch (0 KB), ky (~5 KB) |
| jQuery (~30 KB) | native DOM APIs, querySelector, fetch |
| Chart.js (~60 KB) | Chartist (~15 KB), uPlot (~40 KB faster) |
| Animation libraries (~30-100 KB) | CSS transitions, Web Animations API (native) |
Check bundlephobia.com for size of any npm package and tree-shakeability.
// Static import: everything in initial bundle
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Reports from './pages/Reports';
// Dynamic import: each becomes its own chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
Build outputs separate JS files per route. User loading homepage doesn't download Settings or Reports code. Each navigation triggers loading only that route's chunk.
npx depcheck # Shows unused dependencies, missing dependencies, unused devDependenciesManually verify before removing — depcheck can miss dynamic imports.
npm ls --depth=2Look for: same library at multiple versions (caused by transitive dependencies), unexpected huge packages installed indirectly.
# nginx
brotli on;
brotli_types text/css application/javascript application/json text/html;
brotli_comp_level 6;
# Pre-compress at build time for static files
npm install --save-dev compression-webpack-plugin
new CompressionPlugin({
algorithm: 'brotliCompress',
test: /\.(js|css)$/
});
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Production build outputs main.abc123.js. Hash changes when content changes. Cache for a year because the file URL changes on update. Repeat visits pay zero JS download.
# Lighthouse CI
{
"ci": {
"assert": {
"assertions": {
"resource-summary:script:size": ["error", { "maxNumericValue": 200000 }]
}
}
}
}
Fails build if JS exceeds 200 KB. Keeps regressions out.