It was a normal patching day. I patched and upgraded my npm dependencies without making code changes, and suddenly, some of my unit tests failed.
Wtf!
My tests failed because Jest encountered an unexpected token
; they failed because Jest cannot handle ESM-only packages out of the box. In fact, Jest is written in CommonJS.
But what does that mean? To do so, we need to understand why CommonJS and ESM exist.
Why Do We Need Module Systems?
In the early days of web development, JavaScript was mainly used to manipulate the Document Object Model (DOM) with libraries like jQuery. However, the introduction of Node.js also led to JavaScript being used for server-side programming. This shift increased the complexity and size of JavaScript codebases. As a result, there arose a need for a structured method to organize and manage JavaScript code. Module systems were introduced to meet this need, enabling developers to divide their code into manageable, reusable units1.
The Emergence of CommonJS
CommonJS was established in 2009, originally named ServerJS2. It was designed for server-side JavaScript, providing conventions for defining modules. Node.js adopted CommonJS as its default module system, making it prevalent among backend JavaScript developers. CommonJS uses require
to import and module.exports
to export modules. All operations in CommonJS are synchronous, meaning each module is loaded individually.
The Rise of ESM (ECMAScript Modules)
In 2015, ECMAScript introduced a new module system called ECMAScript Modules (ESM), primarily targeting client-side development. ESM uses import
and export
statements, and its operations are asynchronous, allowing modules to be loaded in parallel3. Initially, ESM was intended for browsers, whereas CommonJS was designed for servers. It became more and more a standard for the JS ecosystem. Nowadays, modern JavaScript runtimes support both module systems. Browsers began supporting ESM natively in 2017. Even Typescript adapted the ESM syntax, and whenever you learn it, you also learn ESM subconsciously.
CommonJS is here to stay
The truth is that there are many more CommonJS (CJS)- only packages than ESM-only packages4.
However, there is a clear trend. The number of ESM-only or dual module packages is on the rise, while fewer CJS-only packages are being created. This trend underscores the growing preference for ESM and raises the question of how many of the CJS-only packages are actively maintained.
Comparison
An interesting comparison between CommonJS and ESM involves performance benchmarks. Due to its synchronous nature, CommonJS is faster when directly using require and import statements. Let's consider the following example:
// CommonJS -> s3-get-files.cjs
const s3 = require('@aws-sdk/client-s3');
new s3.S3Client({ region: 'eu-central-1' });
// ESM -> s3-get-files.mjs
import { S3Client } from '@aws-sdk/client-s3';
new S3Client({ region: 'eu-central-1' });
I used the aws-sdk S3-Client because it has dual module support. Here we instantiate the client and then execute it with node
:
hyperfine --warmup 10 --style color 'node s3-get-files.cjs' 'node s3-get-files.mjs'
Benchmark 1: node s3-get-files.cjs
Time (mean ± σ): 82.6 ms ± 3.7 ms [User: 78.5 ms, System: 16.7 ms]
Range (min … max): 78.0 ms … 93.6 ms 37 runs
Benchmark 2: node s3-get-files.mjs
Time (mean ± σ): 93.9 ms ± 4.0 ms [User: 98.3 ms, System: 18.1 ms]
Range (min … max): 88.1 ms … 104.8 ms 32 runs
Summary
node s3-get-files.cjs ran
1.14 ± 0.07 times faster than node s3-get-files.mjs
As you can see, the s3-get-files.cjs
and, thus, CommonJS run faster.
I got inspired by Buns Blogpost.
However, when you want to productionize your JS library, you need to bundle it. Otherwise, you will ship all the node_modules
. Is used esbuild
because it is able to bundle to CJS and ESM. Now, let's run the same benchmark with the bundled version.
hyperfine --warmup 10 --style color 'node s3-bundle.cjs' 'node s3-bundle.mjs'
Benchmark 1: node s3-bundle.cjs
Time (mean ± σ): 62.1 ms ± 2.5 ms [User: 53.8 ms, System: 6.7 ms]
Range (min … max): 59.5 ms … 74.5 ms 45 runs
Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet system without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.
Benchmark 2: node s3-bundle.mjs
Time (mean ± σ): 45.3 ms ± 2.2 ms [User: 38.1 ms, System: 5.6 ms]
Range (min … max): 43.0 ms … 59.2 ms 62 runs
Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet system without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.
Summary
node s3-bundle.mjs ran
1.37 ± 0.09 times faster than node s3-bundle.cjs
As you can see, the s3-bundle.mjs
is now faster than the s3-bundle.cjs
. The ESM file is now even faster than the unbundled CommonJS file because it results in smaller file sizes and faster load times due to efficient tree-shaking—a process that removes unused code.
Embrace ESM!
The future of JavaScript modules is undoubtedly leaning towards ESM. This starts when creating a new NodeJS project or even a React project. Every tutorial and article uses the import
—statement, which is thus ESM. Despite many existing CommonJS packages, the trend is shifting as more developers and maintainers adopt ESM for its performance benefits and modern syntax. Another question is also how many of these CJS-only projects are still maintained.
ESM is a standard that works in any runtime, such as NodeJS, Bun, or Deno, and in the browser without running on a server. It is unnecessary to convert via Babel to CommonJS because the browser understands ESM. You can still use Babel to convert to a different ECMAScript version, but you shouldn't convert to CJS.
You should develop in ESM-only because every runtime now and browser newer than 2017 understands ESM.
If your code breaks, you may have legacy issues. Consider using different tooling or packages. For example, you can migrate from Jest to vitest
or from ExpressJS to h3
. The syntax remains the same; the only difference is the import statement.
Key Takeaways:
- Smaller Bundles: ESM produces smaller bundles through tree-shaking, leading to faster load times.
- Universal Support: ESM is supported natively by browsers and JavaScript runtimes (Node.js, Bun, Deno).
- Future-Proof: With ongoing adoption, ESM is positioned as the standard for modern JavaScript modules.
To get started, you can follow this Gist or get an inspirational learning here.
For a better JavaScript Future, Embrace ESM!
More Resources
- https://dev.to/logto/migrate-a-60k-loc-typescript-nodejs-repo-to-esm-and-testing-become-4x-faster-22-4a4k
- https://jakearchibald.com/2017/es-modules-in-browsers/
- https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76af7dfcad3
- https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76af7dfcad3
Top comments (0)