javascript

A Deep Dive Into CommonJS and ES Modules in Node.js

Damilola Olatunji

Damilola Olatunji on

A Deep Dive Into CommonJS and ES Modules in Node.js

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:

JavaScript
// math.js function add(a, b) { return a + b; } function subtract(a, b) { return a - b; } module.exports = { add, subtract, };

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:

JavaScript
// app.js const math = require("./math"); // Use the `add` function const sum = math.add(5, 3); console.log(`Sum: ${sum}`); // Output: Sum: 8 const difference = math.subtract(5, 3); console.log(`Difference: ${difference}`); // Output: Difference: 2

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.

JavaScript
// math.mjs export function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; }

2. Module Consumption with import

In a different file, you can use import to selectively import only the specific things you need:

JavaScript
// app.mjs import { add } from "./math.mjs"; const sum = add(5, 3); console.log(sum); // Output: 8

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:

FeatureCommonJSES modules
Module definitionPrimarily 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)
Importingconst moduleName = require('./path/to/module')import { specificExport } from './path/to/module' or import * as moduleName from './path/to/module'
Loading mechanismAlways synchronous (blocking)Can be synchronous or asynchronous (with a top-level await)
Top-level thisReferences the module.exports objectundefined
Dynamic importsLimited 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-shakingNot directly supported (requires additional tools)Supported (tools can analyze static import statements and remove unused code)
Browser compatibilityRequires bundling or transpilingNative support in modern browsers (with <script type="module">)
Node.js compatibilityFully supportedNative support through the .mjs extension or "type": "module" in package.json
Deno compatibilitySupported in Node.js compatibility modeNative support
Bun compatibilityFully supportedNative support
Ecosystem adoptionWidely used in existing Node.js projectsRapidly 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

JSON
{ "type": "module" }

In Node v13.2.0, a new type field was introduced for the package.json file, and it can have two possible values:

  1. commonjs: This value (the default if the type field is not present) indicates that all .js files within the package should be treated as CommonJS modules.
  2. 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 the import and export 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:

JavaScript
// math.cjs function add(a, b) { return a + b; } function subtract(a, b) { return a - b; } module.exports = { add, subtract, };
JavaScript
// index.mjs import { add } from "./math.cjs"; const sum = add(5, 3); console.log(sum); // Output: 8

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.

JavaScript
// math.mjs export function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; }
JavaScript
// index.cjs const math = require("./math.mjs"); // Use the `add` function const sum = math.add(5, 3); console.log(`Sum: ${sum}`); // Output: Sum: 8

Executing the index.cjs file in this instance will yield the following error:

text
node:internal/modules/cjs/loader:1279 throw new ERR_REQUIRE_ESM(filename, true); ^ Error [ERR_REQUIRE_ESM]: require() of ES Module /home/dami/demo/commonjs-vs-esm/math.mjs not supported. Instead change the require of /home/dami/demo/commonjs-vs-esm/math.mjs to a dynamic import() which is available in all CommonJS modules.

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():

JavaScript
// index.cjs async function main() { const math = await import("./math.mjs"); console.log(math.add(5, 3)); // Output: 8 } main();

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:

JavaScript
// index.cjs const math = require("./math.mjs"); // Use the `add` function const sum = math.add(5, 3); console.log(`Sum: ${sum}`); // Output: Sum: 8

You may now execute it with the --experimental-require-module flag as follows:

Shell
node --experimental-require-module index.cjs

You will see the following output:

text
Sum: 8 (node:126015) ExperimentalWarning: Support for loading ES Module in require() is an experimental feature and might change at any time (Use `node --trace-warnings ...` to show where the warning was created)

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:

JavaScript
// math.mjs function add(a, b) { return a + b; } export function subtract(a, b) { return a - b; } export default { add, };

You must use require(<module_path>).default to access the exported value in your CommonJS module:

JavaScript
// index.cjs const math = require("./math.mjs"); // Use the `add()` function through the `default` property const sum = math.default.add(5, 3); console.log(`Sum: ${sum}`); // Output: Sum: 8 // use `subtract()` directly console.log(math.subtract(10, 20));

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:

text
node:internal/modules/esm/module_job:339 this.module.instantiateSync(); ^ Error: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla. . . .

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:

JavaScript
__dirname; // The absolute path to the current module's parent directory __filename; // The absolute path to the current module

However, these variables are not available in ES modules, so this workaround was initially being used:

JavaScript
// app.mjs import * as url from "url"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const __filename = url.fileURLToPath(import.meta.url);

With the release of Node.js version 20.11.0, this boilerplate is no longer necessary. You can now write:

JavaScript
// app.mjs import.meta.dirname; // The absolute path to the current module's parent directory import.meta.filename; // The absolute path to the current module

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).

JavaScript
// Correct const math = require("./math"); const math = require("./math.js"); const math = require("./math.cjs");

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:

JavaScript
// Correct import myModule from "./myModule.js"; import myModule from "./myModule.mjs"; import myModule from "./myModule.cjs"; // Incorrect (will throw a `ERR_MODULE_NOT_FOUND` error) import myModule from "./myModule";

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.

JavaScript
// app.cjs const books = require("./books.json"); console.log(books); // Works
JavaScript
// app.mjs import books from "./books.json"; // fails with `ERR_IMPORT_ATTRIBUTE_MISSING` error console.log(books);

The workaround is to use the experimental import attributes feature (still currently marked as experimental):

JavaScript
// app.mjs import books from './books.json' with { type: 'json' }; console.log(books); // Works, but produces a warning as of Node v22.5.1

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.

JavaScript
// app.js const url = "https://jsonplaceholder.typicode.com/posts"; const resp = await fetch(url);
text
/home/dami/demo/commonjs-vs-esm/app.js:2 const resp = await fetch(url); ^^^^^ SyntaxError: await is only valid in async functions and the top level bodies of modules

The workaround is to use an async Immediately Invoked Function Expression (IIFE):

JavaScript
// app.js const url = "https://jsonplaceholder.typicode.com/posts"; (async () => { const response = await fetch(url); const data = await response.json(); console.log(data); })();

ES modules support asynchronous loading, so await can pause execution until a promise resolves (without being placed within an async function):

JavaScript
// app.mjs const url = "https://jsonplaceholder.typicode.com/posts"; const response = await fetch(url); const data = await response.json(); console.log(data);

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:

JavaScript
const { promisify } = require("node:util"); const stat = promisify(require("node:fs").stat);

This pattern isn't as concise in ES modules, requiring an extra step:

JavaScript
import { promisify } from "node:util"; import { stat as _stat } from "node:fs"; const stat = promisify(_stat);

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:

JavaScript
// a.cjs const b = require("./b.cjs"); console.log("Message from a.cjs:", b.message); module.exports = { message: "Hello from a.cjs", };
JavaScript
// b.cjs const a = require("./a.cjs"); console.log("Message from b.cjs:", a.message); module.exports = { message: "Greetings from b.cjs", };

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:

text
Message from b.cjs: undefined Message from a.cjs: Greetings from b.cjs (node:380724) Warning: Accessing non-existent property 'message' of module exports inside circular dependency (Use `node --trace-warnings ...` to show where the warning was created)

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.

JavaScript
// b.cjs const a = require("./a.cjs"); setTimeout(() => { console.log("Message from b.cjs:", a.message); }, 50); module.exports = { message: "Greetings from b.cjs", };
text
// Executing a.cjs outputs: Message from a.js: Greetings from b.js Message from b.js: undefined (node:382785) Warning: Accessing non-existent property 'message' of module exports inside circular dependency (Use `node --trace-warnings ...` to show where the warning was created)

Converting such cyclic dependencies to ESM exhibits a different behavior:

JavaScript
// a.mjs import { message as bMessage } from "./b.mjs"; export const message = "Hello from a.mjs"; console.log("Message from a.mjs:", bMessage);
JavaScript
// b.mjs import { message as aMessage } from "./a.mjs"; export const message = "Greetings from b.mjs"; console.log("Message from b.mjs:", aMessage);

Executing a.mjs now throws an error because it detects that you're trying to use an import before it's initialized.

text
console.log("Message from b.mjs:", aMessage); ^ ReferenceError: Cannot access 'aMessage' before initialization . . .

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.

JavaScript
// b.mjs import { message as aMessage } from "./a.mjs"; export const message = "Greetings from b.mjs"; setTimeout(() => { console.log("Message from b.mjs:", aMessage); }, 0);

Executing a.mjs now outputs:

text
Message from a.mjs: Greetings from b.mjs Message from b.mjs: Hello from a.mjs

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.

Damilola Olatunji

Damilola Olatunji

Damilola is a freelance technical writer and software developer based in Lagos, Nigeria. He specializes in JavaScript and Node.js, and aims to deliver concise and practical articles for developers. When not writing or coding, he enjoys reading, playing games, and traveling.

All articles by Damilola Olatunji

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps