Understanding Module Systems in Node.js
Lukas Schneider
DevOps Engineer · Leapcell

Introduction
Module systems are fundamental to modern JavaScript development, enabling developers to organize code into reusable units and manage dependencies efficiently. For a long time, CommonJS (CJS) served as the de facto standard for server-side JavaScript in Node.js, proving robust and effective for numerous projects. However, with the standardization of ECMAScript Modules (ESM) in the browser and eventually in Node.js, the landscape of module management has shifted significantly. This evolution brings powerful new features and a more unified module system across the JavaScript ecosystem, but it also introduces complexities and decision points for developers. Understanding the nuances between CJS and ESM, along with how to navigate their interoperability, is crucial for building maintainable and high-performing Node.js applications today. This article will delve into these differences, explore strategies for working with both systems, and provide practical insights for your projects.
Core Concepts
Before diving into the specifics, let's define the core terms related to module systems in Node.js.
CommonJS (CJS)
CommonJS is a module specification primarily used in Node.js. It's a synchronous module loading system, meaning that when a module is required, the runtime waits for the module to be loaded and parsed before continuing execution.
require()
: The function used to import modules. It takes the module path as an argument and returns theexports
object of that module.module.exports
: An object used to export values from a module. By default, it's an empty object{}
. When you assign tomodule.exports
, you're setting the entire export of the module.exports
: A reference tomodule.exports
. You can add properties toexports
to expose multiple values.
ES Modules (ESM)
ES Modules (also known as JavaScript Modules or ES6 Modules) are the official standard for modules in ECMAScript. They feature a static, asynchronous loading mechanism and are designed to work both in browsers and Node.js.
import
: The statement used to import modules. It can be used for named imports (import { name } from './module'
) or default imports (import defaultExport from './module'
).export
: The statement used to export values from a module. It can be used for named exports (export const name = 'value'
) or default exports (export default value
).- Static Analysis: ESM imports and exports can be analyzed statically at parse time, which enables tree-shaking and better tooling support.
- Asynchronous Loading: ESM modules are designed for asynchronous loading, which is crucial for performance in web browsers.
package.json
type
field
Node.js uses the type
field in package.json
to determine whether files in a package should be interpreted as CJS or ESM.
"type": "commonjs"
: All.js
files (and files with no extension) are treated as CJS."type": "module"
: All.js
files (and files with no extension) are treated as ESM.
Regardless of the type
field, .mjs
files are always treated as ESM, and .cjs
files are always treated as CJS. This provides a way to delineate module types explicitly.
Differences and Interoperability
The core differences between CJS and ESM stem from their design philosophies and how they handle loading, syntax, and scope.
Key Differences
-
Syntax:
- CJS: Uses
require()
for imports andmodule.exports
orexports
for exports. - ESM: Uses
import
for imports andexport
for exports.
CJS Example:
// math.js function add(a, b) { return a + b; } module.exports = { add }; // app.js const { add } = require('./math'); console.log(add(2, 3)); // 5
ESM Example:
// math.mjs export function add(a, b) { return a + b; } // app.mjs import { add } from './math.mjs'; console.log(add(2, 3)); // 5
- CJS: Uses
-
Loading Mechanism:
- CJS: Synchronous loading.
require()
blocks execution until the module is loaded. This is fine for server-side environments where file I/O is fast. - ESM: Asynchronous loading.
import
statements are processed first before the module's code is executed. This allows for parallel loading and is optimized for web environments.
- CJS: Synchronous loading.
-
Binding vs. Values:
- CJS: Exports are copies of values. If an exported value changes inside the exporting module, the importing module won't see the updated value.
- ESM: Exports are live bindings. If an exported value changes in the exporting module, the importing module will see the updated value. This is particularly useful for things like class instances or configuration objects that might be modified.
ESM Live Binding Example:
// counter.mjs export let count = 0; export function increment() { count++; } // app.mjs import { count, increment } from './counter.mjs'; console.log(count); // 0 increment(); console.log(count); // 1 (live binding)
CJS Value Copy Example:
// counter.js let count = 0; function increment() { count++; } module.exports = { count, increment }; // app.js const { count, increment } = require('./counter'); console.log(count); // 0 increment(); console.log(count); // 0 (copied value, not live binding)
-
this
Context:- CJS: At the top level of a CJS module,
this
refers tomodule.exports
. - ESM: At the top level of an ESM module,
this
isundefined
.
- CJS: At the top level of a CJS module,
-
File Extensions:
- CJS: Typically uses
.js
(unlesstype: "module"
is set). - ESM: Can use
.mjs
explicitly or.js
iftype: "module"
is set inpackage.json
.
- CJS: Typically uses
-
__dirname
and__filename
:- CJS: These global variables are available directly to provide the current directory name and file name.
- ESM: These are not directly available. You need to construct them using
import.meta.url
.
ESM equivalent of
__dirname
and__filename
:import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); console.log(__filename); console.log(__dirname);
Interoperability Strategies
Node.js offers mechanisms to use CJS and ESM together within the same project.
-
Importing CJS from ESM: ESM modules can directly
import
CJS modules. Thedefault
export of the CJS module becomes the imported value. Named exports from CJS modules are not directly accessible; you must import the entire module and then destructure it.// cjs-module.js module.exports = { greet: 'Hello CJS!', sayHi: () => 'Hi CJS!' }; // esm-app.mjs (or .js with "type": "module") import cjsModule from './cjs-module.js'; // Imports the entire exports object console.log(cjsModule.greet); // Hello CJS! console.log(cjsModule.sayHi()); // Hi CJS! // Direct named import of CJS is not supported // import { greet } from './cjs-module.js'; // This will error
-
Requiring ESM from CJS (Experimental/Indirect): Directly
require()
an ESM module from a CJS module is not supported by Node.js out of the box.require()
is synchronous, while ESM loading is asynchronous.To achieve this, you need to use dynamic
import()
within a CJS module, which returns a Promise. This is typically done in anasync
function.// esm-module.mjs export const message = 'Hello ESM from CJS!'; // cjs-app.js async function run() { const esmModule = await import('./esm-module.mjs'); console.log(esmModule.message); // Hello ESM from CJS! // For default exports, it would be esmModule.default } run();
-
Mixed Packages (Dual Package Hazard): When publishing a package that supports both CJS and ESM, you face the "dual package hazard," where different parts of an application might load different instances of the package, leading to unexpected behavior (e.g., singleton classes becoming multiple instances).
To mitigate this, libraries often provide separate entry points using the
exports
field inpackage.json
:{ "name": "my-package", "main": "./lib/cjs/index.js", "module": "./lib/esm/index.mjs", "exports": { ".": { "import": "./lib/esm/index.mjs", "require": "./lib/cjs/index.js" }, "./package.json": "./package.json" }, "type": "commonjs" }
With this configuration:
- When an ESM module imports
my-package
, it will loadlib/esm/index.mjs
. - When a CJS module requires
my-package
, it will loadlib/cjs/index.js
.
This ensures that the correct module type is loaded based on the context.
- When an ESM module imports
Application Scenarios and Best Practices
- New Projects: Generally, new Node.js projects should favor ESM. It's the standard, offers static analysis benefits, and aligns with the broader JavaScript ecosystem. Set
"type": "module"
in yourpackage.json
. - Existing CJS Projects: Migrating a large CJS project to ESM can be a significant undertaking. Incremental adoption using the interoperability features is often the most practical approach. Gradually convert parts of your codebase to ESM, especially new functionalities.
- Library Development: If you're building a library, strongly consider supporting both CJS and ESM using the
exports
field inpackage.json
to reach the widest audience and avoid the dual package hazard. - Tooling: Be aware that some older Node.js tools and testing frameworks might have better support for CJS than ESM. Check your toolchain's documentation for ESM compatibility.
Conclusion
The coexistence of CommonJS and ES Modules in Node.js presents both challenges and opportunities. While CJS has a long history and remains prevalent, ESM represents the future of JavaScript modularity, offering a standardized approach with significant advantages like static analysis and live bindings. By understanding their fundamental differences and leveraging Node.js's built-in interoperability features, developers can effectively navigate this dual-module environment. Embracing ESM for new projects and carefully managing interop in existing ones will lead to more robust, maintainable, and future-proof Node.js applications.
The shift towards ESM unifies JavaScript's module story across environments, making code organization more consistent and powerful.