⚡ Web performance

Code splitting and tree shaking: shrinking the JavaScript bundle

How to split JavaScript into chunks loaded only when needed, strip dead code from dependencies and find out what is really bloating your bundle.

JavaScript is the most expensive resource on a page, byte for byte. A 200 KB image only has to be decoded and painted; a script of the same size has to be downloaded, parsed, compiled and executed on the main thread, and on a mid-range phone that work can take far longer than on a laptop. Reducing the amount of JavaScript sent on first load is therefore one of the most effective interventions, and the two main tools are code splitting and tree shaking.

The single-bundle problem

Many projects start with a single JavaScript file containing everything: the home page code, the cart, the members' area editor, the charting library used on just one page. Every visitor downloads and runs the whole package, even if they need a tenth of the code.

Code splitting divides this file into several parts, loaded only when needed. Tree shaking, by contrast, removes from the final output code that is imported but never used.

Code splitting with dynamic import

The foundation of code splitting is dynamic import(), which returns a promise and tells the build tool to create a separate file:

chartButton.addEventListener('click', async () => {
  const { drawChart } = await import('./charts.js');
  drawChart(container, data);
});

The charting library no longer goes into the main bundle: it is downloaded only by those who press the button. Modern build tools such as webpack, Rollup, Vite and esbuild recognize this syntax and generate the separate files automatically.

Where to split

The most rewarding split points are:

  1. by page or route: in an application with several sections, each route loads only its own code. Almost every framework supports this natively;
  2. by heavy component: rich text editors, maps, charts, video players, complex dialogs;
  3. by rarely used feature: PDF export, admin tools, initial setup wizards;
  4. by condition: polyfills loaded only in browsers that need them.

Beware of the opposite excess: splitting into dozens of tiny files multiplies requests and can create chains where one chunk discovers the next only after it has run. With HTTP/2 many parallel requests are not a problem, but chains are. For chunks that will almost certainly be needed, modulepreload or prefetching on hover reduces the wait.

Tree shaking: removing dead code

Tree shaking analyses the import and export statements of ES modules and discards functions that nobody uses. For it to work, a few conditions apply:

  • ES modules: with import/export syntax the build tool can analyse dependencies statically. With CommonJS (require) the analysis is far more limited;
  • targeted imports: import { debounce } from 'library' makes it possible to include only that function, whereas importing the library's whole object makes it hard to discard the rest;
  • no side effects: if a module modifies global objects merely by being loaded, the bundler cannot safely remove it. The "sideEffects": false field in a library's package.json declares that its modules can be dropped if unused.

A classic example: some utility libraries come in two versions, one CommonJS and one as ES modules. Switching to the latter and importing individual functions can drastically reduce that dependency's share of the bundle.

Finding out what weighs

Before optimizing you need to know where to look. Useful tools are:

Tool What it shows
Bundle analysers (for webpack, Rollup, Vite) Visual map of dependencies and their weight
DevTools Coverage panel Percentage of code downloaded but not executed
Build report Size of each output file, compressed and uncompressed
npm package analysis sites A library's weight before you install it

An analyser's map often reveals recurring surprises: a date library that ships every language in the world, two versions of the same package pulled in by different dependencies, an entire icon library to use three icons, polyfills for browsers the site no longer needs to support.

Typical interventions

In my work, the largest gains almost always come from a few targeted changes:

  • replacing bulky dependencies with lighter alternatives or native browser features, such as Intl for formatting dates and numbers;
  • dynamically loading components that appear only on some pages;
  • setting the compilation target to the browsers actually supported, avoiding unnecessary transforms and polyfills;
  • removing duplicate dependencies by aligning versions.

Keeping the results over time

The bundle tends to grow back with every new feature. It pays to set a size limit for the main files and have it checked on every change: tools such as size-limit fail the build if the limit is exceeded, turning a silent regression into a visible problem to discuss before release.

In short

  • JavaScript costs more than other resources because it must be parsed and executed.
  • Code splitting with import() loads code only when it is needed.
  • Tree shaking requires ES modules, targeted imports and side-effect-free libraries.
  • Bundle analysers and the Coverage panel show where to intervene.
  • Set size limits that are checked automatically to prevent regressions.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also