Tags: web-dev concept

Module Bundling

Date: 2026-08-17


Following imports to build a graph, then emitting it as a small number of files. The interesting parts are what gets left out — tree-shaking — and how the graph is cut into chunks, because both decide what a visitor actually downloads.


Bundling resolves a module graph from an entry point and emits it as one or more output files.

Building the graph

main.js
  ├─ imports  basket.js
  │              ├─ imports  price.js
  │              └─ imports  lodash
  └─ imports  header.js
                 └─ imports  price.js   ← already
                                          in the graph

Each module appears once, however many times it’s imported. The graph is the bundler’s model of the application, and everything after is a transformation of it.

Module formats

FormatSyntaxStatic analysis
ESMimport / exportYes — the whole point
CommonJSrequire() / module.exportsNo — dynamic
UMDWrapper detecting the environmentNo
IIFEA self-executing functionN/A

ESM’s static structure is what makes tree-shaking possible. Imports are declarations resolvable without running the code, so a bundler can prove an export is unused. The language-level differences between the two systems are in Modules.

// analysable — a bundler knows what's used
import { debounce } from 'lodash-es'
 
// not — the argument could be anything
const name = getName()
const mod = require(name)

This is why the ecosystem moved to ESM, and why a CommonJS-only dependency costs you tree-shaking on that package.

Tree-shaking, and why it under-delivers

Tree-shaking removes exports nothing imports. It frequently removes less than expected, for specific reasons:

  • Side effects. A module that mutates global state on import can’t be removed, even if its exports are unused. sideEffects: false in package.json tells the bundler it’s safe
  • CommonJS dependencies can’t be analysed statically
  • Barrel files. An index.js re-exporting a hundred modules means importing one pulls the barrel, and whether the rest is shaken depends on the side-effect declarations of all of them
  • Whole-library imports:
import _ from 'lodash'          // the lot
import { debounce } from 'lodash-es'  // just this

Barrel files are the most common cause of a bundle being larger than expected, and they’re a convention almost every codebase adopts for import tidiness.

Chunks

The graph has to be cut into files, and where you cut decides the caching behaviour:

ONE BUNDLE
  everything.js  ← one request
  ← any change invalidates all of it

SPLIT BY ROUTE
  main.js
  checkout.js    ← loaded when needed
  account.js

SPLIT BY CHANGE FREQUENCY
  vendor.js      ← changes rarely
  app.js         ← changes daily
  ← vendor stays cached across deploys

Splitting vendor code from application code is the highest-value split, because dependencies change far less often than your own code, and the cached copy survives every deploy — Code Splitting, Caching Strategies.

Content hashing

app.a3f9c2e1.js

The hash is derived from the file’s contents, so changing the content changes the URL. That makes the file cacheable indefinitely, because a new version is a new URL rather than a stale one — Content Delivery Networks.

The trap: if a shared chunk’s hash changes on every build — because a module ID or the ordering shifted — every file’s cache is invalidated on every deploy. Deterministic module IDs matter for exactly this reason — Build Caching.

Output formats

esm     modern browsers and bundlers
cjs     Node, older tooling
iife    a <script> tag, no module system
umd     works everywhere, largest

For an application, emit ESM and let the tooling handle older targets. For a library, ship both ESM and CJS, with exports in package.json mapping them — a library shipping only CJS denies its consumers tree-shaking.

Where it goes wrong

  • Duplicate dependencies. Two versions of the same package resolved into one bundle. npm ls <pkg> finds it
  • A dependency bundled that should be external. A library bundling React ships a second copy into every consumer
  • Node built-ins in browser code. Something imports path or crypto and the bundler either polyfills it silently or fails
  • No measurement. The only reliable way to know what’s in a bundle is to look — Bundle Analysis