Skip to content

The end of the CJS - ESM hell in Node.js

Published:

TL;DR: It’s finally possible to load ESM modules from CJS using require()!

For years, a major pain point has existed in the Node.js ecosystem: the incompatibility between CommonJS (CJS) and ECMAScript Modules (ESM). This means CJS projects can not easily leverage the growing number of ESM-only libraries.

How the nightmare began

JavaScript started as a way to program the browser, but eventually, it also made its way into the server. Node.js rose as the winner among server-side JS runtimes. While doing so, it adopted CommonJS (CJS) as its module system - and with it, the divide between browser and server-side JS became wider.

Back then, there was no specification for module loading in JavaScript. In the browser, you would just reference a bunch of .js files (or even inline!) via <script> tags, more often than not sharing the global scope.

But when it comes to the server side, there is no HTML page to load .js files. You just have the JS files themselves to work with. So having a way to import JS files into each other was a foundational need, and the CommonJS module system was made to address it.

CommonJS was designed to be synchronous. This means that modules are loaded and executed as soon as they are imported, one by one, blocking the main thread. For server-side this was assumed to be fine, or even desirable, as files are available directly in the file system and load in milliseconds, not needing network requests like when including JS files in the browser. And after all, it’s the way many other server-side languages work, like Python and Ruby.

What seemed like a reasonable approach at the time, would become the source of many problems down the line.

ESM enters the ring

In 2015, the TC39 committee of Ecma produced the ES6 specification. With it, there was finally a standard way of importing JS files, built right into the language: the ESM module system.

ESM is designed to work everywhere, including the browser, so it’s asynchronous: whether you import a module through the network or from the local file system, it works the same. This is great as it means less difference between browser and server code, taking JS a step closer to its “universal language” promise. But it put it at odds with CommonJS. And although Node.js added support to ESM, most of the existing Node.js ecosystem still uses CommonJS.

There are many others that have explained in detail the multiple differences and problems the ESM - CJS dichotomy has created since then, so I will just address the main one that has been holding back Node.js for way too long.

ESM modules can not be imported into CJS modules

While you can import CJS modules in ESM just fine, the opposite is not possible:

esmModule.mjs
// this works
import cjsModule from "./cjsModule.cjs";
cjsModule.cjs
// this doesn't work! ERR_REQUIRE_ESM
const esmModule = require("./esmModule.mjs");

The problem is that CommonJS expects the module import to be synchronous, something that ESM doesn’t do.

This means that if your project is CommonJS, you can not make use of ESM-only third-party packages without resorting to non-ideal solutions like dynamic imports, which ends up being a pain as CJS does not support top-level awaits. If you go down that path, you might see your project slowly get bundled into a nest of IIFEs1 just for a simple import, kind of like a virus slowly overtaking your codebase.

This is a huge problem as more and more libraries are becoming ESM-only (and rightly so). If you are stuck with CJS, like if for example you are using the NestJS framework which is CommonJS, you have to go through hoops to be able to use any ESM library and accept bad ergonomics.

This bit me hard when trying to use AdminJS with NestJS. We eventually made it work, but in a painful way full of dynamic imports and .mjs files in a TypeScript project 😢:

Dynamic imports pain

This makes me cry...

The end of the nightmare?

It seems finally the end of this plight is at hand thanks to PR 51977!

It introduces the --experimental-require-module flag, which when enabled, allows for importing ESM modules from CommonJS using require().

This (now legendary) PR was finally merged into the Node.js main branch a few days ago!

There are some caveats, as the ESM module can not have a top-level await, but fortunately, this is not a common case for packages in NPM meant to be imported by third parties. Most ESM modules will be finally importable in CJS projects without the dreaded ERR_REQUIRE_ESM error!

The change will be part of the next major release of Node.js, v22, expected next month (update: it’s available now in v22), and it will most likely be backported to v20 as well (update: this seems to not have happened so far…).

To use it right now, you can use a nightly since 20240319.

And if you are interested in the backstory of this PR, and why it took so long to solve this problem, here is a blog post from the author - definitely worth a read!

In an ideal world, we’d all be using ESM and not worrying about this stuff. But reality is, lots of us are stuck with the old CommonJS, making the switch to ESM a real headache.

This update isn’t a cure-all, but it sure makes life easier. I’m hoping the flag makes it out of experimental and becomes enabled by default soon, so we can finally leave ERR_REQUIRE_ESM behind as just a spooky campfire story.


Footnotes

  1. Immediately Invoked Function Expressions. Functions that get called right after they are defined, like (() => console.log("I'm an IIFE!"))();