Why is my JavaScript bundle so big? Tree shaking and code splitting (The Bloat Asura)
A login page that downloads a megabyte of JavaScript. Vayu, the Air guide of Domlok, weighs the bundle, shakes out unused lodash with ES modules and splits heavy routes with import() and React.lazy until the Bloat Asura shrinks.
Told by Vayu, with Kai asking the questions
Vayu: Air carries only what is light, Kai. Your login page is carrying every page you ever built.
If your JavaScript bundle is huge, the usual reason is that every import at the top of every file ends up in the first download, including pages and libraries the visitor has not opened yet. Weigh the bundle, import the ES module builds of your libraries so tree shaking can work, and split heavy routes out with dynamic import().
In the world of Domlok this is the work of the Bloat Asura, a demon that swallows everything you import and makes every visitor carry it up the mountain. It never throws an error. Your app works perfectly on your fast laptop, and spins for six seconds on a phone.
Your guide for this episode is Vayu, the Air guide of Domlok and keeper of everything that has to travel light: loading, rendering and Core Web Vitals. Bundle size is pure performance, so it is his fight. Kai, as usual, is the one who fed the Asura: he imported everything at the top of App.jsx and shipped it.
One bag for everything
Kai’s dashboard has three pages: a login page, a Reports page with charts and a Notes page with a rich text editor. His app entry looks like this:
import _ from "lodash";
import Reports from "./Reports"; // pulls in a chart library
import Notes from "./Notes"; // pulls in an editor
import Login from "./Login";
export default function App() {
return (
<Routes>
{" "}
<Route path="/" element={<Login />} />{" "}
<Route path="/reports" element={<Reports />} />{" "}
<Route path="/notes" element={<Notes />} />{" "}
</Routes>
);
}KaiIt works. Every page loads instantly once you are in. Ship it?
Your bundler (Vite, webpack, Rollup or esbuild) starts at the entry file and follows every static import. Everything it reaches goes into the main bundle, and the browser has to download, parse and run that bundle before your app can render anything. In Kai’s case (example sizes, minified):
| Part of main.js | Size | Needed on the login page? |
|---|---|---|
| Chart library (Reports) | 420 KB | No |
| Rich text editor (Notes) | 260 KB | No |
| App code | 160 KB | Partly |
| react-dom | 140 KB | Yes |
All of lodash, for one debounce |
72 KB | One function |
| Total | 1052 KB |
VayuAn import at the top of a file means: load this before anything runs. Nobody sees a chart before they log in, yet every visitor downloads one.
Step 1: weigh the bag first
Never guess which package is heavy. Build for production and open a bundle visualizer, which draws every module as a box sized by its bytes.
# Vite (uses Rollup under the hood)
npm i -D rollup-plugin-visualizer
# add visualizer() to the plugins in vite.config.js, then npx vite build # writes stats.html
# webpack npx webpack --profile --json > stats.json npx webpack-bundle-analyzer stats.jsonStart with the biggest box. Before you install a new package, look it up on bundlephobia.com to see its size and whether it can be tree-shaken.
KaiThe charts are the biggest box. Then the editor. And all of lodash, for one debounce?
Step 2: tree shaking, take only the leaf you need
Tree shaking is dead-code elimination: the bundler drops exports nobody imports. It relies on the static structure of ES modules (import and export), so it can only shake what it can see. The classic lodash package is CommonJS, so importing from it brings the whole library along.
// Before: CommonJS, the whole library ships (about 72 KB)
import _ from "lodash";
const save = _.debounce(saveDraft, 300); // After: the ES module build, only debounce and its helpers stay
import { debounce } from "lodash-es";
const save = debounce(saveDraft, 300);Tree shaking only removes code when three things are true:
- ES module syntax all the way.
import/export, notrequire(), and no Babel setting that converts your modules to CommonJS. - A production build. Development builds keep everything so they stay fast and debuggable.
- No side effects on import. A module that runs code when it is imported (a polyfill, a global stylesheet) is kept. Libraries mark side-effect-free files with
"sideEffects": falsein theirpackage.json.
Step 3: code splitting with dynamic import()
Tree shaking trims a library. Code splitting is the big win: the charts and the editor are only needed on their own pages, so load them only there. Written with parentheses, import() returns a promise and loads the module only when that line runs. Bundlers turn each dynamic import into its own chunk file.
import { lazy, Suspense } from "react";
import Login from "./Login";
const Reports = lazy(() => import("./Reports"));
const Notes = lazy(() => import("./Notes"));
export default function App() {
return (
<Suspense fallback={<Spinner />}>
{" "}
<Routes>
{" "}
<Route path="/" element={<Login />} />{" "}
<Route path="/reports" element={<Reports />} />{" "}
<Route path="/notes" element={<Notes />} />{" "}
</Routes>{" "}
</Suspense>
);
}lazy() expects the module to have a default export that is a component. While the chunk downloads, Suspense shows the fallback. Declare lazy components at the top level of a module, never inside another component, or React will throw their state away on every render.
KaiSo the login page never downloads the charts at all?
VayuNot until someone opens Reports. Split by route first: that is where most of the weight hides. Then split heavy widgets such as charts, editors, maps and big modals.
With both fixes, the login page downloads about 303 KB instead of 1052 KB. The chart chunk (420 KB) loads on /reports and the editor chunk (260 KB) on /notes.
Load later, but not too late
A split route shows its fallback for a moment the first time it opens. Start the download early: call the same import() when the pointer enters the link. The bundler fetches the chunk once, so the later lazy() call reuses it.
<Link to="/reports" onPointerEnter={() => import('./Reports')} > Reports </Link>Don’t split everything. Every chunk is another request, and dozens of tiny chunks can be slower than one medium file.
Catch the Asura in DevTools
1. Network panel. Open Chrome DevTools, go to Network, filter by JS and reload. Before the fix you see one large main.js. After it you see a small entry file, and new chunk files appear only when you open their page.
2. Coverage panel. Open the Command Menu (Ctrl+Shift+P, or Cmd+Shift+P on a Mac), run Show Coverage, and reload. The red part of each bar is code that was downloaded but never ran on this page. A big red bar on your entry file is the Asura’s belly.
3. Lighthouse. The Reduce unused JavaScript audit lists the same files, with how many bytes you could save.
KaiFrom a megabyte to three hundred kilobytes. The Asura is shrinking!
Before you ship a bundle
- Have you looked at the production bundle in a visualizer, biggest box first?
- Do your libraries come in as ES modules (
lodash-es, named imports) so tree shaking can drop what you don’t use? - Is every route other than the first page loaded with
lazy(() => import(...))? - Are heavy widgets (charts, editors, maps, big modals) split out and prefetched on intent?
- Does the Coverage panel show only a little red on your entry file?
Acharya's Rule #20Ship what the page needs now. Load the rest when it’s asked for.
Next episode: One Listener to Rule Them. A hundred buttons, a hundred event listeners, and the one listener that can replace them all: event delegation.
Think you can shrink the Asura yourself? Play Shrink the Bloat Asura on domlok.in and earn seal 20.
Watch the full episode and the Short on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.
Further reading
- MDN: import() (dynamic import)
- webpack: Tree Shaking and the sideEffects flag
- web.dev: Reduce JavaScript payloads with code splitting
- React docs: lazy