Module Era
ESM + npm2015 – 2020Specified in 2015, shipped in runtimes years later. The language finally got a static module format — and the ecosystem got a decade-long CommonJS interop problem.
Two module systems, one language. `require()` is synchronous and dynamic; `import` is static, hoisted, and delivers live bindings. When they meet, each runtime picks its own default-export story — and that is where the ecosystem's strangest edge cases live.
// This whole snippet runs as a module — the sandbox treats files that
// start a line with export as ESM.
export let count = 0; // importers see a *live* binding, not a snapshot
export function inc() {
count++;
}
console.log("count", count);
inc();
inc();
console.log("count after two inc()", count);
// CommonJS has no live bindings: module.exports is one mutable object and
// require() reads whatever it holds at call time.
//
// // counter.cjs
// let count = 0;
// module.exports = { get count() { return count; }, inc() { count++; } };| V8 | Implements `import`, `export`, and dynamic `import()` — and nothing else. `require` is embedder territory, so no interop logic lives in the engine. |
|---|---|
| Node.js | `import cjs from "pkg"` exposes `module.exports` as the default export, with named exports detected by cjs-module-lexer. `require()` of ESM is experimental in Node 22 and default-on in Node 23+. |
| Deno | No `require` in the native API — npm packages are reached via `npm:` specifiers, and the Node compatibility layer emulates CJS for `node:` built-ins. |
| Bun | Transparent two-way interop: `require()` of ESM and `import` of CJS both work, reconstructing live bindings from CJS where possible. |