The ESM Mess: JavaScript's Module System Is Still Broken and Here's Why

The require() vs import collision is still causing real damage
Last week I added a dependency to a Node.js service we're building. Nothing fancy — a utility library I've used before. The project was CommonJS because it started years ago and nobody had time to migrate. The package I pulled in had just dropped its CJS build.
Build broke. require() of ES Module — that error you've seen a hundred times and still have to re-Google every time. I spent two hours chasing it down, patching around it with a dynamic import() inside an async wrapper, and quietly seething.
I've been writing JavaScript since before npm existed. I've seen a lot of things labeled "the future" that never fully arrived. ESM is the current reigning champion.
The Timeline Is Embarrassing
ES Modules were finalized in the spec in 2015. Node.js started experimental support in 2017. "Stable" support landed in Node 12 in 2019. It's now 2026. Nine years since the spec. Seven years since Node.js began support.
Current adoption? Depending on who's measuring: somewhere between 9% and 27% of npm packages have shipped ESM-only or dual-mode builds. The rest are still CommonJS.
Let that sit for a second. After nearly a decade of "ESM is the future" messaging — blog posts, conference talks, framework migrations, tool updates — the overwhelming majority of the JavaScript ecosystem is still on CommonJS.
This isn't a community being stubborn. It's a community that ran into real friction and stopped.
What the Friction Actually Looks Like
If you haven't hit it recently, here's how it goes in practice.
Scenario 1: Your project is CommonJS, you add an ESM-only package
You add the package. You require() it as you always have. Node throws:
Error [ERR_REQUIRE_ESM]: require() of ES Module /node_modules/some-package/index.js not supported.
The "fix" is to change your call site to use await import(). Which means it has to be inside an async function. Which means you restructure call chains. Which means you potentially break other things. If you do this in enough places, you've effectively started an unplanned, untested partial migration.
Scenario 2: Your project is ESM, you need a CommonJS package
Usually this one's fine — ESM can import CJS without too much trouble. But not always. Some CJS packages use module.exports patterns that trip up ESM's named imports. You end up writing:
import pkg from 'some-cjs-package'
const { specificThing } = pkg
instead of
import { specificThing } from 'some-cjs-package'
Which sounds minor until it breaks tree-shaking, confuses your types, or just fails outright with certain bundler configs.
Scenario 3: You're writing a library and want to support both
Now you're maintaining dual builds. Your package.json grows a exports map:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
You configure your bundler to emit both. You test both. You realize older versions of Node and older bundlers don't respect the exports field at all, so you also keep main and module fields around for compatibility. Your build config is now a dissertation.
Scenario 4: The .mjs / .cjs / .js extension ambiguity
Node resolves module type based on file extension and the nearest package.json's "type" field. .mjs is always ESM. .cjs is always CommonJS. .js depends on whether "type": "module" is set. This means the same .js file can behave differently depending on which directory it lives in. That is not a feature. That is a source of mysterious breakage.
Why This Stagnation Persists
Here's the honest answer: the ecosystem has no forcing function.
Publishing ESM-only is a breaking change for every CommonJS consumer. Package maintainers — most of whom are volunteers — absorb the support burden when they make that move. They get angry GitHub issues from people who haven't migrated. They get forks. They get charming comments. So many reasonable maintainers have held off, or ship dual-mode builds with all the complexity that entails.
Meanwhile, tools have varying levels of support. Bundlers handle this better than raw Node.js. Jest had years of poor ESM support. Some test runners still have edge cases. TypeScript's moduleResolution settings are a choose-your-own-adventure of compatibility footguns (node, node16, bundler — each with different behaviors for the same import statement).
The lack of registry-level coordination is the core issue. npm could say: in 12 months, all new major versions of packages must declare their module format explicitly. There could be tooling that flags ambiguous packages, surfaces compatibility information in the registry UI, or enforces exports maps. None of that exists. It's every package for itself, and the interop surface between the two systems absorbs the cost.
The point worth making clearly: the npm registry and Node.js could do far more to drive adoption. They sit at the center of this ecosystem. They have the leverage. Using it is a choice they haven't made.
What To Actually Do Today
I'm not going to tell you to rewrite everything to ESM. That's fine advice in the abstract and useless advice when you have a service that needs to ship.
Here's what I actually do:
If you're starting a new project, use ESM from the start. Set "type": "module" in package.json. Use .js extensions (not .mjs) and let the type field do the work. Use a bundler (Vite, esbuild, Rollup) — don't rely on raw Node resolution for anything beyond simple scripts. Configure TypeScript with "module": "ESNext" and "moduleResolution": "bundler".
If you're on an existing CommonJS codebase, don't migrate for the sake of it. Migrate if you're blocked on a package you actually need that has gone ESM-only. When you do hit that wall, the least invasive fix is the dynamic import wrapper:
// Before
const thing = require('esm-only-package')
// After — now everything calling this must be async
const { thing } = await import('esm-only-package')
It's ugly. Do it anyway rather than blowing up your project timeline.
If you're writing a library, ship dual-mode until you can realistically drop CJS. Use a tool like tsup or unbuild that makes this manageable. Pin the engines field in your package.json so users know what Node versions you actually test against.
If you're debugging interop errors, the --experimental-vm-modules flag in Jest, the transform config in Vitest, and the esModuleInterop TypeScript flag are the three places where most mystery failures live. Know them.
❌ Don't assume a package is dual-mode just because it used to be. Check the exports field in its package.json before adding it to a CJS project.
❌ Don't use require() on a path ending in .mjs. That's always going to fail.
❌ Don't set "type": "module" in a project that has untouched CJS files scattered through it and call that a migration. You've created a minefield.
The Ask
The ecosystem needs to actually commit.
If you maintain a package: add an exports map. Be explicit about what you ship. If you're going ESM-only, cut a major version and document it clearly. Don't make your users discover it when their build breaks on a Friday.
If you work at a company with internal npm packages: use this moment to standardize. Pick ESM for new packages. Plan the migration path for existing ones. Don't let it drift for another five years.
And if anyone at npm or the Node.js foundation is reading this: the tooling and the registry have more leverage here than any number of blog posts. A compatibility layer in the registry UI, stricter exports enforcement, clear migration guides surfaced at the point of npm install — any of these would move the needle faster than the current approach of hoping developers figure it out themselves.
Nine years is long enough. Either ESM is the future, or it isn't. Right now it's occupying a limbo where it's neither the present nor officially the past — just a permanent source of friction for everyone trying to get work done.
We can do better than this.
