Why ESM Is Overtaking CommonJS: A Deep Dive into JavaScript Modules
This article traces the history and reasons behind JavaScript’s module formats—from early AMD and UMD to Node’s CommonJS and the modern ECMAScript modules—explains migration challenges, tooling, testing nuances, and best practices for managing dual builds in contemporary projects.
From CommonJS to ES Modules… and More
Node originally adopted CommonJS (CJS) because, in 2009, JavaScript lacked a standardized module syntax and CJS offered a simple synchronous require() / module.exports mechanism that worked well on the server and avoided global scope conflicts in browsers.
Browser Module Systems (AMD and UMD)
Before native ES modules, browsers used asynchronous module definitions (AMD) via define() and later UMD, which combined AMD, CommonJS, and global fallback logic to achieve "write once, run everywhere".
ECMAScript Modules (ESM)
ES6 (2015) introduced official import / export syntax. Unlike CJS, ESM is statically analyzable, supports tree‑shaking, dynamic import(), default exports, and top‑level await. Browsers gained native support around 2017‑2018; Node added stable ESM in v12 (experimental) and v14 (stable).
Node’s Cautious Adoption of ESM
Node kept CJS for years. Around Node 12 it added ESM behind a flag; by Node 14 ESM became stable but required explicit configuration via the type field in package.json or file extensions: .js defaults to CJS unless "type":"module", .mjs always ESM, .cjs always CJS.
Current Landscape
As of 2025, ESM is the de‑facto standard across Node, browsers, Deno, and Bun, yet about 87 % of popular npm packages remain pure CJS. The ecosystem is gradually shifting, with Node promising indefinite CJS support while encouraging ESM adoption.
Key Differences Between CommonJS and ESM
Import/Export Syntax
CommonJS: const lib = require('lib'); module.exports = … ESM: import {foo} from 'lib.js'; export function bar() {} (file extensions required in Node).
Loading Model
CJS loads modules synchronously via require().
ESM loads asynchronously, performs static analysis, and disallows require() in ESM files (throws ERR_REQUIRE_ESM).
Static vs Dynamic Structure
ESM exports are known at compile time, enabling tree‑shaking.
CJS exports are mutable objects, allowing runtime changes.
Top‑Level Features
ESM permits top‑level await and has import.meta.url.
CJS lacks top‑level await and uses __dirname, __filename, and module objects.
File Extensions / Package Boundaries
With "type":"module", .js is treated as ESM; otherwise it is CJS. .mjs always ESM, .cjs always CJS.
Importing Between Formats
ESM can import CJS packages, receiving the entire module.exports object as the default export.
CJS cannot directly require() an ESM‑only package; it must use dynamic import() or convert the code.
Package Author Strategies
Dual builds: publish both CJS and ESM using the exports field.
ESM‑only: drop CJS support.
CJS‑only: keep legacy format.
Node documentation recommends choosing a single format to avoid the "dual‑package hazard" where the same library is loaded twice in different formats.
TypeScript and Build Tools
Historically TypeScript emitted CJS, but now supports "module":"nodenext" to generate both dist/index.cjs and dist/index.mjs. Tools like tsup, Rollup, Webpack, and esbuild can produce dual builds automatically.
Actual Adoption
Node 18/20 makes ESM production‑ready, yet many frameworks (e.g., Express, Jest) were built around CJS and require migration. Some frameworks now support ESM natively, but mixed dependencies can cause a domino effect.
Ecosystem Tools and Runtimes
Modern bundlers (Vite, esbuild, Webpack) prefer ESM for tree‑shaking. Deno only supports ESM; Bun tries to support both, allowing require and import in the same file for development convenience.
Single Project vs Monorepo
In a single Node backend you can switch to ESM by adding "type":"module" to package.json or using .mjs files. Front‑end projects already use ESM and bundlers handle any CJS dependencies internally.
In monorepos you may publish each package as ESM, CJS, or both. Consistency is crucial to avoid the dual‑package hazard and ensure that internal imports use the same format.
Next.js, TurboRepo, etc.
Next.js can consume either format; TurboRepo can build both. When publishing dual builds, set
"exports": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }so Node resolves the correct entry.
Bun in Monorepos
Bun tolerates mixing require and import in the same file, which is handy for local development but not reliable for production Node deployments.
ESM and Unit Testing
Jest
Jest is CJS‑centric; to test ESM you can transpile with Babel/ts‑jest or enable experimental ESM mode ( --experimental-vm-modules). Mocking differs: use jest.unstable_mockModule() with dynamic import() instead of jest.mock().
Vitest
Vitest is ESM‑first, handling ESM code natively and offering modern mock APIs. It works best when the codebase is fully ESM.
Node Built‑in Test Runner
Node 18+ provides node --test, a simple runner with basic mocking via mock.method(). It lacks advanced mock features but is sufficient for small packages.
Pitfalls, Traps, and Less‑Obvious Issues
Dual‑Package Hazard : Loading the same library as both ESM and CJS creates separate instances, breaking singletons.
Misusing the type Field : Setting "type":"module" while writing CJS code (or vice‑versa) leads to syntax errors.
Interoperability Surprises : Importing a CJS package in ESM yields a default export object; named exports may require import pkg from 'pkg'; const {named} = pkg;.
Mocking Challenges : ESM’s static imports are read‑only; prefer dependency injection or use framework‑specific mock helpers.
Inconsistent Error Messages : Errors like ERR_REQUIRE_ESM or missing file extensions often indicate a mismatch between the file’s format and the type configuration.
Publishing and Tooling : Dual builds increase maintenance; ensure .d.ts files for TypeScript and test both formats.
Self‑Referencing Packages : Importing a package’s own entry in a different format can cause recursive resolution issues; use the imports field to disambiguate.
Conclusion
JavaScript’s module system evolved from <script> tags through CJS/AMD/UMD to native ESM. While CJS remains prevalent for legacy reasons, the clear future is ESM. New projects should start with ESM for better compatibility, and existing projects can migrate gradually by converting leaf modules, using .cjs for legacy code, and ensuring consistent package configuration.
const myModule = await import('./myModule.mjs');Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
