CommonJS vs. ESM

CommonJS is an established way of building modules in JavaScript. ESM on the other hand is a newer standard way of writing modules. Both have their ups and downs and I want to discuss them here.

Released: 21. Feb 2023
Tags:
Share this on: Twitter

Like always I mention the comments by some specific individuals in this post and once again I want to make clear that I'm only discussing these specific comments by these individuals. I only critizise (positively or negatively) their specific comments and not them as an individual or even their other work.

Why do I post an opinion?

As a developer that uses JS/TS as one of my main languages, I have an opinion on the different ways to publish modules. Since these discussions are more nuanced than "use this" and there are also some very strong opinions in this field, I strained away from this discussion until now, but here we are...

This week someone opened a PR on an open source project to add an ESM version to the published version of the project. As a response a TC39 member chimed in and outright told everyone that publishing ESM packages is bad (related github issue). I disagree and since a post on Mastodon is lost fairly quickly and doesn't provide enough room for nuance, I'm writing here instead.

What are CommonJS and ESM?

To bring us all up to the same starting point, let me reiterate again what CommonJS and ESM style modules are:

CommonJS

CommonJS (or CJS) modules are the "older" type of writing modules. It became really big when NodeJS and npm launched, since CommonJS modules were the only supported style of modules in NodeJS up until version 12.

These modules use this syntax:

js
const { someImport } = require("some-module"); module.exports = { someExport: {}, };

As you can see, a global, synchronous require() function is added to import other modules and a module scope module variable is used to hold module informations like the exports. You then export something from your module by overwriting the exports property.

You can mark your file as a CommonJS module by either naming it with the .cjs extension, or by using the type: "commonjs" in your package.json.

ESM

EcmaScript Modules (or ESM) got standardized later and are the only natively supported module style in browsers and all modern standard-compliant runtimes have support for ESM out of the box. As the name suggests, it is the (EcmaScript / JavaScript) standard way of writing modules.

These modules use this syntax:

js
import { someImport } from "some-module"; const { someOtherImport } = await import("some-other-module"); export const someExport = {};

This means, that ESM is a syntax addition to JS and allows to easily import and export static members.
This also makes loading simpler for browsers, because imports and exports are known before the module even starts executing. Also dynamic imports via await import() are now asynchronous.

A closer look at CommonJS

Benefits

  • already an established ecosystem
  • allow for dependency tree analysis from within

Drawbacks

  • purely synchronous
  • non-standard
  • require bundling for non-cjs runtimes like browsers

A closer look at ESM

Benefits

  • standard way of doing modules
  • supported in browsers and server side runtimes like deno and node
  • allow asynchronous loading
  • exports without globals

Drawbacks

  • watching / analyzing the dependency tree at runtime is not (always) supported

So what is my opinion

I think that for new projects that are not exclusively for NodeJS, it's better to choose ESM over CommonJS.

ESM is the standard way of doing modules and while it might sound hard, I think of CommonJS as a legacy module system that was good when there was no other viable option. If your package requires things like a dependency graph, you can still go the CommonJS route or do, what others mention for CommonJS -> ESM modules: Use a transpiler to convert your ESM to CommonJS.

By the way: If you're using TypeScript, the import syntax there is really close (in nearly all cases identical) to ESM.

If your project only focuses on NodeJS (or another runtime that focuses on CJS), you can still write your package as ESM and release it as ESM and CommonJS.

But bundling/transpilation is the job of the App and not the package

Even in the linked discussion this came up as:

Why is a bundle needed? The best practice remains for an app to bundle, and never for a package to do so.

I think that deviding between bundling and transpilation is important. When you transpile CommonJS to ESM or the other way around, you're building a bundle, but that bundle is not meant to be shipped like that in most cases. Instead you just do that for transpilation reasons to aid compatibility. This means, that once everyone switched over to ESM, you can just remove your transpilation step again.

ESM support in CommonJS

CommonJS does not support importing ESM via the require() method. This is mainly because of its synchronous nature. But you can always just use await import() instead. Earlier this was problematic, because NodeJS didn't support top-level await, but since NodeJS 14.8 unflagged this feature all currently maintained versions of NodeJS support top-level await.

Being a pull factor

When you build a package ESM first, you can aid the ecosystem to move to ESM. In my opinion staying on an CommonJS-first point is holding back the ecosystem. This way we will have two "competing" module systems forever.

Should NodeJS remove support for CommonJS?

No. CommonJS has its place in the NodeJS and NPM ecosystem for now and for legacy support reasons I do not think that removing support would be a good idea. Nevertheless I still think that most packages should go the ESM route, so that future runtimes don't have to hack around for support like Deno does.

Buildless Development

While "going buildless" is often a dream and only realistic for development environments, this doesn't mean that there aren't benefits to this!

Especially in small, short-lived projects it can be really beneficial to not require a build process.
Also I did some teaching in the past and requiring a build process to setup is really hindering for many new devs. When you set up a build system with a student, you have two options:

  1. Make the Big-WebPack-Handwave and don't explain anything
  2. Spend a lot of time explaining it which often requires concept they could learn better in another way.

Both options are often more confusing than helpful and make web development less aproachable.