BundlingGENERALPLATFORM-SPECIFICSPEC-EVOLVING

ESM vs CommonJS

Static structure known before execution versus a runtime function call that returns an object — and why the first is what makes tree shaking and named-export analysis possible at all.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Why does the module format of a dependency change what my bundler can do with it?

The user intent

A person downloads an application. The format its dependencies were published in decides how much of them they have to download.

The obvious build

They are two syntaxes for the same idea. require and import both bring in a module; the bundler normalises them and nothing downstream cares.

Why it breaks

import declarations are analysed before any code runs. require() is an ordinary function call that can appear anywhere, take a computed argument, and return anything — so a tool cannot know what a CommonJS module exports without running it (The Module Graph).

How it breaks in a real browser
  • import declarations are analysed before any code runs. require() is an ordinary function call that can appear anywhere, take a computed argument, and return anything — so a tool cannot know what a CommonJS module exports without running it (The Module Graph).
  • That single difference is why tree shaking works on ESM and largely does not on CommonJS. The analysis needs named bindings known statically, and CommonJS has an object assembled at runtime (Tree Shaking).
  • Interop is genuinely lossy in one direction. A default export, a namespace object and module.exports do not map onto each other cleanly, which is where .default.default and "is not a function" errors come from.
  • A package published in both formats can end up loaded twice in one application — once through each entry point — giving two copies of any module-level state, which breaks singletons, caches and registries in ways that look like race conditions.
  • Conditional exports resolve differently by environment, so the same specifier can give you a browser build, a Node build or a bundler build. Getting the condition wrong ships Node polyfills to a browser (Bundle Analysis).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • ESM is static. Import and export declarations are only legal at the top level, specifiers are string literals, and bindings are named. The module's shape is known after parsing and before evaluation, which is what allows the graph, the export analysis and the split points to be computed at build time.
  • ESM bindings are live: an importer sees the current value of an exported binding, not a copy taken at import time. Cyclic graphs are legal because bindings are hoisted and linked before evaluation.
  • ESM evaluation is deferred and ordered: modules are resolved and instantiated first, then evaluated in dependency order, each exactly once.
  • CommonJS is dynamic. require() executes the target module the first time it is called, caches the resulting module.exports object, and returns it. Because it is a function call, it can be conditional, computed, or inside a branch.
  • CommonJS exports are a value copied from an object property at the moment of the call, which is why reassigning module.exports after the fact does not reach existing importers, and why cycles produce partially-initialised objects rather than a link error.
  • The exports field in a package manifest maps subpaths to files, and its conditionsimport, require, browser, node, default — select a file per environment. This is where dual packages are declared and where they go wrong.
  • Bundlers normalise both into their graph, but they cannot recover information CommonJS never had. Interop wrappers are added, and the module is usually retained whole (Bundlers Compared).

What this makes the browser do

And which of it is avoidable.

  • Browsers execute ESM natively: <script type="module"> gives deferred execution, strict mode and its own scope without any tooling (`defer`, `async` and `type="module"`).
  • Browsers do not execute CommonJS at all. Every require that reaches a browser was transformed at build time into something else, plus a small runtime that emulates the module object.
  • That emulation is bytes and main-thread work in every chunk that contains CommonJS modules, on top of the code that could not be shaken out of them (The Real Cost of JavaScript).
  • Duplicate copies of a dual-published package cost the browser twice: twice the download, twice the parse, twice the module-level initialisation.

What a tool can know before your code runs

Put the two formats side by side and the difference is not stylistic. In the ESM version, a tool that has only parsed the file already knows the dependency, the imported name, and that nothing else from that module is used. In the CommonJS version, it knows there is a function call.

Everything else in this module follows from that. Split points need literal specifiers. Tree shaking needs named bindings. Named-export analysis — knowing that formatMoney exists and parseMoney does not — needs a declared export list. CommonJS provides none of them, not because it is old but because its exports are an object assembled while the program runs.

The same dependency, two module systems
1// ---- ESM: shape known after parsing, before evaluation
2import { formatMoney } from 'money-lib'
3export const price = (c) => formatMoney(c, 'EUR')
4// A tool knows: edge to money-lib, one binding used, top level
5// declares one export. All of it without running anything.
6
7// ---- CommonJS: shape known only by executing
8const money = require('money-lib')
9if (process.env.LEGACY) {
10 Object.assign(money, require('./legacy-money'))
11}
12module.exports.price = (c) => money.formatMoney(c, 'EUR')
13// A tool knows: there is a call. What `money` holds, which of
14// its properties are used, and whether the second require runs
15// are all runtime questions.
16
17// ---- And the interop seam, where the errors come from
18import money from 'money-lib' // default? namespace? the object?
19const { formatMoney } = money // works, sometimes
20const { formatMoney: f } = money.default // the other sometimes

The last block is not a joke. A CommonJS module has one export slot and ESM has a named list plus a distinguished default; there is no mapping that is correct in both directions, so tools pick a convention and the mismatch surfaces as a runtime error.

The properties that follow

Every row below is a downstream consequence of static structure versus a runtime call. Reading them together is the fastest way to stop treating the choice as a style preference.

PropertyESMCommonJSWhy it matters to a build
When structure is knownAfter parsing, before evaluationOnly by executing the moduleDecides whether a graph can be built without running your app (The Module Graph)
SpecifierString literal, top level onlyAny expression, anywhereDecides whether a chunk boundary can exist for it (Code Splitting)
ExportsA declared list of named bindingsWhatever ends up on one objectDecides whether unused exports can be removed (Tree Shaking)
Binding semanticsLive — importers see current valuesA value copied at call timeDecides how cycles behave and whether reassignment propagates
CyclesLegal; linked before evaluationLegal; you may receive a partial objectDecides whether a cyclic import is a design smell or a bug
Conditional loadingOnly via dynamic import(), which returns a promiseA plain synchronous call in any branchDecides whether "load this only sometimes" is a split point or an opaque call
Browser supportNative, via type="module"None; must be transformed plus a runtime shimDecides whether interop glue ships to users

The dual-package hazard, and the interop pain around it

A package that publishes both formats can be loaded twice in one application: once by something resolving the import condition and once by something resolving require. Two copies of the code means two copies of everything at module scope — a cache, a registry, a context object, a counter.

The symptoms are the worst kind: intermittent, state-shaped, and nowhere near the cause. A framework context that is undefined in one subtree, a plugin registry that is missing half its entries, an instanceof check that fails against a class that looks identical.

Interop symptoms and their real causes
TriggerSymptomCauseResponse
A package published in both formats, reached through two conditionsA singleton behaves as two; context is undefined in part of the treeTwo module instances, two module-scope statesDeduplicate the resolution, or move state out of the package into an explicit host-side initialisation.
instanceof across the two copiesA check fails against an object that is obviously the right shapeTwo distinct class objects from two module instancesCompare by a branded field rather than identity, and fix the duplication underneath.
Default-export interopX is not a function, or a working X.defaultmodule.exports mapped onto ESM default in the direction that loses informationUse the package's documented ESM entry; where you must, normalise once in a small adapter rather than at every call site.
CommonJS dependency with no ESM buildThe whole library is in the treemap for one imported functionNo static export list to analyse (Tree Shaking)Deep-import a submodule, replace the dependency, or accept and isolate it behind a split point (Code Splitting).
Wrong condition resolvedNode built-in shims appear in a browser bundleThe node or default condition matched instead of browser or importSet the build's conditions explicitly and print the resolved path to confirm (Bundlers Compared).
Two versions of one package in the lockfileTwo similar rectangles in the treemapTwo dependencies pinned incompatible rangesResolve to one version where semver allows, and add a duplicate check to CI (Bundle Analysis).

How to build it

Most important first.

  • Write ESM in your own code, everywhere. It is what the platform runs, what the tools analyse, and what makes every other optimisation in this module possible.
  • Prefer dependencies that publish ESM with a correct exports map. When two libraries are otherwise comparable, this is a legitimate tiebreaker (Tree Shaking).
  • For packages you publish, ship ESM as the primary format and be deliberate about a CommonJS fallback. If you ship both, make the entry points share no stateful module, or accept that consumers may load both.
  • Keep stateful singletons out of dual-published packages. Put state behind an explicit initialisation the host application performs once, so two copies of the code cannot mean two copies of the state.
  • Set the browser and import conditions correctly and test the resolution rather than assuming it. A one-line manifest mistake ships an entirely different build (The Module Graph).
  • Avoid conditional require inside otherwise-ESM modules. It forces conservative handling for the whole file and is usually a lazily-loaded dependency in disguise (Lazy Loading).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Format choice has no direct accessibility surface, but it is one of the largest levers on how much JavaScript reaches a device, and on a low-end device that is the gap between rendered and operable (Long Tasks).
  • Interop wrappers and unshakeable CommonJS modules are pure overhead in every chunk that contains them — bytes and parse time that buy the user nothing (The Real Cost of JavaScript).
  • A dual-loaded package with module-level state can break accessibility infrastructure specifically: two copies of a focus manager, an announcement queue or a portal registry compete, and the symptom is announcements that never arrive (Live Regions and Announcement).
  • Whichever format you ship, the accessibility contract is tested on the built artifact, which is the only place the interop layer exists (Accessibility Testing).

What can go wrong

Failure modes
  • The dual-package hazard: two copies of one package, two module registries, and a singleton that is no longer single.
  • X is not a function after adding a build step, because a default export and module.exports were interoperated in the direction that loses information.
  • A CommonJS dependency shipping whole because there was nothing to analyse, showing up as an unexpectedly large rectangle in the treemap (Bundle Analysis).
  • A cycle that resolves to undefined in CommonJS — the partially-initialised export object — where the same cycle in ESM would have worked through live bindings.
  • A Node-conditioned build reaching the browser, complete with shims for filesystem and buffer APIs that will never be used.
  • An ESM-native dev server needing to prebundle CommonJS dependencies, so a stale prebundle cache produces errors that look like source bugs (Bundlers Compared).
What can arrive out of order
  • Two copies of a dual-published package initialising independently, so whichever registers last wins — a race whose outcome depends on chunk arrival order.
  • A stale dependency prebundle in an ESM-native dev server racing a lockfile change, serving a version that no longer matches what a build would produce.
Security
  • Both formats ship to the browser and both are public. Format has no bearing on secrecy (Storage Security and Durability).
  • A CommonJS module whose require calls are computed is opaque to review and to static analysis, which makes it a comfortable place for a supply-chain payload to live (Software Supply Chain Security in Security).
  • Conditional exports are a redirection layer. A malicious or careless package can serve one file to your reviewers' environment and another to your build's (Dependency Security in Security).
  • Top-level module code runs at import time in both formats, with your page's full authority. Nothing about ESM makes an imported module safer than a required one (Third-Party Scripts and the Supply Chain).
Misreads
  • "They are two syntaxes for the same thing." One is a declaration analysed before execution; the other is a function call evaluated during it. Everything a bundler can do downstream follows from that difference.
  • "import is just newer." It is structurally different: live bindings, hoisted linking, one evaluation, static shape.
  • "The bundler normalises them, so it does not matter." It normalises the calling convention. It cannot recover an export list that was never statically declared (Tree Shaking).
  • "Dual publishing is strictly safer." It is safer for compatibility and it is how one package becomes two copies with two sets of module state.
  • "Cycles are broken in ESM." Cycles are legal in ESM and work through live bindings; it is CommonJS that hands you a partially-initialised object.

Measuring it, and what changes in the field

How you would see this
  • The treemap: a dependency present in full for one imported function is usually a CommonJS build (Bundle Analysis).
  • The bundler's interop warnings, which name the modules it had to wrap and the ones it could not analyse.
  • Resolution output — most tools can print which file a specifier resolved to and via which condition, which is the fastest way to confirm a dual-package problem.
  • A duplicate-package check in CI over the lockfile, which catches two versions before they reach a bundle.
  • A runtime assertion for singletons that must be single: initialise once and throw loudly on a second initialisation rather than debugging the consequences later.
Slow device, slow network, large data, old tab
  • In an application, the cost of CommonJS dependencies is bytes and unshakeable code; you can usually route around it by choosing differently.
  • In a published library, the format decision is your consumers' problem, and it determines whether they can tree-shake you at all.
  • In a mixed repository with server and browser code, conditional exports do real work and are correspondingly easy to misconfigure.
  • On a slow device, every interop wrapper and every unshakeable module is parse and compile time nobody asked for.
  • In an ESM-native dev server, CommonJS dependencies must be prebundled before they can be served, which is where the dev and production paths diverge most visibly.
What this costs
  • Publishing ESM only is cleaner and excludes consumers still on a CommonJS pipeline; publishing both invites the dual-package hazard.
  • Choosing dependencies by module format sometimes means the less pleasant API, or writing the small piece you needed yourself.
  • Conditional exports give precise per-environment builds and multiply the number of artifacts you must test.
  • ESM's static restrictions — top-level only, literal specifiers — are exactly what enable the analysis, and they genuinely are restrictions.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe static-versus-dynamic distinction is a property of the two module systems themselves and holds in every bundler and every runtime. What differs is how much interop machinery each tool inserts and how loudly it warns when it cannot analyse a module.
  • PLATFORM-SPECIFICNode resolves both formats natively and applies its own rules for type, extensions and conditions; browsers execute only ESM and have no require at all. Advice about interop written for a Node context therefore does not transfer directly to a browser build.
  • SPEC-EVOLVINGConditional exports, import attributes and the practical conventions around dual publishing have all changed over recent years and continue to. The static-versus-dynamic core is stable; the exact manifest fields should be checked against current package documentation.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Compilers & Programming Languages — module linking, live bindings and cyclic dependency resolution as a language-design problem, and why static structure is what makes whole-program analysis tractable.