If you’ve worked with JavaScript for any length of time, you’ve probably noticed that we’ve had multiple module systems floating around, leading to some real confusion for modern devs. Historically, Node.js leaned on CommonJS (CJS) (require()
/module.exports
), while the browser world standardized ECMAScript Modules (ESM) (import
/export
). Nowadays, the JS ecosystem is migrating toward ESM, yet CommonJS remains stubbornly popular.
In this post, I’ll walk through how these module formats came to be — covering older ones like AMD and UMD, and how Node.js ended up using CommonJS in the first place. We’ll then examine how the shift to ESM is playing out for real projects, with TypeScript, monorepos, popular frameworks (like Next.js, TurboRepo, Bun), testing setups (Jest, Vitest, and Node’s built-in runner), plus a few classic pitfalls when mixing or migrating module formats.
Background: From CommonJS to ES Modules… and Beyond
Why Node.js Used CommonJS
Back around 2009, there was no standardized module syntax in JavaScript. Node’s creators needed a consistent way to chunk up server-side code, so they grabbed the emerging CommonJS spec. It gave them a straightforward, synchronous require()
approach: great for reading from disk on the server, and it avoided the messy global-scope collisions that front-end devs were used to under <script>
tags.
Node formalized this with require()
and module.exports
(or exports
). It was never an official ECMAScript standard, but it effectively powered the entire Node ecosystem (npm) for years. Having come from a background where <script>
collisions were a big headache, I found it a relief when I could just do require('something')
and not worry about stepping on global variables. That vibe stuck around for a good chunk of the 2010s.
Browser Module Systems (AMD and UMD)
Meanwhile, web devs wanted modules in the browser, but they needed asynchronous loading. So we got Asynchronous Module Definition (AMD) with RequireJS. Instead of require()
, it used a define()
function that loads modules in parallel via script tags, no blocking the browser event loop. The code was clumsy, but it was a workaround before we had real ES module support. Then came UMD (Universal Module Definition), which basically tried to unify AMD, CommonJS, and global variables in one format. It’d check if define()
is around, or require()
, or else attach itself to the global window object. That was “write once, run anywhere”, and it worked, but it’s definitely old-school now.
ECMAScript Modules (ESM)
Then ES6 (2015) finally brought us official modules: import
/export
in JavaScript itself. Unlike CommonJS’s runtime require, ESM is statically analyzable, that’s huge for tooling (e.g., tree-shaking). It also introduced dynamic import()
, default exports, and eventually top-level await
. Browsers started supporting ESM natively around 2017-2018, and Node added stable ESM in 2019 (Node v12).
Node’s Cautious Adoption of ESM
Because CommonJS was so entrenched, Node took a few years to add ESM properly. Around Node 12, ESM arrived behind flags; by Node 14, it was stable, but with new rules for file extensions and a "type"
field in package.json
. Typically:
.js
is CommonJS, unless your package.json says"type": "module"
..mjs
is always ESM;.cjs
is always CommonJS.
So you might set "type": "module"
in your package and rename old scripts to .cjs
. Node needed that approach to stay backward compatible.
Today’s Landscape
As of 2025, ESM is basically standard across Node, browsers, Deno, Bun, you name it. That said, CommonJS isn’t dead. In late 2022, only about 9% of popular npm packages were pure ESM, ~4% offered dual-format, and the remaining ~87% were still purely CJS or “faux” ESM. So most Node packages out there remain CommonJS. Over time, though, we see an uptick in ESM adoption. Node itself will keep supporting CJS indefinitely, but ESM lines up with the browser and other JS runtimes, that’s clearly the direction we’re heading.
Before diving into the nuts and bolts of how ESM differs from CommonJS, let’s lay out the major contrasts so we’re all on the same page.
Key Differences Between CommonJS and ESM
Although they’re both “module systems,” CJS and ESM differ substantially behind the scenes:
Import/Export Syntax
- CommonJS:
const lib = require('lib'); module.exports = whatever;
- ESM:
import { doThing } from 'lib.js'; export function myFunc() {}
ESM imports must specify a file extension in Node ("./lib.js"
) unless you have a specific resolution config. In CJS, you can omit.js
in many cases. Syntactic subtlety can trip you up if you forget.
Synchronous vs. Asynchronous Loading
- CJS loads a module immediately when
require()
is called. - ESM is loaded asynchronously in parallel, analyzing imports up front. Because ESM is asynchronous, Node prevents you from just doing
require()
on an ESM file. That’s why you get theError [ERR_REQUIRE_ESM]: require() of ES Module not supported
. If you’re in CJS code and want to load an ESM file, you have to do something like:
const myModule = await import('./myModule.mjs');
That’s a big mental shift from the old require()
pattern.
Static vs. Dynamic Structure
- ESM is statically analyzable. Your exports are known at compile time, awesome for tree-shaking. You can’t dynamically add new exports in the middle of execution.
- CommonJS is more dynamic, since
module.exports
is basically an object you can mutate. This difference can lead to some weird corner cases, especially if you rely on something like changing exports at runtime (not recommended, but historically not unheard of).
Top-Level await
and this
- ESM lets you do
await
right in the top scope. - CommonJS doesn’t. Also, top-level
this
in ESM isundefined
, while in old-school CJS it pointed tomodule.exports
(though most Node code is in strict mode by now anyway).
File Extensions / Package Boundaries
- In Node, setting
"type": "module"
inpackage.json
makes.js
parse as ESM. Otherwise,.js
is typically CJS. .mjs
is always ESM,.cjs
is always CJS.
Meanwhile, browsers only speak ESM these days (no concept ofrequire()
built in).
Importing CommonJS in ESM (and vice versa)
- ESM can import a CJS package just fine, but it appears as a default export (the entire
module.exports
object). - CommonJS cannot simply do
require('esm-only-lib')
. You’ll get a require-of-ESM error. You’d have to do a dynamic import or convert your code to ESM. This asymmetry catches many people off guard.
Module Scope / Special Variables
- CommonJS modules have
__dirname
,__filename
, plus amodule
object. - ESM doesn’t provide those by default. Instead, you can use
import.meta.url
and the URL API to replicate them. It’s a smaller difference, but important if your old code relies heavily on those Node-isms.
So in short, CommonJS is synchronous and dynamic, while ESM is asynchronous, statically analyzable, and better integrated with modern JS. They can work together in Node, but you need to handle interop carefully, or it can cause headaches.
The Ecosystem’s Transition to ESM
Node.js Support and Backward Compatibility
Today, Node >=12.22 supports ESM, and from Node 14 onward, it’s stable enough that a lot of libraries offer ESM builds. Node’s approach is incremental, it leaves CommonJS in place but adds ESM as a parallel option. This means you can have some .mjs
files, some .cjs
files, etc. That’s a big reason why this transition is slow. Node devs don’t have to drop CommonJS if they don’t want to.
Package Author Strategies
Library authors often do one of three things:
- Dual modules: publish both CommonJS and ESM, using the
package.json
exports
field to directimport
vs.require
to different files. - ESM-only: cut ties with CommonJS, telling older Node users to adapt or use an older version.
- CJS-only: keep shipping CommonJS if their users are mostly old Node versions or have no impetus to change.
Interestingly, the official Node docs actually encourage authors to publish in one format to avoid complexity. They say if possible, either do ESM-only or CJS-only, because double publishing can cause the dreaded “dual package hazard”, which we’ll discuss soon.
TypeScript and Build Tools
Historically, TypeScript defaulted to output CommonJS. But now TS has advanced ESM modes like "module": "nodenext"
and can compile one codebase into both ESM and CJS. Tools such as tsup, Rollup, webpack, and esbuild let you produce dist/index.cjs
and dist/index.mjs
easily. That’s why so many TS-based libraries are starting to ship dual mode. It’s basically a config line, plus you need to test in both environments.
Adoption in Practice
By Node 18 or 20, it’s really smooth to do ESM in production. Many frameworks, though, like Express and Jest, were built around CJS, so they’ve had to adapt. Some are fully ESM-compatible now, but if you depend on a library that’s ESM-only, that can force you to switch your own code to ESM. It’s a bit of a domino effect.
Ecosystem Tools and Runtimes
Modern bundlers favor ESM due to tree-shaking. Tools like Vite, esbuild, or Webpack prefer an ESM build of a dependency if available. Meanwhile, new runtimes like Deno only support ESM, ignoring the entire CommonJS approach. Bun tries to support both, letting you mix import
and require
with minimal friction. Some people love that, though it basically means Bun takes on the complexity instead of you. As the Bun blog says, “CommonJS is here to stay” but the friction is slowly decreasing.
Single Projects vs. Monorepos
Single-Project Applications
For a typical Node.js backend, you’ve got two big choices, keep it CommonJS (the old way) or switch to ESM. With ESM, you just add "type": "module"
to package.json
or use .mjs
files. That means you can do import
/export
, top-level await
, etc. The main watch-out is if you pull in an ESM-only package, your CommonJS code can’t just do require('that-esm-lib')
; you need dynamic import or to flip your entire codebase to ESM.
For front-end apps, you usually write in ESM from the get-go. Build tools bundle everything up. Any CommonJS dependencies get wrapped behind the scenes. So from your perspective, it’s all import foo from 'foo'
. A framework like Next.js might still keep a next.config.js
in CommonJS, but the app code is definitely ESM. Some folks do next.config.mjs
if they want to unify everything.
Monorepos and Shared Packages
In a monorepo, you might have multiple packages or apps. Some strategies:
- Write everything in ESM and compile to CJS if needed for older tools.
- Or ship dual builds (CJS and ESM) from each package, using an
exports
map. - Or remain all CJS if that’s simpler, though that’s more old-school.
If you do produce both ESM and CJS for a package, be mindful of the “dual package hazard” (two versions loaded at once in the same app). Usually, you want to make sure your monorepo is consistent in how it imports those internal packages. Tools like Turborepo or Yarn/Bun workspaces can help with linking, but you have to ensure nobody’s require()
-ing a library that’s also being import
ed in a different subproject. If that happens, you might see mysterious duplication or shared-state issues. We’ll dive deeper into that soon.
Next.js, TurboRepo, etc.
Next.js can handle ESM or CJS for your libraries (just watch out for config issues). TurboRepo orchestrates building them. If you do a dual build (like dist/index.cjs
and dist/index.mjs
), you can set up "exports": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }
so Node picks the right file. Meanwhile, bundlers (like Next’s Webpack/Turbopack) will read the ESM build, so you get tree-shaking and other benefits.
Bun in a Monorepo
Bun is super lenient with module formats. It’ll typically let you do require
and import
in the same file if you like. That’s convenient for dev, but if you deploy to Node in production, you can’t rely on that. So I’d treat it as a local dev convenience, not something to rely on for your final production environment. Just a heads-up from personal experience. I tried mixing those in my dev environment, only to break when shipping to a Node-based server. Learned that the hard way.
ESM and Unit Testing
Jest
Jest has dominated JS testing for a while, but it’s historically CJS-based. You can test ESM code by letting Babel or ts-jest transpile it back to CJS, or by using its experimental ESM mode. As of Jest 28+, you can attempt native ESM testing, but you often need the --experimental-vm-modules
flag, plus you can’t rely on the old jest.mock('module')
approach if you’re purely in ESM. That’s because ESM imports are resolved before any test code runs, so Jest can’t intercept them the way it does with require
. There’s a new method, jest.unstable_mockModule()
, which requires dynamic import()
to bring in the mocked module after the mock call.
Honestly, many people still keep their tests in CommonJS or Babel-transpiled ESM so that jest.mock()
just works like it always did. If you’re heavily into top-level await
in your actual code, though, that might get weird with transpilation, so you’d have to test it carefully. I’ve personally run into Cannot use import statement outside a module
or ERR_REQUIRE_ESM
a few too many times when misconfiguring Jest. If it happens to you, check your jest.config.js
to ensure it’s not treating ESM files as CommonJS. It’s definitely a gotcha zone.
Vitest (Vite’s Approach)
Vitest is a newer testing framework that pairs with Vite. It’s ESM-first, so it can handle your ESM code gracefully. The main difference is that Vitest can’t do auto-mocking for modules loaded with require()
. If your code is fully ESM, great. Vitest’s mocking is sweet. If you rely on old CommonJS files, though, you might have to rewrite them or do some manual hacking with mock-require
. Many devs love Vitest for its speed and simpler config, especially if they were sick of fighting with Jest’s ESM quirks. One developer I read about switched a big Node repo from Jest to Vitest for exactly that reason, and only had to tweak a couple test methods. Overall, if your code is already ESM (or you’re willing to switch), Vitest can be a refreshingly modern approach.
Node’s Built-in Test Runner (node:test)
In Node 18, we got a built-in test runner (node --test
). It’s minimal but allows subtests, basic mocks, and everything runs in the real Node environment, no extra transformations. By Node 20, it added some mocking/spying APIs. They’re not quite like jest.mock()
. Instead, you can do mock.method(object, 'methodName', yourFake)
to override something that’s already been imported. True “replace an entire import” mocking isn’t there, because ESM doesn’t really let you intercept that easily. If you really need that, you might try loader hooks or third-party packages like esmock
. On the flip side, if your test doesn’t require heavy module-level mocking, the built-in runner is nice and straightforward. I’ve personally found it great for smaller packages or library code that doesn’t need advanced mocking.
Pitfalls, Gotchas, and Less Obvious Issues
The Dual Package Hazard
If a library ships both ESM and CJS builds, it’s possible to load it twice in the same app, like if your code does import library
while a dependency does require('library')
. Node sees them as separate modules, so that library’s internal singletons or state can get duplicated. That leads to weird bugs (like two different class instances that look the same but fail instanceof
checks). The official Node docs warn about this. Easiest fix is to ensure you only load that library in one format or ensure the library only publishes one format.
Incorrect “type” Field Usage
Another classic is SyntaxError: Unexpected token 'export'
because you set "type": "module"
but wrote old CommonJS code. Or the reverse, forgetting to set "type": "module"
and mixing import syntax. Node tries to parse your file in the wrong mode. The fix is to keep file extensions and package.json
settings consistent. If you truly need both ESM and CJS, use .mjs
and .cjs
.
Interoperability Surprises
For instance, if you import a CommonJS module in ESM as import foo from 'foo-package'
, you get foo
as the entire exports object. If that library updates to a genuine ESM build with named exports, you might have to change your imports. Also, a library might do weird “hybrid” code that sets exports.__esModule = true
for Babel-interop, leading to double-wrapping. Usually you want to find that library’s official ESM entry if it exists.
Mocking and ESM
If you used to monkey-patch require('fs')
or do jest.mock('fs')
, that might not be straightforward in pure ESM. ESM’s static import is read-only at the top level. Often the recommended approach is to pass dependencies as function parameters so you can test them. Or you can rely on specialized mocking in tools like Vitest or use Node’s experimental mocking methods. But it’s definitely trickier than it was in CJS.
Inconsistent Error Messages
Over the years, Node’s ESM errors have evolved, Must use import to load ES Module (ERR_REQUIRE_ESM)
or Cannot use import statement outside a module.
Sometimes it’s a matter of forgetting a file extension or referencing a subpath that’s not in the exports
field. If you see bizarre module resolution errors, double-check the extension or your package.json
fields. Also note that ESM doesn’t guess .js
if you omit an extension, while CJS might have done that, but ESM is more strict.
Publishing & Tooling Challenges
If you publish an ESM-only library, you might get user questions like, “Why can’t I require this?” or “Jest is blowing up.” The ecosystem is better now, but you might still have to provide guidance. Some packages solve this by shipping both ESM and CJS, but that can lead to the dual package hazard. If you can, test your library in both Node and bundlers to ensure it’s truly universal. TypeScript adds another layer: you might have to produce .d.ts
that’s correct for both. Usually not a big deal, but worth verifying.
Hybrid (Self-Referential) Packages
There’s also a corner case if a package tries to import itself, and ends up grabbing its own other format. Node 14+ improved self-referencing with the imports
field, but it can still cause confusion. If you see a bizarre recursion, check if you’re importing your own package name from inside itself in two ways.
Overall, ESM is stable, but watch for those “statically analyzable” quirks. If your old code did a lot of dynamic requires or monkey-patching, you’ll need to adapt.
Conclusion
The JavaScript module saga, from <script>
tags to CommonJS/AMD/UMD, culminating in official ECMAScript Modules, has been long and occasionally painful. Node made CommonJS the default, which served us well, but the modern direction is clearly ESM. If you start a new project in 2025 (or beyond!), you’ll probably want to embrace ESM for that sweet synergy with browsers and other runtimes. CommonJS, however, still hangs around for legacy reasons, so we’ll be living in a dual-world for quite a while.
To sum up:
- Understand the differences (sync vs. async loading, static vs. dynamic, file extension quirks). This is crucial for debugging weird ESM vs. CJS issues.
- For new projects, definitely consider going ESM from the start. Node 18+ makes that pretty straightforward, and it lines up with all modern tooling.
- For older or existing projects, you can migrate gradually. Start converting leaf modules, use
.cjs
for legacy bits, test carefully. - Monorepos benefit from consistency. If you do dual builds, watch out for the dreaded dual-package hazard.
- Testing in an ESM world can be more complicated, so check out Vitest if you prefer ESM-first mocking, or stick with Jest’s transpile approach. Node’s built-in test runner is also an option if you don’t need fancy mocks.
- Be mindful of pitfalls, double-loading a library, messing up the
"type"
field, or monkey-patching ESM exports. They’ll trip you up if you’re not careful.
As Node’s own docs mention, ESM used to be experimental and had shifting best practices, but these days it’s fairly solid. With better tooling and stable specs, adopting ESM can make your projects cleaner, safer, and more future-proof. I hope this deep dive helps you navigate without too many module resolution nightmares!
Enjoyed this piece?
If this piece was helpful or resonated with you, you can support my work by buying me a Coffee!

Become a subscriber receive the latest updates in your inbox.
Member discussion