A library I depend on shipped a major-version bump last year that switched it from CommonJS to ESM. The release notes were one paragraph; the migration on my side took the better part of a day. I had to update three Vite configs, change a Jest setup file (Jest's ESM support is conditional, depending on which mode you launched it in), update the bin script's shebang, and trace down a subtle issue where a tool that imported the library was crashing because it expected a .default property and the new ESM shape did not provide one. None of these were the library's fault. Each one was the JavaScript ecosystem reminding me that "modules" are a single word for at least three things, depending on who is doing the loading.
The argument I want to make is that the CJS/ESM dichotomy is not a single-axis spectrum where ESM is "newer and better" and CJS is "legacy". Both module systems are still in active use, both have semantics that the other cannot fully emulate, and the interop layer between them is where most of the practical pain lives. Knowing the layer matters because it determines what your tooling can and cannot do, and which choices in your package.json are actually load-bearing.
What CJS and ESM each are, mechanically
CommonJS is Node.js's original module system. A CJS module is a JavaScript file that runs as if it were the body of a function: require, module, exports, __dirname, and __filename are local-feeling globals that Node injects when it loads the file. Exports are values assigned to module.exports (or shorthand: properties of the exports object). Imports are require('./mod') calls that synchronously read, parse, and execute the imported file, returning its module.exports.
CJS evaluation is synchronous. The require call blocks until the imported module has run to completion, including any side effects in the module body. Cyclic imports return whatever module.exports was set to at the moment the cycle was hit, which often means an incomplete object.
ECMAScript Modules (ESM) are the standardised module system, originally designed for browsers and adopted by Node. An ESM module declares its imports and exports with import and export keywords. The dependency graph is parsed and resolved before any code runs; bindings are linked statically.
ESM evaluation has phases: parse all modules, link the imports, then run the bodies in dependency order. Imports are live bindings, not value copies; if math.mjs exports a variable that is later reassigned (in its own body, not by the importer), the importer sees the new value.
The two systems do not just look different; they have different lifecycle semantics, different scoping rules (CJS has implicit module and exports; ESM has neither, plus top-level await is allowed), and different relationships to the surrounding tooling.
The four sources of practical interop pain
Years of fighting with this stack have taught me to recognise four separate categories of pain. They are usually grouped together as "ESM/CJS interop", but they have distinct causes.
File extensions and type field. .cjs is CJS, .mjs is ESM, regardless of any other setting. .js defaults based on the nearest package.json's "type" field: "type": "module" makes .js files ESM, "type": "commonjs" (or the absence of type) makes them CJS. This is the rule the rest of the system rests on; getting it wrong produces the most confusing errors.
Bundler interop. Bundlers (Webpack, Vite, esbuild, etc.) consume both shapes and emit one shape. The __esModule flag is the convention bundlers use to mark transpiled ESM-from-CJS-source: when an ESM file imports a CJS file, the bundler wraps the CJS export so import x from './cjs-file' works as if the CJS module had a default export. Some bundlers add a real __esModule: true marker, some interpret the absence of it heuristically. The output is mostly compatible, but the heuristics differ.
Node runtime interop. Until recently, Node could not require() an ESM module synchronously; you had to use dynamic import(), which returns a promise. Recent Node versions (>= 22.12) allow require of ESM under specific conditions (the ESM module must not be top-level-async). Going the other direction (ESM importing CJS) has always worked: the import gets a default-export wrapping the CJS module's module.exports, and named imports work for CJS modules whose exports the loader can statically detect.
Tooling interop. Test runners, type-checkers, dev servers, and lint plugins each have their own opinion on how to handle the two systems. Jest needs explicit ESM support flags or it falls back to a CJS transform. ts-node has separate ESM and CJS modes. tsx is closer to drop-in. ESLint plugins that crawl the import graph might assume one shape. Each tool's docs has a section titled "ESM Support" that you have to read carefully.
Conditional exports and the dual-publish problem
A library that wants to support both module systems publishes both. The mechanism Node provides is the "exports" field in package.json, with conditional entries.
When a CJS consumer does require('my-lib'), Node walks the exports map, finds the require condition, and serves dist/index.cjs. When an ESM consumer does import 'my-lib', Node serves dist/index.mjs. The types condition serves the TypeScript declaration file regardless.
The dual-publish problem is real and structural: if your library has any module-level state (a singleton, a registry, a cache), that state is duplicated. CJS consumers and ESM consumers see different instances. For a logger, an event bus, or a connection pool, this is a bug. The workaround is to factor the state into a separate file with a stable representation that both shapes can share, or to publish only one shape and force consumers to use the matching loader.
Named imports of CJS from ESM: where the rules bite
The detail that causes the most confusion in practice: when you import { foo, bar } from 'a-cjs-package', Node statically analyses the CJS module's exports to extract the named bindings. The analysis works for the common case (module.exports = { foo: ..., bar: ... }) and for exports.foo = ...; exports.bar = ...;. It does not work for dynamic exports, conditional assignments, or module.exports set inside an if.
The third form forces ESM consumers to use a default import: import pkg from 'a-cjs-package'; pkg.foo;. The named-import form fails at parse time with does not provide an export named 'foo'. The fix on the library side is to keep the top-level export shape static even if the values inside are dynamic; the fix on the consumer side is to use the default import and access fields on it.
The --experimental-vm-modules and other Jest gotchas
Jest deserves its own paragraph because so many teams hit the same wall. Jest's transform pipeline historically assumed CJS. To run ESM tests, you either configure Jest to use a CJS transform of the ESM source (the babel-jest path with @babel/preset-env), or you launch Node with --experimental-vm-modules and configure Jest to use its native ESM mode. The two paths have different semantics for import.meta, dynamic imports inside test files, and module mocking.
jest.mock('./module') works only in CJS mode reliably; in ESM mode, you need jest.unstable_mockModule and a different test-file structure (top-level await on dynamic imports). I have seen test suites stay on CJS-via-Babel solely because the team's existing mocks would have required a rewrite under ESM Jest.
The alternative is to switch to a runner that treats ESM as native: Vitest is the obvious one, with a Jest-compatible API and a Vite-based transform pipeline. The migration is usually a few hours; the payoff is no more Jest ESM gotchas, and faster startup.
What tsx, ts-node, esbuild, and bun are doing differently
Four runtime options for executing TypeScript directly, each with a different module-loading strategy.
ts-node has separate CJS and ESM modes (ts-node and ts-node-esm). The CJS mode is the most stable; ESM mode uses Node's loader hooks and has worked reliably only since Node >= 20.
tsx is a single binary that transpiles on the fly, defaulting to ESM, with CJS interop via Node's loader hooks. It is faster than ts-node because it uses esbuild for transpilation. It treats ESM as the default and CJS as the interop case, which I find matches the direction the ecosystem is moving.
esbuild is a bundler primarily but has a --bundle=false mode that just transpiles files. Used directly as a runner via tsx (which embeds it) or via the esbuild-runner wrapper.
bun is a separate runtime with its own module loader. ESM and CJS both work, with bun's own interop layer between them. Bun has been opinionated about default-export wrapping in ways that occasionally surprise teams porting from Node.
Of the four, tsx is what I reach for in scripts and CLI tools, and bun for greenfield projects where the runtime is part of the choice. ts-node I use only when an existing project locks me to it.
A pragmatic policy for new projects
The policy I have settled on, after enough scar tissue:
| Situation | Choice |
|---|---|
| New library, want broad consumer support | Dual-publish ESM + CJS via exports, no module-level state, types as a separate condition |
| New library, willing to require modern Node | ESM-only, document the requirement loudly |
| Application code (not published) | ESM if the runtime supports it; CJS if a critical dep is CJS-only |
| Internal scripts and CLIs | tsx with ESM source, run directly without bundling |
| Test runner | Vitest for new projects; Jest for existing ones with established mocking patterns |
The middle path of "dual publish with no module state" is what most popular libraries land on now. The pure-ESM stance is becoming defensible faster than I expected; once Node 18 was end-of-life'd, the floor for ESM consumers got high enough that the maintenance burden of dual-publishing started to look optional.
Where modules are heading
ESM is the future of the standard, and the ecosystem has been migrating for half a decade. CJS is not going anywhere either; the legacy code base is too large and the interop is too workable. The medium-term shape is "both, with cleaner interop than today, and tooling that makes the boundary invisible most of the time". Bun, Deno, and the Node maintainers are all converging on roughly the same set of conventions: the exports field for resolution, conditional exports for dual-publish, top-level await in ESM, recent Node versions that allow require of ESM under controlled circumstances.
The thing I would tell anyone starting a new project today: write ESM if you can, dual-publish if you must, and document the choice in the README so future maintainers do not have to reverse-engineer it from the package.json. The pain in the interop layer is mostly mechanical; the painful part is when the choice is implicit and the tooling silently does the wrong thing. Make the choice explicit, and the rest is configuration.
