Bundle Optimization
The less JavaScript a page ships, the faster it is downloaded, parsed and executed. The main techniques:
- Measure the production build with a bundle analyzer.
- Tree shaking: the bundler drops exports that nothing imports.
- Code splitting: dynamic
import()moves code into chunks that are loaded on demand. - Minification and compression, and lighter dependencies.
Details
Measuring
- Always look at the production build: a development build is not minified and contains extra code.
- An analyzer shows a treemap of what the bundle consists of:
webpack-bundle-analyzerfor webpack,rollup-plugin-visualizerfor Vite and Rollup,source-map-explorerfor any bundler that emits source maps.
Tree Shaking
- It relies on ES modules:
importandexportare static, so the bundler can see what is used. CommonJS (require) cannot be analyzed reliably. - It works in production builds of webpack, Rollup, Vite and esbuild.
- A library helps the bundler with
"sideEffects": falsein itspackage.json: a module that is imported or re-exported, but whose exports are not used, can then be dropped entirely. Files with side effects (CSS, polyfills) are listed as exceptions.
How the import style affects the bundle, with Lodash as an example:
| Import | What gets into the bundle |
|---|---|
import _ from 'lodash' | the whole library |
import { debounce } from 'lodash' | the whole library: lodash is CommonJS |
import debounce from 'lodash/debounce' | only debounce and what it depends on |
import { debounce } from 'lodash-es' | only debounce and what it depends on: lodash-es is ES modules |
Code Splitting
- A dynamic
import('./module.js')returns a promise, and the bundler puts the module into a separate chunk. It works in every modern bundler. - Split by route first, then heavy components that are rarely needed: modals, editors, charts.
- In React,
lazytogether withSuspenseloads a component on first render. Routers can load routes lazily, see Routing. - Dependencies that rarely change can go into a separate vendor chunk
(
splitChunksin webpack,manualChunksin Rollup), so that the browser keeps it cached between releases. See HTTP caching.
Other Techniques
- Minification (Terser, esbuild, SWC) is done by the bundler; compression (Brotli, gzip) is done by the server or the CDN.
- Lighter dependencies:
date-fnsorIntlinstead of Moment.js, native methods instead of utility libraries. - Fewer polyfills: a
browserslistconfig that matches the real audience.
Example
import { Suspense, lazy } from 'react';
// The chart code is loaded only when <Dashboard> is rendered
const Chart = lazy(() => import('./Chart'));
function Dashboard() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Chart />
</Suspense>
);
}
Follow-up Questions
- Why does tree shaking not work for a library? It is published as CommonJS, or
its modules have side effects and it does not declare
sideEffects. - What is the downside of code splitting? More requests and a delay when a chunk is needed for the first time. Chunks for likely navigation can be preloaded.
- How do you find out what made the bundle bigger? Compare analyzer reports before and after the change.
Common Mistakes
- "Named imports are always tree-shaken." Only from ES modules;
import { debounce } from 'lodash'pulls in the whole library. - Measuring performance or bundle size on a development build.