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.
Why does the module format of a dependency change what my bundler can do with it?
A person downloads an application. The format its dependencies were published in decides how much of them they have to download.
They are two syntaxes for the same idea. require and import both bring in a module; the bundler normalises them and nothing downstream cares.
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).
importdeclarations 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.exportsdo not map onto each other cleanly, which is where.default.defaultand "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).
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 resultingmodule.exportsobject, 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.exportsafter the fact does not reach existing importers, and why cycles produce partially-initialised objects rather than a link error. - The
exportsfield in a package manifest maps subpaths to files, and its conditions —import,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
requirethat 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.
1// ---- ESM: shape known after parsing, before evaluation2import { formatMoney } from 'money-lib'3export const price = (c) => formatMoney(c, 'EUR')4// A tool knows: edge to money-lib, one binding used, top level5// declares one export. All of it without running anything.6 7// ---- CommonJS: shape known only by executing8const 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 of14// its properties are used, and whether the second require runs15// are all runtime questions.16 17// ---- And the interop seam, where the errors come from18import money from 'money-lib' // default? namespace? the object?19const { formatMoney } = money // works, sometimes20const { formatMoney: f } = money.default // the other sometimesThe 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.
| Property | ESM | CommonJS | Why it matters to a build |
|---|---|---|---|
| When structure is known | After parsing, before evaluation | Only by executing the module | Decides whether a graph can be built without running your app (The Module Graph) |
| Specifier | String literal, top level only | Any expression, anywhere | Decides whether a chunk boundary can exist for it (Code Splitting) |
| Exports | A declared list of named bindings | Whatever ends up on one object | Decides whether unused exports can be removed (Tree Shaking) |
| Binding semantics | Live — importers see current values | A value copied at call time | Decides how cycles behave and whether reassignment propagates |
| Cycles | Legal; linked before evaluation | Legal; you may receive a partial object | Decides whether a cyclic import is a design smell or a bug |
| Conditional loading | Only via dynamic import(), which returns a promise | A plain synchronous call in any branch | Decides whether "load this only sometimes" is a split point or an opaque call |
| Browser support | Native, via type="module" | None; must be transformed plus a runtime shim | Decides 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A package published in both formats, reached through two conditions | A singleton behaves as two; context is undefined in part of the tree | Two module instances, two module-scope states | Deduplicate the resolution, or move state out of the package into an explicit host-side initialisation. |
instanceof across the two copies | A check fails against an object that is obviously the right shape | Two distinct class objects from two module instances | Compare by a branded field rather than identity, and fix the duplication underneath. |
| Default-export interop | X is not a function, or a working X.default | module.exports mapped onto ESM default in the direction that loses information | Use 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 build | The whole library is in the treemap for one imported function | No 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 resolved | Node built-in shims appear in a browser bundle | The node or default condition matched instead of browser or import | Set the build's conditions explicitly and print the resolved path to confirm (Bundlers Compared). |
| Two versions of one package in the lockfile | Two similar rectangles in the treemap | Two dependencies pinned incompatible ranges | Resolve 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
exportsmap. 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
browserandimportconditions correctly and test the resolution rather than assuming it. A one-line manifest mistake ships an entirely different build (The Module Graph). - Avoid conditional
requireinside 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
- The dual-package hazard: two copies of one package, two module registries, and a singleton that is no longer single.
X is not a functionafter adding a build step, because a default export andmodule.exportswere 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
undefinedin 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).
- 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.
- Both formats ship to the browser and both are public. Format has no bearing on secrecy (Storage Security and Durability).
- A CommonJS module whose
requirecalls 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).
- "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.
- "
importis 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
- 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.
- 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.
- 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 norequireat 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.
- — 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.