loke.dev
Header image for 3 Reasons Node.js 23’s Native `require(esm)` Support Is the End of the CJS/ESM War

3 Reasons Node.js 23’s Native `require(esm)` Support Is the End of the CJS/ESM War

The divide between module systems in Node.js has finally collapsed, allowing you to delete your complex build scripts and simplify your dependency graph for good.

· 4 min read

For years, running const chalk = require('chalk') in a standard CommonJS project would result in the most dreaded error in the Node.js ecosystem: ERR_REQUIRE_ESM. This single error message sparked a multi-year "war" that forced maintainers to dual-publish packages, broke build pipelines, and made moving from CommonJS (CJS) to ECMAScript Modules (ESM) feel like trying to swap a car's engine while driving 70mph down the highway.

With Node.js 23, the require(esm) flag is finally enabled by default. If the module you're requiring is synchronous (meaning it doesn't use top-level await), you can just require it. No flags, no hacks, no dynamic import() wrappers.

Here is why this change effectively ends the CJS/ESM friction for good.

1. The "Pure ESM" Hostage Situation is Over

We’ve all been there. You're working on a legacy CJS codebase—maybe a large Express app or a suite of Lambda functions—and you want to use a popular library like node-fetch or chalk. You install it, run your code, and it explodes because the maintainer (rightfully) decided to go "Pure ESM."

Before Node 23, your only options were to convert your entire project to ESM (a week of work you didn't plan for) or use a clunky async wrapper:

// The "I hate my life" way of using ESM in CJS (Pre-Node 23)
async function doSomething() {
  const { default: chalk } = await import('chalk');
  console.log(chalk.blue('Finally working... but it's async now.'));
}

This was a nightmare because it forced async execution on code that should have been synchronous. In Node 23, that friction vanishes. If the ESM module doesn't use top-level await, you can treat it like any other library:

// The Node 23 way
const chalk = require('chalk'); 

console.log(chalk.green('It just works. No promises required.'));

2. Your package.json Can Finally Go on a Diet

To support both worlds, library authors have been forced to use the "Dual Package" pattern. This involves generating two versions of the codebase (one .cjs, one .mjs), doubling the bundle size, and creating a convoluted exports map in package.json.

It looks something like this mess:

{
  "name": "my-library",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

This wasn't just annoying; it was dangerous. If a user happened to load both the CJS and ESM versions in the same process, they’d end up with two separate instances of your library. If your library held any internal state (like a singleton or a cache), everything would break.

Now, maintainers can ship just ESM. Since CJS users can now require() that ESM code, the need for a secondary CJS build target evaporates. We can finally stop building for two different module systems and just write standard JavaScript.

3. Tooling Complexity Simply Disappears

The divide between CJS and ESM wasn't just a syntax issue; it was a tooling disaster. We had to configure tsup, rollup, or esbuild to handle complex transformations just so our code could run in different environments.

I recently looked at a project where the tsconfig.json and esbuild config were longer than the actual logic, mostly to ensure that moduleResolution played nice with both require and import.

When you can require(esm), the "glue code" dies. You don't need a complex build step to transform export to module.exports just for the sake of compatibility. You can write modern ESM code, and your users—regardless of their legacy setup—can consume it without thinking.

The One Catch: Top-Level Await

It isn't a total "get out of jail free" card. Node.js still can't magically turn an asynchronous operation into a synchronous one. If an ESM module uses top-level await, require() will still throw an error.

// dependency.mjs
const data = await fetch('https://api.example.com'); // Top-level await!
export default data;

// app.cjs
const data = require('./dependency.mjs'); 
// ^ This will still throw! 
// Error: [ERR_REQUIRE_ASYNC_MODULE]: require() of ESM module ... is not supported

In these cases, you still have to use import(). But honestly? Most utility libraries—the ones that caused the most headaches—don't use top-level await. They are purely functional or object-oriented structures that are perfectly safe to load synchronously.

Why this matters for your next project

The CJS/ESM split was the single biggest point of confusion for new Node.js developers for the last five years. By bridging the gap, Node.js 23 allows us to move toward a future where "ESM by default" is the standard, without leaving the millions of existing CJS projects in the dust.

If you're starting a new project today, use ESM. But if you're maintaining an old one, stop stressing about that one dependency that updated to ESM-only. Just upgrade to Node 23, keep your require calls, and get back to writing actual features instead of fighting your runtime.