For several years now, the Node.js ecosystem has been steadily shifting towards ES modules (ESM) as the preferred method for sharing and utilizing JavaScript code.
While CommonJS has served the community well, ESM are rapidly gaining traction as they offer a standardized approach for creating JavaScript modules across all supported runtimes.
This article aims to shed light on the core differences between CommonJS and ES modules, discuss the benefits of adopting ES modules, and offer practical guidance for a smooth transition.
Let's dive in!
Understanding CommonJS Modules
In its early days, JavaScript lacked a structured way to organize and control the scope of code across files, so everything was in the global scope. As developers began building complex JavaScript projects outside of the browser, the need for a module system became evident.
CommonJS emerged in 2009 as an early solution to tackle the challenges of organizing and reusing JavaScript code in non-browser contexts. It introduced a module system that allowed developers to split their code into separate files (modules) and provide a public API for other modules.
The Node.js project quickly adopted CommonJS as its default module system. That decision proved pivotal in making JavaScript a viable language for large-scale application development on the server.
The CommonJS module system is built on two fundamental concepts: defining modules with module.exports
and importing modules with require()
. Let's look at using module.exports
first.
1. Module Definition with module.exports
In CommonJS, each file is treated as a separate module with a special object
called module.exports
, which acts as the gateway for encapsulating and sharing
functionality with other files in a project.
By default, none of the variables, functions, and classes defined in a module
are accessible to other files unless you explicitly assign them to
modules.exports
:
2. Importing Modules with require()
The require()
function is used to access the exported functionality from
other modules. It resolves a module using the provided argument, executes the
code within it, and returns the module.exports
object, giving you access to
the module's public interface:
Limitations of CommonJS
Despite its simplicity and flexibility, CommonJS has inherent drawbacks that have become more apparent as JavaScript has evolved:
- Not native to browsers: CommonJS modules are not natively supported by web browsers. You'll need to use build tools to bundle your code and resolve module dependencies if you're writing Universal (Isomorphic) JavaScript.
- Synchronous loading: Modules are loaded and executed one after the other in a blocking manner, which limits browser adoption.
- Limited static analysis: The dynamic nature of
require()
makes it less efficient for tools to perform static analysis and optimizations like tree-shaking to remove unused code.
While CommonJS played a crucial role in the early development of Node.js, the rise of ES modules in 2015 offered a more standardized and versatile approach to modularity. ESM gained stable support in Node.js 14 and is now the preferred choice for modern JavaScript development.
Let's delve deeper into the details of ES modules to explore why they've become the new standard.
Understanding ES Modules (ESM)
ES modules, or ECMAScript modules, are the official and standardized way to work with modules in JavaScript. They address the limitations of earlier module systems and offer a more streamlined, performant, and future-proof approach to organizing and sharing code.
Some of the key advantages of ES modules over CommonJS include:
- Browser compatibility: ES modules are supported directly by modern web browsers without the need for additional tools or bundling.
- Asynchronous loading: ESM modules are loaded asynchronously, meaning they can be fetched and processed in parallel without blocking the main thread.
- Static analysis: The import and export keywords provide a clear structure that allows tools to analyze your code statically. This enables optimizations like tree-shaking, where unused code can be eliminated, reducing the size of your final bundles.
- Modern syntax: The syntax of ESM is more aligned with modern JavaScript practices. It is considered cleaner and more intuitive than the CommonJS syntax.
ES Modules are built around two key concepts: defining modules with export
and consuming them with import
. Let's take a quick look at each.
1. Module Definition with export
Within an ES module, you can use the export
keyword to designate which
functions, classes, or variables are made available for use in other modules.
You can have multiple named exports, a single default export, or both,
providing flexibility in how you expose your module's functionality.
2. Module Consumption with import
In a different file, you can use import
to selectively import only the
specific things you need:
ES modules have become the preferred way to write modular JavaScript, with Node.js actively embracing the standard (although CommonJS remains fully supported).
CommonJS vs. ES Modules: Comparing Features
To truly understand the distinctions between CommonJS and ES modules, let's examine their key features side by side:
Feature | CommonJS | ES modules |
---|---|---|
Module definition | Primarily single default export (module.exports ) | Multiple named exports and/or a single default export |
File extensions | .js (default), .cjs (explicit) | .mjs (traditional), .js (with "type": "module" in package.json ) |
Importing | const moduleName = require('./path/to/module') | import { specificExport } from './path/to/module' or import * as moduleName from './path/to/module' |
Loading mechanism | Always synchronous (blocking) | Can be synchronous or asynchronous (with a top-level await ) |
Top-level this | References the module.exports object | undefined |
Dynamic imports | Limited support (via require.ensure() or newer dynamic import() in Node.js 13+) | Native support (import('./path/to/module') returns a promise that resolves to the module's exports) |
Tree-shaking | Not directly supported (requires additional tools) | Supported (tools can analyze static import statements and remove unused code) |
Browser compatibility | Requires bundling or transpiling | Native support in modern browsers (with <script type="module"> ) |
Node.js compatibility | Fully supported | Native support through the .mjs extension or "type": "module" in package.json |
Deno compatibility | Supported in Node.js compatibility mode | Native support |
Bun compatibility | Fully supported | Native support |
Ecosystem adoption | Widely used in existing Node.js projects | Rapidly gaining adoption, considered the modern standard for new projects |
As you can see, ES modules gains a clear advantage with its wider compatibility, performance advantage, and alignment with modern JavaScript development practices.
In the upcoming sections, we'll explore how Node accommodates both module systems, the mechanisms it employs to distinguish between them, and things to note when transitioning to ES modules from CommonJS.
How Node Supports CommonJS and ES Modules
Node has had to walk a tightrope, supporting both the established CommonJS module system and the newer, standardized ES modules. This dual support allows for backward compatibility with existing projects, while enabling a gradual transition towards ESM.
Let's look closely at the mechanisms Node uses to distinguish between these two module systems and see how you can leverage them in your projects.
Standard JavaScript Files (.js
)
Node initially only supported CommonJS, using the .js
extension for all
module files. When ES modules was introduced, using .js
for both module
systems would have created ambiguity, so the decision was made to continue
interpreting .js
files as CommonJS modules by default. This is still the
current behavior in the latest Node.js release (v22) at the time of writing.
ES Modules JavaScript Files (.mjs
)
The .mjs
file extension was experimentally introduced in
Node.js v8.5.0 as a way to
distinguish ES modules from CommonJS modules. It was later stabilized in v13.2.0
so that you could use .mjs
files to indicate ES modules without needing
experimental flags.
This allows you to gradually adopt ES modules within your projects without disrupting existing CommonJS code. By updating the file extension and module syntax within the file, you can choose which files to convert to ESM while leaving the rest as CommonJS.
CommonJS JavaScript Files (.cjs
)
The .cjs
extension explicitly marks a file as a CommonJS
module, even if the project has "type": "module"
set in its package.json
.
This is useful when you need specific files to be treated as CommonJS modules
within an ESM project.
The type
Property in package.json
In
Node v13.2.0, a new type
field was introduced for the package.json
file, and it can
have two possible values:
commonjs
: This value (the default if thetype
field is not present) indicates that all.js
files within the package should be treated as CommonJS modules.module
: This indicates that all.js
files within the package should be treated as ES modules. You no longer have to write your JavaScript code in a.mjs
file to use theimport
andexport
syntax.
Introducing the "type": "module"
setting marked a significant step
towards the wider adoption of ES modules in the Node ecosystem because it
provided an easy way to explicitly declare that all .js
files within a package
should be treated as ES modules.
While the default remains commonjs
, it may later be changed to module
, so
library authors are encouraged to be explicit where possible. This ensures that the package files will continue to be
interpreted correctly even if the
default changes in the future.
Note that regardless of this setting, .mjs
files will always be treated as ES
modules, while .cjs
files will be interpreted as CommonJS modules.
CommonJS and ES Modules: Interoperability
While CommonJS and ES modules have distinct characteristics, Node aims to provide a degree of interoperability between the two systems. This allows you to gradually transition your codebase or work with legacy projects that mix both module types.
However, combining these module systems isn't always seamless due to their inherent differences. Let's delve into the strategies you can use to resolve errors and make them work well together.
Importing CommonJS Into ES Modules
You can use CommonJS modules from within an ES module through the
import
statement. This essentially means you don't need to worry about what
module system you're importing into ESM files.
Here's an example:
When you execute the index.mjs
file, you will see the expected 8
output
confirming that CommonJS modules can be imported and used seamlessly in an ES
module.
While this is a positive step towards smoother transitions, the compatibility between the two module systems unfortunately doesn't extend much further.
Importing ES Modules Into CommonJS
By default, CommonJS modules cannot directly require()
an ES module. This is
due to fundamental differences in how the two systems handle module loading and
execution.
Executing the index.cjs
file in this instance will yield the following error:
The ERR_REQUIRE_ESM
error occurs when you try to use the CommonJS require()
function to import an ES module. To fix the issue, the error message itself
provides the solution — replace the require()
call with a dynamic import()
:
Executing this updated file now produces the expected output of 8
.
Loading ES Modules with require()
A common misconception is that ES modules cannot be loaded with CommonJS's
require()
function due to ESM's asynchronous nature. However, ES modules load
synchronously unless they contain a top-level await
statement.
To improve interoperability between CommonJS and ES modules, a new
--experimental-require-module
flag was introduced in
Node.js v22.
This flag allows importing synchronous ES modules (no top-level await
) within
CommonJS files through require()
. You may
read the pull request here.
If you revert the content of your index.cjs
file to:
You may now execute it with the --experimental-require-module
flag as follows:
You will see the following output:
The sum prints as expected, but it also triggers a warning that this remains an experimental feature that should not be relied on for production use at the moment. The goal is to eventually make this the default behavior in a future release.
One thing to note is that if you're using default exports, as in:
You must use require(<module_path>).default
to access the exported value in
your CommonJS module:
Also, note that this feature is currently only available for fully synchronous ES
modules which don't contain a top-level await
. If you attempt to import a module
containing a top-level await
, you'll get the following error:
Things to Consider When Migrating from CommonJS to ES Modules
Transitioning your codebase from CommonJS to ES modules can be rewarding, unlocking the benefits we discussed earlier. However, it's important to approach this process thoughtfully and systematically. Here's a step-by-step guide to help you navigate the transition smoothly.
1. No __dirnmame
And __filename
CommonJS automatically provided two variables in its module scope that returned the current module's directory name and file name:
However, these variables are not available in ES modules, so this workaround was initially being used:
With the release of Node.js version 20.11.0, this boilerplate is no longer necessary. You can now write:
2. File Extensions Are Required in ES Modules
In CommonJS, it is common practice to omit file extensions when importing
relative files. Node would automatically try to resolve the module by
appending .js
, .json
, and .node
extensions (in that order).
While the current recommendation is to explicitly include the .js
or .cjs
extension for clarity and to avoid potential ambiguity, especially in projects
where both CommonJS and ES modules are used, the prevailing practice is still to omit
them.
However, file extensions are mandatory for importing relative files in ES
modules. You need to specify either .js
, .mjs
, or .cjs
, depending on the
type of module you are importing and the project configuration:
3. Importing JSON Modules Is Experimental
Directly importing JSON files using the standard import
statement is not
natively supported in ES modules, unlike in CommonJS, where you can simply
require()
a JSON file.
The workaround is to use the experimental import attributes feature (still currently marked as experimental):
4. Top-Level Await is Only Available in ES Modules
CommonJS does not directly support top-level await
. If you try to use
await
outside of an async
function, you'll get a syntax error, due
to the synchronous nature of CommonJS module loading and execution.
The workaround is to use an async
Immediately Invoked Function Expression
(IIFE):
ES modules support asynchronous loading, so await
can pause execution
until a promise resolves (without being placed within an async
function):
5. Slightly Less Compact Syntax with ES Modules
CommonJS allows for a compact style where you can manipulate an imported module
within the same line as the require()
statement. For instance, you can convert
callback-based APIs into promises using util.promisify
:
This pattern isn't as concise in ES modules, requiring an extra step:
This is admittedly a minor point, but it's worth noting regardless.
6. Cyclic Dependencies Work Better with ES Modules
A cyclic dependency happens when two or more modules depend on each other, creating a circular reference. While this might seem harmless, it can cause issues due to how modules are loaded and initialized. Let's explore how CommonJS and ES modules tackle this challenge.
Imagine we have the following two files:
Both a.cjs
and b.cjs
import each other, creating a cycle. When you execute
a.cjs
, the second module in the cycle (b.cjs
) receives an unfinished copy of
the first module (a.cjs
) with its module.exports
not fully populated.
This leads to a.message
returning undefined
and the following warning:
Even if you delay the execution of the console.log()
statement in b.cjs
with
a setTimeout()
, you will still get undefined
because the reference to
a.cjs
's module.exports
is not updated after the initial copy.
Converting such cyclic dependencies to ESM exhibits a different behavior:
Executing a.mjs
now throws an error because it detects that you're trying to
use an import before it's initialized.
This is much better because it explicitly tells you why the code isn't working as you'd expect it to.
Since ES modules use live bindings to refer to exported variables, if you delay using the imported variable until both modules are initialized, you can access the updated values.
Executing a.mjs
now outputs:
Cyclic dependencies are best avoided, but ES modules offer more predictable handling through errors and live bindings, unlike CommonJS' partial objects.
And that's it!
Wrapping Up
In 2009, CommonJS addressed a crucial need within the JavaScript ecosystem, providing a much-needed module system at a time when the language lacked a native solution.
But today, ES modules have emerged as the standardized and future-proof approach to modular JavaScript. While the transition might present some challenges, the long-term advantages are undeniable.
As you navigate the process, remember to leverage tools, learn from the community, and embrace the exciting possibilities that ES modules bring to JavaScript development.
Thanks for reading!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.