Made by Dhawal Made by Dhawal

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

The Bloat Asura, a swollen purple demon hugging stacks of npm package boxes with a 1.05 MB tag, beside the title "Why is my JavaScript bundle so big?"

Vayu: Air carries only what is light, Kai. Your login page is carrying every page you ever built.

It is only a login form!

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:

App.jsx
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.

Terminal
# 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.json

Start 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.

search.js
// 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, not require(), 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": false in their package.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.

App.jsx
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.

Nav.jsx
<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 #20

Ship 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

Frequently asked questions

Why is my JavaScript bundle so big?+
Your bundler follows every static import from the entry file and puts everything it reaches into the first bundle, including pages and libraries the visitor has not opened yet. Large libraries imported for one function, and CommonJS packages that cannot be tree-shaken, add the most weight.
How do I see what is inside my JavaScript bundle?+
Build for production and open a bundle visualizer, such as rollup-plugin-visualizer for Vite or webpack-bundle-analyzer for webpack. Each module is drawn as a box sized by its bytes, so the biggest boxes show where to start.
Why is tree shaking not removing unused code?+
Tree shaking only works on ES module syntax (import and export) in a production build. CommonJS packages such as the classic lodash, a Babel setup that converts modules to CommonJS, or code that runs side effects when imported are kept in the bundle.
How do I code split a React app?+
Load routes and heavy components with React.lazy and a dynamic import, for example lazy(() => import(‘./Reports’)), and wrap them in Suspense with a fallback. The bundler puts each dynamic import in its own chunk, which downloads only when that component first renders.
How can I find unused JavaScript in Chrome?+
Open DevTools, run Show Coverage from the Command Menu and reload the page. The red part of each bar is code that downloaded but never ran, and Lighthouse’s Reduce unused JavaScript audit lists the same files.