SevyDevy

Guide · Frontend

JavaScript Code Splitting and Tree Shaking: How to Cut Bundle Size

22 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • JavaScript code splitting and tree shaking cut the code a page downloads. Splitting moves rarely used code into separate files called chunks. Tree shaking deletes exported functions nothing imports.
  • A bundler is the build tool that joins your files into a few files for the browser. Writing import() as a function call tells it where to cut a chunk.
  • Tree shaking works on ES modules, the import and export syntax. CommonJS, the older require and module.exports style, often blocks it: one lodash function cost 25 to 27 kB gzip in our runs.
  • The sideEffects field in package.json tells bundlers a file does nothing just by loading. Set it wrong and the bundler silently drops a CSS file or setup script you needed.
  • Beginners can stop after the dynamic import section. Engineers get measured byte sizes from esbuild 0.28.2 and webpack 5.112.2, the gotchas each bundler handles differently, and a build that fails when bundles grow.

What goes wrong when one JavaScript bundle carries the whole app?

Every visitor downloads, parses and runs code for screens they never open. On a slow phone, that work can delay the moment the page first responds to a tap. JavaScript code splitting and tree shaking are the two build-time fixes for this.

Picture a common setup. A shop runs an admin web app with an Orders page that staff open all day and a Reports page with charts that a few managers open on Mondays. The setup is made up, but the byte sizes below come from real builds. The first version imports everything at the top of one file, so the Orders page also ships the reporting code.

In our build that single file was 18,745 bytes, minified. After one change and one build flag, the Orders page needed 199 bytes, and the reporting code loaded only when someone opened Reports. This guide shows that change, then the tree shaking rules that decide what is inside each file.

Which words do you need before shrinking a JavaScript bundle?

  • Module: one JavaScript file that shares some values with other files and uses values from them.
  • ES modules (ESM): the standard module syntax with import and export. Bundlers can read these lines without running the code.
  • CommonJS (CJS): the older Node.js style, where a file calls require() and fills module.exports while it runs.
  • Bundler: a build tool such as esbuild, webpack, Rollup or Vite that follows your imports and writes a few output files.
  • Bundle and chunk: a bundle is an output file the browser loads. A chunk is a piece of it that can load on its own.
  • Minifier: a build step that shortens names and removes spaces. It also deletes code that can never run.
  • Side effect: anything a file does just by loading, such as setting a global value or adding CSS to the page.
  • gzip and brotli: compression formats servers use to send fewer bytes. The browser unpacks them, then parses the full raw size.

What are JavaScript code splitting and tree shaking?

JavaScript code splitting cuts your app into several files so each page loads only what it needs first. Tree shaking removes exported code that no import uses. Splitting decides when code arrives; tree shaking decides whether it ships at all.

The webpack docs define tree shaking as dead-code elimination that relies on the static structure of import and export. Static means the bundler can see every import before any code runs. That is why the module format matters so much later in this guide.

Think of packing for a work trip. Tree shaking is leaving clothes you will never wear at home. Code splitting is packing a second bag that a courier brings only if you need it. The sideEffects field is a label saying a bag holds no hidden items like medicine you must take.

Where the picture breaks: a bundler never guesses what you will need. It follows import lines exactly, so a single forgotten import keeps a whole file in the main bag.

What a bundler does with the shop admin app

Notice that tree shaking happens before files are written, and import() is the only thing that creates the second file.

Diagram connections:

  • Source modules to Build the import graph (bundler reads)
  • Build the import graph to Tree shaking (used exports marked)
  • Tree shaking to Find import() calls (only live code left)
  • Find import() calls to main.js, 199 bytes (static imports)
  • Find import() calls to reports chunk, 18,644 B (dynamic import())
  • Source modules: main.js, orders.js, reports.js, lodash-es
  • Build the import graph: Reads every import and export line
  • Tree shaking: Drops exports nothing imports
  • Find import() calls: Each one marks a place to cut a chunk
  • main.js, 199 bytes: Loads on every page
  • reports chunk, 18,644 B: Loads only when Reports opens

How does tree shaking remove an unused export?

The bundler marks every export that some import uses and deletes the rest. Here is a small file of price helpers from the admin app, where only one helper is used.

javascript
// src/format.js
export function formatPrice(paise) {
  return "Rs " + (paise / 100).toFixed(2);
}

export function buildInvoiceText(order) {
  return "INVOICE for order " + order.id + "\nTotal: " + formatPrice(order.total);
}

// src/app.js
import { formatPrice } from "./format.js";

console.log(formatPrice(129900));

Build it with esbuild and search the output for the word INVOICE. The command below bundles, minifies and writes an ES module file.

bash
npx esbuild src/app.js --bundle --minify --format=esm --outfile=out/app.js
wc -c out/app.js        # 69 out/app.js
grep -c INVOICE out/app.js   # 0

The whole output is one line of 69 bytes, and buildInvoiceText is gone. When app.js imports both helpers, the same build is 160 bytes and the invoice text is back. Webpack 5.112.2 in production mode gave 66 and 168 bytes.

What just happened? The bundler saw that nothing imported buildInvoiceText, so it never wrote it out. You did not delete anything by hand, and the source file still exports both helpers for other pages.

Esbuild works differently. Its source code turns tree shaking on whenever it bundles, and our build without --minify was 141 bytes, with the invoice helper already gone.

Why does tree shaking keep code you never call?

It keeps any top-level code that might have a side effect, because removing it could change what your app does. Top-level code is code outside any function, which runs the moment the file loads.

Say the admin app uses a small chart library, chart-kit, a made-up package we wrote for these tests. The app imports only formatAxis, a helper that turns 250000 into 2.5 lakh (250 thousand). The library's draw.js file builds a colour palette at the top level.

javascript
// node_modules/chart-kit/draw.js
const palette = makePalette(12);

function makePalette(n) {
  return Array.from({ length: n }, function (_, i) {
    return "hsl(" + i * 30 + ", 70%, 50%)";
  });
}

export function drawChart(ctx, values) {
  values.forEach(function (v, i) {
    ctx.fillStyle = palette[i % palette.length];
    ctx.fillRect(i * 10, 100 - v, 8, v);
  });
}

Nothing in the app uses drawChart, and every bundler we tried removed it. But all three kept the makePalette call. The esbuild output was 172 bytes and webpack's was 130 bytes, both still building colours nobody reads.

The bundlers cannot prove that calling makePalette, which calls Array.from, is harmless. Deciding whether any function call is safe to skip is hard in JavaScript, so bundlers stay careful.

Two ways to tell the bundler the code is safe

You can mark a single call as pure, meaning it has no side effects, with a special comment before it. Writing /* @__PURE__ */ makePalette(12) removed the palette in esbuild, webpack and Rollup, giving 69 and 59 bytes in the first two. The webpack docs describe the same annotation.

The second way works on whole files, and it is the next section. The webpack docs call it much more effective, because it can skip whole files and everything they import.

What does sideEffects false in package.json do?

It is a promise from the package author that no file in the package does anything just by being imported. With that promise, a bundler can skip any file whose exports you do not use, including its top-level code.

json
{
  "name": "chart-kit",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "sideEffects": false
}

With this one field added, the same app shrank from 172 to 69 bytes in esbuild and from 130 to 59 bytes in webpack. The palette code was gone because draw.js was skipped completely.

This field belongs to bundlers, not to Node.js. The Node.js packages documentation lists the fields Node reads as name, main, type, exports and imports, and says other fields are ignored by Node.js. Node itself runs every file you import, whatever the field says.

When sideEffects false breaks your app

Some files exist only for their side effects. A CSS import, a polyfill or a setup file that registers chart themes exports nothing, so a wrong false tells the bundler to drop them.

javascript
// node_modules/chart-kit/register.js
globalThis.chartKitThemes = ["light", "dark"];

// src/register-app.js
import "chart-kit/register.js";

console.log(globalThis.chartKitThemes);

Without the field, both bundles printed the two themes. With sideEffects false, both printed undefined, because the import was removed. Esbuild 0.28.2 printed a warning starting Ignoring this import because the file was marked as having no side effects.

Webpack 5.112.2 dropped the same import and reported compiled successfully, with no warning, even in development mode. That silence is observed behaviour, not something the webpack docs promise either way, as of 24 October 2026. Pin your version and test the real page.

The fix is the array form, which lists the files that do have side effects. The webpack docs say patterns without a slash, like *.css, match in every folder.

json
{
  "name": "chart-kit",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "sideEffects": ["./register.js", "*.css"]
}

With that list, both bundles printed the themes again. The same rule applies to your own app's package.json, not only to libraries you publish.

Can the bundler skip this file?

Notice that a file is skipped whole only when none of its exports are used and it is marked side-effect free; otherwise its top-level code stays.

Diagram connections:

  • An import is found to Is the file an ES module? (check format)
  • Is the file an ES module? to Are its exports used? (Yes)
  • Is the file an ES module? to Keep most of the file (No: CommonJS)
  • Are its exports used? to Marked side-effect free? (No)
  • Are its exports used? to Keep top-level code (Yes: keep used parts)
  • Marked side-effect free? to Skip the whole file (Yes)
  • Marked side-effect free? to Keep top-level code (No)
  • An import is found: import { formatAxis } from chart-kit
  • Is the file an ES module?: import and export lines, not require()
  • Are its exports used?: Checked across the whole import graph
  • Marked side-effect free?: sideEffects false, or not on the list
  • Skip the whole file: Its top-level code never ships
  • Keep top-level code: Unused functions still removed
  • Keep most of the file: esbuild and Rollup keep it all

Why does CommonJS defeat tree shaking?

CommonJS builds its exports while the code runs, so a bundler often cannot see which exports exist or which ones you use. Faced with that, esbuild and Rollup keep the whole file.

Here is the chart helper again, this time written in CommonJS. The app still imports only formatAxis, and the package still says sideEffects false.

javascript
// node_modules/chart-kit-cjs/index.js
function formatAxis(n) {
  return (n / 100000).toFixed(1) + " lakh";
}

function drawChart(ctx, values) {
  values.forEach(function (v, i) {
    ctx.fillRect(i * 10, 100 - v, 8, v);
  });
}

exports.formatAxis = formatAxis;
exports.drawChart = drawChart;

Pause and predict: will drawChart stay in the bundle? The ES module version of this library gave a 69-byte esbuild bundle without drawChart.

The CommonJS version gave 763 bytes with esbuild, and drawChart was still inside. The extra bytes are drawChart plus a small wrapper esbuild adds to run CommonJS code. Rollup with its CommonJS plugin also kept drawChart. Its output, which we did not minify, was 574 bytes.

Webpack 5.112.2 surprised us. It removed drawChart and produced 271 bytes, because webpack 5 added support for some simple CommonJS patterns. Its release post lists patterns like exports.name = value, and says it stops tracking exports when it finds code it cannot analyse.

We checked that limit. When the library copied its exports in a loop, webpack kept drawChart too, at 633 bytes. So treat CommonJS as unshakeable unless you have measured your own bundler on your own library.

The same lesson with a real library

Lodash is a popular helper library. The lodash package is CommonJS, and lodash-es is its ES module build, with type module and sideEffects false in its package.json. We imported only debounce, a helper that waits until calls stop before running a function.

Bundle size when importing only debounce (lodash 4.18.1 and lodash-es 4.18.1, minified, gzip level 9, run on 24 October 2026)
How it is importedesbuild 0.28.2 raw / gzipwebpack 5.112.2 raw / gzip
import { debounce } from lodash (CommonJS)73,839 / 26,831 bytes71,135 / 25,261 bytes
import { debounce } from lodash-es (ES modules)2,925 / 1,457 bytes2,428 / 1,101 bytes
import debounce from lodash/debounce.js (one file)3,517 / 1,657 bytes3,246 / 1,425 bytes

The CommonJS import shipped the whole library: 18 to 23 times more gzip bytes for one function. Importing the single file is a workaround when a library has no ES module build.

Your own code can fall into the same trap. The webpack docs warn that a compiler may turn your import lines into CommonJS, and they name the popular Babel preset-env default. Check your build output, not only your source.

How does dynamic import() create a separate chunk?

Writing import() as a function call lets a bundler that supports splitting put that module and its imports in a separate file. The browser fetches it only when the line runs. MDN describes import() as returning a promise for the module's exports, and lists it as widely available in browsers since January 2020.

A promise is a value that arrives later, which is why the code uses await. Here is the admin app's router after the change.

javascript
// src/main.js
import { renderOrders } from "./pages/orders.js";

async function route(path, el) {
  if (path === "/reports") {
    const { renderReports } = await import("./pages/reports.js");
    renderReports(el, []);
  } else {
    renderOrders(el);
  }
}

route(location.pathname, document.body);

The old version imported renderReports at the top, so its lodash-es helpers were in the main file. Build both with esbuild's splitting option and compare.

bash
npx esbuild src/main.js --bundle --minify --format=esm --splitting --outdir=out/split
#  out/split/reports-ECIECDVG.js  18.2kb
#  out/split/main.js               199b
The shop admin app before and after one dynamic import (raw and gzip bytes, run on 24 October 2026)
BuildLoaded on the Orders pageLoaded later, on Reports only
esbuild 0.28.2, static importmain-static.js: 18,745 / 7,269nothing
esbuild 0.28.2, import() with --splittingmain.js: 199 / 177reports chunk: 18,644 / 7,208
esbuild 0.28.2, import() without --splittingmain.js: 22,505 / 8,937nothing
webpack 5.112.2, static importmain.js: 16,270 / 5,761nothing
webpack 5.112.2, import()main.js: 2,759 / 1,444two chunks: 365 and 18,071 raw

What just happened? The Orders page now loads 177 gzip bytes instead of 7,269. The reports code did not shrink; it moved to a file that only Reports users download.

Webpack split the code with no extra settings. Its main file is larger because it carries a small runtime that fetches chunks, and it put lodash-es in its own vendors chunk.

What the browser fetches when a manager opens Reports

Notice that the reports chunk is a second network request that starts only after the click, so the first visit to Reports waits for it.

Diagram connections:

  • Page loads main.js to Orders page renders (1. run main.js)
  • Orders page renders to Manager opens Reports (2. later, a click)
  • Manager opens Reports to import() fetches chunk (3. await import())
  • import() fetches chunk to Reports page renders (4. promise resolves)
  • Page loads main.js: Browser → server, 177 bytes gzip
  • Orders page renders: main.js → screen, no reports code
  • Manager opens Reports: User → router, path /reports
  • import() fetches chunk: Browser → server, 7,208 bytes gzip
  • Reports page renders: Chunk → screen, renderReports runs

Two ways to write import() and still ship one big file

First, the bundler has to support splitting for your output format. Without the splitting option, esbuild kept the reports code inside main.js, and the file grew to 22,505 bytes. Esbuild's docs say splitting only works with the esm format, and asking for iife failed with that error.

Second, one leftover static import cancels the split. When main.js also imported renderReports at the top, webpack wrote a single 16,741-byte file. Esbuild moved the code into a shared chunk that main.js imports at startup, so it still loaded on Orders.

How do you split routes and components in React?

Wrap the dynamic import in React's lazy function, and show a fallback with Suspense while the chunk loads. Suspense is a React component that shows a placeholder until the code inside it is ready.

javascript
// src/App.js (React 19.3.1, plain createElement calls, no JSX)
import { createElement, lazy, Suspense, useState } from "react";
import { createRoot } from "react-dom/client";

const Reports = lazy(function () {
  return import("./Reports.js");
});

function App() {
  const [tab, setTab] = useState("orders");
  if (tab === "reports") {
    return createElement(Suspense, { fallback: "Loading reports..." },
      createElement(Reports, { orders: [] }));
  }
  return createElement("button", { onClick: function () { setTab("reports"); } }, "Reports");
}

createRoot(document.getElementById("root")).render(createElement(App));

Esbuild produced a 17,399-byte Reports chunk that loads on the first click. It also wrote an 8,792-byte shared chunk with React code that both files use. The entry file was 214,215 bytes, or 66,399 bytes gzip.

Esbuild's analyze report showed where those bytes came from. React DOM's client file, loaded from its cjs folder, was 200.1 kB of the 209.2 kB entry file. That is a CommonJS file, so esbuild bundles it whole. Splitting your own pages is the lever you control.

How do you measure bundle size and stop it growing back?

Measure the production build after every change, and make the build fail when a file passes a size you agreed on. A size limit like this is called a performance budget.

Which numbers to look at

Raw bytes are the size on disk after minifying. Gzip and brotli bytes are roughly what travels over the network. Keep both: the download depends on the compressed size, while the browser still parses every raw byte.

  • Esbuild: add --analyze to print each output file with the modules inside it and their share, or --metafile to save build data, including input and output sizes, as JSON.
  • Webpack: the code splitting guide lists tools such as webpack-bundle-analyzer, which draws the bundle's contents as a zoomable treemap.
  • Chrome DevTools: the Coverage panel lists each loaded file with an Unused Bytes column for the page you are on.
  • Any setup: compress the files yourself, as we did with Node's zlib module, and compare runs.

A budget that fails the build

Webpack has budgets built in. The performance settings warn by default in production mode for any asset or entry point over 250,000 bytes. Set hints to error and the build fails instead.

javascript
// budget.webpack.config.js
module.exports = {
  mode: "production",
  entry: "./src/main-static.js",
  performance: {
    hints: "error",
    maxEntrypointSize: 10000,
    maxAssetSize: 10000,
  },
};

Run npx webpack -c budget.webpack.config.js on the old static version. You should see ERROR in asset size limit, naming main.js at 15.9 KiB against a 9.77 KiB limit, and exit code 1. A KiB is 1,024 bytes, so 10,000 bytes shows as 9.77 KiB.

Our file was 16,270 bytes raw and 5,761 bytes gzip, and webpack compared the raw size. The docs only say the limits are in bytes, so set your numbers against raw, minified output.

  1. Record today's sizes

    Build in production mode and write down raw and gzip bytes for every file the first page loads.

  2. Set the budget a little above today

    Pick a limit just above the current entry size, so any real growth breaks the build.

  3. Fail the build in CI

    Set webpack performance hints to error, or run a small script that compresses each file and exits with code 1 when one is over.

  4. Read the report when it fails

    Run esbuild --analyze or a bundle analyzer, find the new module, then split it, shake it or replace it.

  5. Raise the limit only on purpose

    Change the number in a reviewed commit that says why the page needs more code.

How do esbuild, webpack and Rollup differ on the same code in 2026?

They agree on the basics and disagree on the edges. Every result below ran on esbuild 0.28.2, webpack 5.112.2 and Rollup 4.65.0, as of 24 October 2026. Rollup ran with its node-resolve and CommonJS plugins and without a minifier.

Same source, three bundlers: what stayed in the bundle
Caseesbuild 0.28.2webpack 5.112.2Rollup 4.65.0
Unused named exportRemovedRemovedRemoved
Top-level call in an unused file, no sideEffects fieldKeptKeptKept
Same, with sideEffects falseFile skippedFile skippedFile skipped
Side-effect-only import, sideEffects falseDropped, with a warningDropped, no warningNot tested
CommonJS with exports.name = valueWhole file keptUnused export removedWhole file kept
CommonJS exports copied in a loopWhole file keptWhole file keptNot tested
One default export object of helpersUnused helper keptUnused helper removedUnused helper kept

The last row catches many teams. A file that ends with export default followed by an object of helpers bundles every helper with esbuild and Rollup. Named exports were removed by all three.

The webpack cells for CommonJS and default objects describe observed behaviour, not documented promises. Pin your bundler version and re-run your size check after upgrades.

Which mistakes undo code splitting and tree shaking?

Common mistakes and what we saw in our runs
MistakeWhat happened in our buildFix
Measuring a development buildWebpack kept the unused export at 3,156 bytesMeasure only production output
Importing a CommonJS library73,839 bytes for one lodash functionUse an ES module build or a per-function file
sideEffects false on a package with CSS or setup filesThe setup import vanished and the app printed undefinedList those files in the sideEffects array
A static import left beside import()The reports code still loaded on the first pageSearch for every import of the split module
Using import() without splitting turned onesbuild wrote one 22,505-byte fileUse --splitting with --format=esm
One default object of helpersesbuild and Rollup kept every helperExport each helper by name

How do you explain JavaScript code splitting and tree shaking in a system design interview?

Start from the user's first screen, then show how you keep only its code on the critical path and stop the bundle growing back. Use this frame.

  1. Name the cost

    Every first visit downloads, parses and runs the main bundle, so its size shapes how soon the page responds on slow phones.

  2. Split by route and heavy widget

    Use dynamic import() for pages and large components most users never open, with a loading state and optional prefetch.

  3. Make tree shaking possible

    Use ES modules end to end, prefer libraries with ES module builds, and export small named functions.

  4. Mark side effects honestly

    Set sideEffects in package.json, listing CSS and setup files, so whole unused files are skipped safely.

  5. Measure and guard

    Track raw and gzip sizes per file, inspect with an analyzer, and fail CI when a budget is crossed.

  6. State the trade-off

    More chunks mean more requests and a short wait on first use, so split where the saving is large.

Questions people ask

What is the difference between code splitting and tree shaking?
Tree shaking deletes code nothing uses; code splitting moves used code into files that load later. Tree shaking makes the total smaller. Splitting makes the first download smaller without changing the total. In our shop admin build, splitting cut the first page from 7,269 to 177 gzip bytes, while the reports code still existed in its own chunk.
Does tree shaking work with CommonJS require()?
Mostly no. Esbuild 0.28.2 and Rollup 4.65.0 kept the whole CommonJS file in our runs. Webpack 5 understands some simple patterns, such as exports.name = value, and removed an unused export there. It kept everything when exports were copied in a loop. Prefer ES module builds of libraries whenever they exist.
Is it safe to add sideEffects false to my package.json?
Only if no file does anything just by loading. CSS imports, polyfills and files that set globals or register plugins all have side effects. If you have any, use the array form and list them, such as ./register.js and *.css. Then test the production page, because webpack 5.112.2 dropped such an import without any warning in our run.
Does dynamic import() always create a separate chunk?
No, it depends on the bundler and settings. Webpack 5.112.2 split it with no configuration. Esbuild 0.28.2 split it only with the splitting option and the esm output format. Without the option, it kept the code in one file. A static import of the same module elsewhere also keeps the code in the startup files.
Should I compare bundle sizes raw or gzipped?
Track both. Gzip or brotli size is close to what users download, and raw size is what the browser must parse and run. Webpack's performance budget compared the raw minified size in our test: a 16,270-byte file failed a 10,000-byte limit even though it was 5,761 bytes gzipped.
Why is my bundle still big after tree shaking?
Usually one of four reasons. A library is CommonJS, a file has top-level code with possible side effects, you export one default object of helpers, or you measured a development build. Run esbuild with the analyze option or a bundle analyzer to see which modules take the space, then fix the largest one first.
Do I need to configure anything in Vite?
Very little for splitting. Vite's docs say it rewrites code-split dynamic imports with a preload step and moves the CSS of async chunks into separate files. You still choose where to call import(), which libraries to install and what sideEffects says. Those choices decide the size, so measure the production build the same way.

Sources and further reading

Practise this with Lala

Practise