Modules — ESM, CommonJS & Resolution
In one line
ESM imports are static, hoisted, and live — the module graph is resolved before any code runs, which is what makes tree shaking and circular imports behave the way they do.
What it is
CommonJS is imperative. require() is a function call that runs at the point it appears, executes the module if it hasn't run yet, and returns a copy of whatever module.exports pointed to at that moment. Because it's a runtime call, you can require conditionally, inside a function, with a computed path — and because it's dynamic, a bundler generally cannot know what you used.
ESM is declarative. import statements are hoisted and processed in three phases: construction (fetch and parse every module, building the graph), instantiation (allocate the exported bindings and wire imports to them), then evaluation (run module bodies, once each). The names are known statically before a line executes.
The consequence people trip on is live bindings. An ESM import is a reference to the exporting module's binding, not a copy of its value. If the exporter reassigns it later, importers see the new value. Under CommonJS they would not. This is why import { count } can change under you while const { count } = require(...) cannot.
Static structure is also what makes tree shaking possible: a bundler can see that only two of a module's twenty exports are imported and drop the rest — provided the module has no side effects, which is what "sideEffects": false in package.json declares.
Circular dependencies behave differently in each. CommonJS gives you a partially-populated exports object, silently. ESM handles function declarations fine because they're hoisted during instantiation, but accessing a const from a cycle before its module has evaluated throws a TDZ ReferenceError — noisier, and better.
import() is the escape hatch: a function returning a promise, usable anywhere, and the mechanism behind every code-split route and lazily-loaded component.
Dual packages are where this becomes a real support burden. A package shipping both formats can end up loaded twice in one process — the CJS copy and the ESM copy — with separate module state. Two instances of a singleton, two React contexts, instanceof failing across them. The exports field in package.json controls which entry point each consumer gets and is the main tool for containing this.
In the browser, <script type="module"> is deferred by default, always strict mode, has its own top-level scope rather than sharing globals, and is fetched with CORS.
Why it matters
Bundle size questions bottom out here — "why didn't this tree-shake?" is nearly always a side-effectful module or a CJS dependency. So do a whole family of confusing build errors: ERR_REQUIRE_ESM, "exports" is not defined, a library that works in dev and breaks in the Next.js server build.
At senior level you're expected to reason about the module graph rather than trial-and-error the config, and to know what you're committing to when you publish a package other teams consume.
Key points
- ESM resolves its graph statically before evaluation; CommonJS resolves at runtime when
require()executes, which is why only ESM can be reliably tree-shaken. - ESM imports are live bindings to the exporter's variable — reassignment is visible to importers. CommonJS hands back a value copy.
- Tree shaking needs both static imports and an absence of side effects;
"sideEffects": falseis the declaration bundlers rely on. - Circular imports give CommonJS a half-initialised
exportsobject silently, while ESM throws a TDZReferenceErrorforconstaccess before evaluation. - Dynamic
import()returns a promise and is the basis of route-level code splitting and lazy components. - A dual-format package can be instantiated twice in one process, producing duplicated singletons and
instanceoffailures across the copies. - The
exportsfield inpackage.jsonis what decides which entry a consumer resolves, and it is the main tool for avoiding the dual-package hazard. <script type="module">is deferred, strict, CORS-fetched, and scoped to the module rather than sharing the global namespace.