ESM vs CommonJS: JavaScript Modules Explained
Photo by Unsplash on Unsplash
Table of Contents
The Short Version
If you've ever pasted "Cannot use import statement outside a module" into a search box, you've met the seam between these two systems. So has everyone else. It's the most-searched JavaScript error for a reason.
The good news is that the situation genuinely improved. Node made `require()` of ES modules stable across the current LTS lines, which removes the sharpest edge that made dual-publishing painful for years.
The rest is worth understanding properly, because the error messages don't explain themselves.
How CommonJS Works
Everything about it is synchronous and runtime-based. `require('./config')` is a function call that happens when execution reaches that line, which means you can call it conditionally, inside an `if`, or with a computed path. That flexibility is genuinely useful and it's why plugin systems loved it.
It also means the module graph isn't known until the code runs. A bundler can't statically determine what's imported without executing or guessing, which is why tree shaking works poorly with CommonJS — dead code can't be proven dead.
Circular dependencies behave strangely too. A partially-populated `exports` object gets handed back, so you can end up with `undefined` where you expected a function, with no error to tell you why.
How ESM Works
That static structure is the whole point. The engine builds the dependency graph first, resolves every binding, then executes. Bundlers can see exactly what's used, so unused exports get dropped and imports can be reordered safely.
Imports are also live bindings rather than copies. If a module reassigns an exported variable, importers see the new value — genuinely different behaviour from CommonJS, where you received a snapshot of whatever `module.exports` held at require time.
Top-level `await` works in ESM and not in CommonJS, which follows directly from the async loading model. That single feature is why a lot of packages moved, and it's the reason so many projects hit the interop wall at once around 2022.
The Errors You Actually Hit
"require() of ES Module not supported" was the old inverse — CommonJS code trying to require a package that only ships ESM. On Node 22.12 and later this mostly works now, but you'll still hit it on older runtimes or when the module uses top-level await.
"__dirname is not defined in ES module scope" catches everyone migrating. ESM has no `__dirname` or `__filename`; the replacement is `import.meta.dirname` on modern Node, or deriving it from `import.meta.url`.
"ERR_MODULE_NOT_FOUND" on a relative import usually means a missing file extension. ESM requires them — `./utils.js`, not `./utils`. CommonJS guessed for you and ESM refuses to, which feels hostile until you realise guessing was the source of a lot of subtle bugs.
What Changed With require(esm)
Node 22 introduced `require(esm)` behind a flag, and it has since stabilised across the supported LTS lines. CommonJS code can now require an ES module directly, provided that module has no top-level await.
That exception matters. Top-level await is inherently asynchronous and `require()` is synchronous, so there's no way to bridge it. A package using top-level await stays un-requireable, and that's a permanent design constraint rather than a missing feature.
Practically, this means new packages can ship ESM-only without stranding users on CommonJS. Node's official modules documentation covers the exact resolution rules if you're publishing a library and need to get the `exports` map right.
Reading a package.json exports Map
A dual package usually has `"import"` pointing at the ESM build and `"require"` pointing at the CommonJS one, with `"types"` for TypeScript declarations. The keys are matched in order, so putting `"default"` first silently shadows everything after it — a genuinely common mistake.
`exports` also seals the package. Once it exists, deep imports into internal files stop working unless explicitly listed, which breaks consumers who were reaching into `node_modules/pkg/lib/thing.js`. That's intentional, and it's a good thing, but it does cause upgrade pain.
If you maintain a library, the safest 2026 default is to ship ESM as the primary build, add a CommonJS fallback only if your users need it, and avoid top-level await in the entry point so `require(esm)` keeps working for everyone else.
Migrating a Project
Start by adding `"type": "module"` and renaming any file that genuinely must stay CommonJS to `.cjs`. Then work through the errors — they're mechanical: add file extensions to relative imports, replace `__dirname`, swap `require` calls for `import`, and move any conditional require to a dynamic `import()`.
Dynamic `import()` is the escape hatch that makes migration bearable. It works in both systems, returns a promise and lets you keep lazy-loading patterns that `require()` made easy. If you're rusty on the promise side of it, our guide to promises versus async/await covers the syntax choices.
Tooling config files are the last snag. Some tools still expect `.cjs` for their config even in an ESM project, so don't panic when one file refuses to convert — that's normal and it isn't your mistake.
Which Should You Use in 2026?
CommonJS is still fine for existing Node services that aren't bundled and aren't going anywhere. Rewriting a working internal API to change module syntax is effort with no user-visible payoff, and I'd spend that time elsewhere.
The genuinely bad option is a project that's half-migrated and has been for eighteen months. Mixed projects carry the costs of both systems and the benefits of neither, and every new developer loses a morning to it. Pick a direction, write it in the README and finish the job in one sprint rather than letting it drift across quarters, because the drift is what actually costs you.
If you're picking a package manager or setting up a fresh project, our npm vs yarn vs pnpm comparison pairs with this decision — modern setups assume ESM and the defaults reflect that.
Frequently Asked Questions
What does "Cannot use import statement outside a module" mean?
Node parsed your file as CommonJS but found ESM syntax. Add "type": "module" to your package.json, or rename the file to .mjs so Node treats it as an ES module.
Can CommonJS require an ES module now?
Yes, on Node 22.12 and later require(esm) is stable, so CommonJS code can require ESM packages directly. The exception is any module using top-level await, which cannot be loaded synchronously and never will be.
What replaces __dirname in ES modules?
ESM has no __dirname or __filename. Use import.meta.dirname on modern Node, or derive the path from import.meta.url with fileURLToPath if you need to support older versions.
Do I need file extensions in ESM imports?
Yes for relative imports — ./utils.js rather than ./utils. CommonJS guessed the extension for you; ESM follows the spec and refuses to guess, which is why ERR_MODULE_NOT_FOUND shows up so often during migration.
Is CommonJS deprecated?
Not formally, and Node has no plans to remove it. ESM is the standard and the right default for new code, but existing CommonJS services keep working. If you're modernising a project, our [package manager comparison](/blog/npm-vs-yarn-vs-pnpm-which-package-manager-2026) covers the tooling side of the same decision.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools