Mostly about building products, healthcare tech, and lessons learned along the way.
Loading articles
The ESM Mess: JavaScript's Module System Is Still Broken and Here's Why — Clint Johnson
The ESM Mess: JavaScript's Module System Is Still Broken and Here's Why
ES Modules have been the supposed future of JavaScript for nine years. Only 9-27% of the ecosystem has adopted them. What's really going on, and how to survive until the ecosystem commits.
Nine years after the ES Modules spec, only 9% to 27% of npm packages ship ESM-only or dual-mode builds, and the rest are still CommonJS.
On an existing CommonJS codebase, migrate only when an ESM-only package blocks you, and use a dynamic await import() wrapper as the least invasive fix.
Before adding a package to a CJS project, check the exports field in its package.json instead of assuming it is still dual-mode.
Questions people ask
How do I fix ERR_REQUIRE_ESM in a CommonJS project?
Replace the require() call with await import() inside an async function. Everything calling it then has to be async. Do that in enough places and you have started an unplanned, untested partial migration.
Should I migrate my CommonJS Node.js project to ESM?
Not for the sake of it. Migrate when a package you need has gone ESM-only and blocks you. Don't set "type": "module" over scattered untouched CJS files and call that a migration.
How should I set up a new Node.js project with ES Modules?
Set "type": "module" in package.json and use .js extensions, not .mjs. Use a bundler like Vite, esbuild, or Rollup for anything beyond simple scripts. Configure TypeScript with "module": "ESNext" and "moduleResolution": "bundler".
Is a .js file treated as ESM or CommonJS in Node?
It depends on the "type" field in the nearest package.json. .mjs is always ESM and .cjs is always CommonJS. The same .js file can behave differently depending on which directory it lives in.
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:
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. 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
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. So many reasonable maintainers have held off, or ship dual-mode builds with all the complexity that entails.
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. Every package fends for itself, and the interop surface between the two systems absorbs the cost.
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 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 need that has gone ESM-only. When you hit that wall, the least invasive fix is the dynamic import wrapper:
// Beforeconst thing =require('esm-only-package')// After — now everything calling this must be asyncconst{ thing }=awaitimport('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 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 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.