CJS vs ESM

Andrea Giammarchi
11 min readAug 29, 2021
Photo by Mateusz Wacławek on Unsplash

No, this is not yet another rant, neither a duplicate of other posts; this post is rather about important topics not many others discussed to date.

Background

Developers have opinions that could lead to “wars”, where everyone blindly support their idea without listening, or analyzing, all the facts in an objective way, and the NodeJS module’s topic is no exception: frustrating, sometimes infuriating, and dividing pretty much every developer floating around the JS ecosystem, from Front End (bundlers) to Back End (node vs deno vs …).

But as one that has been part of the NodeJS Module Working Group, blindly pushing for “ESM all the things”, I’ve also stepped back at some point, as I couldn’t steer decisions to any valuable outcome, realizing down the road that my attitude, or better, my mindset, wasn’t neither objective nor constructive.

As result, and after so many years around this topic, I came to the conclusion that both modules systems have strength and weakness, in a way or another, so here I’d like to summarize where one solution is better than the other, trying not to lead any reader toward either approach, ’cause once again, both have reasons to exist … now: let’s start with the (imho) most underrated topic, shall we?

Security

We all know, or read about at some point, how insanely insecure could be the npm based ecosystem, and these are just a few points to consider:

  • nowhere, in the npm site, there is a trust metric regarding the author, or the fact such author publishes modules behind 2FA, as opposite as using just a nick name and a public email everyone can find or guess, and a (hopefully not) silly password easy enough to be guessed, or social engineered
  • the package-lock.json measure found its way through the ecosystem, granting stability on one end, increasing security, friction, or updates related issues on the other
  • while everyone is busy blaming the npm foundation itself, nobody is talking about how fragile is CommonJS applied to such, already fragile indeed, approach
  • the meme around how heavy is the node_modules folder, due dependencies, is kinda fighting against the fact most authors are proud of their “zero dependencies” modules, likely duplicating the amount of sharable code, moving away from the “do one thing and one thing only” mentality, hence competing, instead of standardizing, common approaches for common problems. We call this freedom, but at the same time, we complain about too many modules … but that’s not even the real issue, isn’t it?

CommonJS is insecure by design

This might sound like a harsh statement, but the reality is that everyone can manipulate any CommonJS module out of the box, unless such module has been populated, or exported, through frozen objects, which is something, AFAIK, nobody does in practice.

Here an example:

// module.js
'use strict';
exports.a = 'a';
exports.b = 'b';
exports.c = 'c';
// index.js
const cjsModule = require('./module');
cjsModule.a = 'z';
console.log(cjsModule);
// {a: 'z', b: 'b', c: 'c'}

The index.js manipulation over the module.js export will not only affect the index.js code itself, but every other module requiring module.js from that time on.

Now, the example is simple for documentation sake, but the reality is that while everyone is worried about modules versions, publishers, and so on, mostly nobody is likely considering that any module that is completely unrelated to any other module, has the ability, in CommonJS, to try/catch a random require(...) and affect every other code trusting that module, while modifying, patching, or overriding, that module core features.

Please bear in mind, the exact same is true if the export was just one, as in:

module.exports = {a: 'a', b: 'b', c: 'c'};

… meaning, this is not about the way we export, rather about the primitives used in CommonJS to export, which are the same of any other dynamic Object.prototype affected system.

“… but, come on dude, we trust the module we use!”

And that’s good, but likely you don’t trust everything down the dependency graph, as we all have heard about left-pad before, right?

ESM is secure-ish by design

Now, let’s see what would be the counter example in ESM:

// module.js
export let a = 'a';
export let b = 'b';
export let c = 'c';
// index.js
import * as esmModule from './module.js';
esmModule.a = 'z';
console.log(esmModule);
// result:
esmModule.a = 'z';
^
TypeError: Cannot assign to read only property 'a' of object '[object Module]'

Even if exports are live bindings, through the let keyword, nothing outside the module itself can mutate, or change, its own exports, as defined by the Module Environment Records specification.

The conclusion, here, is that ESM is a more secure module system than CJS and last, but not least, ESM is about syntax that cannot be replaced, while in CJS both module and exports can be replaced on the fly within the module itself.

// CJS accidental leak within module.js
exports = {};
// goodbye CJS 👋

Let’s face it though …

Because I care about security, I want to underline that JS itself is not a strictly secure programming language, mostly due its historical, general and dynamic, purpose. If we think about the crypto namespace being not a frozen object, an API where every password hash could go through, before being “secured”, we might as well think “who cares, I know my dependencies”!

If we consider how vast is the JS ecosystem though, that “who cares” should rather be a “WTF, let me be sure all possible measurements to be secure are in place”, and so ESM is a very easy extra win over CJS when it comes to security.

Testing

Now hear me out: everything I’ve talked about CommonJS, as if it’s already “game over” for it, is actually the reason it’s superior to ESM by all means, when it comes to testing environments:

  • code coverage with branched paths through optional cache invalidation
  • analytics tools able to hook themselves around any module in the stack
  • 3rd party behavior metrics/changes on the fly, while code is running

These are just a few things that made myself stick with CJS to date, when it comes to testing and code coverage: CJS is way too easy to deal with, as it’s not syntax we can change, it’s rather utilities we can enrich for any reason.

require('module');
// some stuff
// module cache invalidation
delete require.cache[require.resolve('module')];
// some other stuff
require('module');

Tree shaking

One of the biggest arguments in favor of ESM is the fact bundlers can avoid importing, or using at all, code that is not necessary. The fact I’ve put in bold bundlers should be no surprise, as technically nothing except AOT interpreters/tools can benefit from Tree shaking:

In computing, tree shaking is a dead code elimination technique that is applied when optimizing code written in ECMAScript dialects like JavaScript or TypeScript into a single bundle that is loaded by a web browser.

I have honestly no idea why modern tools are incapable of applying tree shaking to CommonJS too, but what I know is that pure ESM doesn’t benefit from it, because the imported file always need to be downloaded (Web) or read (NodeJS) before any “tree shaking” (dead code elimination) can happen.

Luckily enough, engines can be that smart to understand, during the parsing phase, which code doesn’t need to be consumed or optimized, and which code does, but this whole topic is somehow misleading for at least a couple of reasons:

  • both CJS and ESM allow dynamic modules load, meaning that dynamic code paths could arbitrarily execute parts of a module that were previously not used before, practically defeating any tree shaking advantage
  • both CJS and ESM allow dedicated exports for specific code paths: as example, if lo-dash is exported as single _ default export with all its code in it, is a lo-dash choice, not an ESM issue. If lo-dash exported all its utilities as accessors able to lazy-load distinguished files, the tree shaking would be available out of the box (easily with top level await, but still …)
  • we have a Web (network) vs NodeJS (filesystem) stack to consider, where the latter won’t likely care, or benefit, from AOT tree shaking, while the former might benefit from it, but only if we own the whole stack and never load on demand 3rd party dependencies, as these could re-import a module already imported before but from another end point

I guess my personal conclusion around this topic is that there is no clear winner around tree shaking; and while I use myself this ESM feature to publish libraries producing the least amount of shippable code, when these libraries are downloaded together with others, the possibility all of them are repeatedly including the same code per bundle are pretty high, especially when we all have top 100+ npm modules everyone uses, hence repeated ad nausea, in every single library, utility, or framework.

As conclusion, I believe tree shaking has both pros and cons, but ideally, if a module benefits so much from tree shaking, I think there’s room for improvements there, splitting the module in multiple sub-modules or different exports, and this is something both CJS and ESM can do. On the other hand, if there are no bundlers around, this argument is one of the most irrelevant one could talk about, maybe to convince peers that ESM is better.

Deno itself cannot directly really benefit from tree shaking, so whoever talks about this, must be some Front End developer with a not-so-objective mentality, or not a whole clear idea of what’s really tree shaking about, and how it works in practice.

Live bindings + default

I personally avoid live bindings ESM feature as much as I can, simply because I cannot automatically translate with ease this feature to CJS modules, so that I can publish dual modules on npm, and without worrying about inconsistent behavior across users of such modules.

However, live bindings could also be defined as read-only accessors on CJS, after some “tooling done well” applied to the ESM source, but that’s not the same for the default export, which has been solved by bundlers, but not necessarily in a good way, actually creating yet another shenanigan in the module system, where CJS, as target, needs to check if default is present in the module and return that instead, when that’s the case.

// module.js
export let a = 'a';
export let b = 'b';
export let c = 'c';
export default a; // ESM only!
// index.js
import * as module from './module.js';
import a from './module.js';
import {a as $a, b, c} from './module.js';

And once again, even for the default export idea we have a lot of developers complaining about it, yet that was another thing inspired by a pretty common CJS pattern:

module.exports = {any: 'value'};

This pattern is effectively discarding the exports reference, overriding its meaning, increasing garbage collection usage by trashing it, and so on … and people still felt CJS here was better, but at the end of the day, the explicit syntax enabled by ESM feels just about right, or superior.

Mime type

The biggest difference between CJS and ESM, is that the former, filesystem constrained, understands some file content based on its extension, while the ECMAScript standard module, aka ESM, doesn’t care much about file extensions, as long as the server provides the mime kind of such file through headers.

This tiny, yet huge, difference, is what makes ESM a rather more HTTP oriented module system, or better, a module system where the response header of any file has to adhere with the provided file content, as opposite of guessing it through some, arguably meaningless on the Web, as in the filesystem, extension name.

To explain this further, .js file extension means, by default, a CommonJS module system on NodeJS world, while even a .gif as end point file name on the Web, could be actively served as application/javascript.

The kinda “conservative” take on NodeJS side, is that its foundation was already based on a different module system, so that making the ESM the default, would be problematic. The reality is that everyone expects ESM to “just work” everywhere, and there is already a standard effort to make the content-type override possible, by a new syntax:

import data from './end-point-data' as 'json';

Because this is the way forward, every argument about CJS being better at guessing, via file extensions, any file intent, is kinda defeated by the fact that code will run better off through this new proposal, so that the developer data or request intent, is explicit, rather than implicit, hence usually better.

Yet, the default module system in NodeJS is still CommonJS, and there is quite some friction in there, or no sign, they’ll welcome ESM as default instead, any time soon. Reasons are kinda good enough … I mean … the whole ecosystem runs out of CJS modules, but they’re not really looking ahead of their nose, if they think CommonJS will stay forever … and they know it too, so that I’ve never really got the whole bike shading around this topic, to me obvious, to date: ECMAScript standard is the way forward!

If noting else should be considered, any file with an .exe extension, might be anything else other than an executable, while on both Linux and Mac worlds, any executable file can be defined by its first line:

#!/usr/bin/env bash

That’s how pointless any file name, or extension, could be, and yet the whole CJS module system trusts this is the way to go, so that an .mjs file is supposed to be an explicit intent over a .cjs named file extension.

But fear not, any time you want to hook into standards, a {"type": "module"} entry in your package.json file, per parent/main folder, is all we need to be sure everything runs as ESM.

On the other hand, because CJS considers anything unknown a valid JS file, using .cjs to migrate from the old module system to the more secure new one, is all it takes, so: consider naming your files .cjs when it comes to legacy NodeJS code. It works pretty well, and it guarantees the intent is to have a CJS module system from that import time on, as ESM is backward compatible, but not strictly vice-versa.

Transpiling

The last topic I’d like to discuss is transpilation:

  • what does it mean?
  • who benefits from it?

The answer to both questions come, mostly, from bundlers:

  • does my bundler understand ESM standard?
  • is the CJS reliable once transpiled, or vice-versa?

Rollup does a great job there, and it’s my primary choice, but it’s not alone. Many other transpilers will just work with a mixture of ESM and CJS modules, yet there are tons of developers complaining about one, or the other, format, specially when it comes to pure ESM, or pure CJS, use cases.

I’ve already talked about dual modules in here, so it’s up to you decide what works best for your use case, but if it’s ESM only, remember there are various tools to convert that to CJS, when possible, and vice-versa … so that nothing should stop us shipping the format we prefer.

Conclusions

In this post I hope I’ve hopefully talked about less boring topics around this debate, and brought some clue about why something is better at something, and what’s not really better at all in some other case.

But something I’d love to stress again, is that CJS vs ESM is not a war, rather a topic to talk even more about, once constraints and respective features are clear, as opposite of just taking a side out of habits, or effort needed, to move on (to ESM). I’d be happy to hear from your experience in this regard too, so please feel free to add your comments, and also please, don’t ask me to put this article in any subscription: Thank You!

--

--

Andrea Giammarchi

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.