The game between ESM and CJS

#ESM# CJS# Javascript

A battle has been going on for a few years now, and in the ring are not Elon or Mark, but two Javascript standards: ESM and CJS.


Note: there is a French version of this article here.

The conflict between these two standards regularly leads to fierce debate. I've tried to get a clearer idea of what's going on.

At stake are ESM (for "ECMAScript Modules") and CJS (for "CommonJs"), two format standards for splitting code. Which brings us to our first question:

1. Why does Javascript have two standards for the same functionality?

There are several articles on the history of this coexistence, but after a careful reading of them and, above all, a fascinating dive into the Google group at the founding of CommonJS, I'd sum things up as follows:

After 12 years or so of existence, Javascript is gaining in importance, and it needs new resources than those already in place to manage code, not least because the need to use Javascript for server-side applications is growing. This was the call made by Kevin Dangoor on January 29, 2009 in "What Server Side Javascript Needs?" and led to the setting up of a Google group to create a modularization standard: CommonJs.

Le + de Dre Drey

CommonJS thus enables you to modularize your code - aka split it into parts that can be imported and exported to and from other files. Despite the initial enthusiasm, this standard was developed primarily to facilitate the use of Javascript as a server language. Ryan Dahl was also a member of the CommonJS working group, and took the opportunity to present the first version of Nodejs.

But CJS has some fundamental disadvantages that don't make its use optimal on the browser side, as summarized by Andy Jiyang:

  • module loading is synchronous. Each module is loaded and executed one by one, in the order that they are required.
  • difficult to tree-shake, which can remove unused modules and minimize bundle size.
  • not browser native. You need bundlers and transpilers to make all of this code work client-side. With CommonJS, you are stuck with either big build steps or writing separate code for client and server."

Le + de Dre Drey

As a result, Javascript's standardization authority, ECMAScript, is looking into the matter. ECMAScript, founded in 1997 and managed by ECMA (the European association for standardizing information and communication systems) regularly publishes new guidelines, and in 2015, ECMAScript 6 (ES6) introduced a new standard for modularizing JS code, ESM. This standard is better suited to the client side than CommonJS, and addresses some of the problems posed by CJS (e.g. asynchronous processing, which is quite practical).

If there's only one thing to remember from this history, it's that since 2015 two major standards have dominated when it comes to modularizing your Javascript code: CommonJS and ESM. Both are designed to meet the same need - modularizing code - but :

  • they have different syntax ;
  • they operate differently from a technical point of view, each with its own technical advantages and disadvantages (in terms of security, testing, bindings, etc.);
  • they were conceived in different contexts (the need to use Javascript on the server side vs. on the client side);
  • one is supported by the Javascript standardization institution (ECMAScript), but the other is the backbone of the most widely used server environment in JS: Node ;

So you might say that the existence of two standards, strange as it is, isn't necessarily a problem. The two could coexist, each with its preferred environment (browser on one side, server on the other). Which brings us to our second question: what's the problem?

2. What's the problem with having two standards?

The core of the problem is that this cohabitation is difficult, because interoperability (i.e. the ability to work together) between ESM and CommonJs is very, very weak.

The problems can be seen from here, and they can be divided into two parts:

  1. on the one hand, it's a problem for libraries' authors: if these two standards don't work well together, it means that JS libraries have to support every possible configuration. This can quickly become a nightmare:
  1. on the other hand, it's a problem for the devs who are going to use these libraries: if all the code is in a standard, it doesn't matter which one, there's no problem. But it's going to be a nightmare as soon as you have to use a library that's only based on CJS, even though the code is in ESM. Why is this?

2.1. A different syntax: Require vs Import

While CJS and ESM both have the same import/export types (default and named), the syntaxes are different. Here's an example with two files, maths.js and index.js.

In CSJ, import/export is recognized by the use of the module and require keywords:

//in my math.js file, I export my function with module.exports :
module.exports = function sum(x, y) {
  return x + y;
};

// then I import it in my index.js file with require() :
const somme = require("./math.js");

En ESM, import/export is recognized by the use of the import keyword.

//in my math.js file, I export my function with export.default:
export default function sum(x, y) {
  return x + y;
}

//then I import it in my index.js file with import:
import somme from "./maths.js";

2.2 A global standard on one side, not on the other side

Another big difference: using ESM doesn't just change the way modules are imported and exported, it's a whole standard that encompasses other functionalities too.

So, when you use the ESM standard, it's not just that you prefer to write import rather than require; for example, ESM doesn't forward contexts, which implies, among other things, a different this return (undefined ), ESM has a StrictMode whereas CommonJS doesn't, and so on.

2.3. Synchronous vs. asynchronous

Finally, as we've already said, CJS is synchronous and ESM is asynchronous! What makes the two very non-interoperable is the fact that with ESM you can use the await keyword at top level (outside an async function, for example), whereas CJS can't read this script! So CJS can't transpile an ESM script that uses an await at top level.

2.4. A different configuration :

It's good to know that, when you launch a project with an npm init, a "default" CJS configuration is launched (all .js files are considered to be in cjs). You would therefore need to use CJS syntax in the project. If you wish to use ESM in a default CJS project, you must indicate this by adding the .mjs extension to the file concerned.

If you want to configure the entire project in ESM, you need to modify the configuration in the package.json by adding "type": "module" which indicates that the .js files are in ESM by default. At this point, to use CJS, you need to indicate this by using the .cjs extension in the files concerned.

Let's imagine we have a piece of code that uses the ESM standard and another piece that uses CJS. A whole series of rules must be applied:

  • You won't be able to import functions from the cjs file into the esm by writing require() BUT WORSE, you won't be able to use require() to import into the cjs file (even though it's its own standard) a function from the file exported to ESM.
  • In the same way, CJS can't use static imports BUT it can work with import XX from 'colors'.
  • ... and lots of other rules that I don't understand, even after reading them three times.

I tried to understand what was doable or not, including Dan Falbulich's explanation, but even after several rereads, what I still understand is... absolutely nothing.

Great, now that we know what CJS and ESM are, how they came to live together and the problems they raise, one last question remains:

3. Last question (the final boss): so what do we do?

Once you've torn your hair out, as I did, when I ended up completely blocked by the fact that two libraries on a project would absolute not operate together, what do you actually do about that :

this module can only be referenced with ecmascript

There's much debate in the JS community about the advantages and disadvantages of each standard and the best practices to adopt. Andrea Giammarchi, who was a member of the NodeJs module working group, gives the pros and cons of each position in a rather nuanced article (which is rare on this subject).

Unfortunately, in concrete terms, there are no miracle solutions, even if library authors can get by by applying Andrea Giammarchi's few rules:

  • Consider shipping only ESM
  • If you're not shipping only ESM, ship CJS only
  • Test that your CJS works in ESM
  • If needed, add a thin ESM wrapper for your CJS named exports
  • Add an exports map to your package.json.

For devs integrating these libraries into their projects, there are several schools of thought:

  • The "we're doing ESM, because they're going to drop CJS" school: in fact, since Node16, ESM is finally supported by Nodejs. As a result, many devs are adopting the technique of coding exclusively in ESM, and the question "Will CommonJS be deprecated?" is a question that rears its ugly head on a regular basis, with many hoping that this will soon be the case.
  • The school of "Typescript solves this problem anyway".
  • The "we use both" school: while ESM does indeed solve some of the problems posed by CJS, Andrea Giammarchi, who is an ESM advocate in the Node Modules Working Group, points to certain advantages of CJS (for example, for test environments). Moreover, this last argument is reinforced by the fact that the new bundlers take both standards into account, and resolve incompatibilities to some extent if they are properly configured.

Further reading

I strongly recommend that you dive into the discussions that gave rise to CommonJS and NodeJS to understand the choices that were made.

But also check out the debates on various communities and platforms:

And a few well-summarized articles, to go into more technical detail: