The adoption of ECMAScript Modules (ESM) for development has become mainstream, yet Node.js continues to use the traditional CommonJS (CJS) format. This has posed challenges for library and application developers. However, with the recent release of version 22, Node.js has begun to experimentally support ESM natively, and this support is expected to stabilize in future versions. Although current support has its limitations and is not yet perfect, it is sufficient to improve the existing situation. This article will provide a detailed overview.
Understanding ESM and Its Synchronous Capabilities
ECMAScript Modules (ESM) are an official standard for structuring and loading modules in JavaScript. Unlike CJS, which executes synchronously, ESM was designed with the capability to load asynchronously. This design supports top-level await
commands, allowing modules to handle asynchronous operations before they are used.
Despite this capability, ESM can also be executed synchronously if the module graph (the structure consisting of all the modules and their dependencies) does not contain any top-level await
.
Hold on, you might be as puzzled as I am — aren’t ESM modules executed asynchronously?
Indeed, the ESM standard is mentioned in the TC39 specifications, where it is designed to be conditionally asynchronous — only when the module graph contains a top-level await
.
Well, now that we know this, understanding the dual functionality of ESM — both synchronous and asynchronous — is crucial for comprehending the new features in Node.js.
Node.js Enhancement: Synchronous require()
for ESM
The recent update in Node.js introduces an experimental feature that allows require()
to synchronously load ESM graphs that do not contain top-level await
. This feature is significant because it allows developers to use require()
with ESM.
How It Works
When enabled via the --experimental-require-module
flag, Node.js can load an ESM if it meets the following conditions:
The module must be explicitly marked as an ES module either through a
"type": "module"
field in the nearestpackage.json
or by using the.mjs
file extension.The module must be fully synchronous, meaning it contains no top-level
await
.
Under these conditions, require()
can load the ESM and return the module namespace object directly, much like the dynamic import()
statement, but executed synchronously.
Code Example: Synchronous Module Loading
// Define a module in a file named 'math-utils.mjs'
export function square(x) {
return x ** 2;
}
export function cube(x) {
return x ** 3;
}
// main.js - Synchronously require the ES module in a CommonJS file
// $ node --experimental-require-module main.js
const mathUtils = require('./math-utils.mjs');
console.log(mathUtils.square(2)); // Output: 4
console.log(mathUtils.cube(3)); // Output: 27
Practical Implications and Benefits
The ability to require()
ESM synchronously has several practical implications:
Interop Simplicity : It simplifies the interoperation between CJS and ESM, reducing the complexity of adopting ESM for projects that still rely heavily on CJS.
Backward Compatibility : It provides a smoother transition for libraries to move from CJS to ESM without breaking changes for consumers still using
require()
.Module Size Reduction : It aids in reducing the size of
node_modules
by avoiding the need for dual module packaging (CJS and ESM), which is common in current practices.
Challenges and Limitations
While this feature is a significant step forward, it comes with its own set of challenges and limitations:
- Top-Level Await :
Modules that use top-level await
cannot be loaded with require()
and will throw an ERR_REQUIRE_ASYNC_MODULE
error. This limitation necessitates using await import()
for such modules. For example:
await new Promise(res => setTimeout(res)); // top-level await
export function square(x) {
return x ** 2;
}
export function cube(x) {
return x ** 3;
}
// 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.
const mathUtils = require('./math-utils.mjs');
console.log(mathUtils.square(2)); // Output: 4
console.log(mathUtils.cube(3)); // Output: 27
// Pass:
void (async () => {
const mathUtils = await import('./math-utils.mjs');
console.log(mathUtils.square(2)); // Output: 4
console.log(mathUtils.cube(3)); // Output: 27
})();
- Experimental Status :
Since it is still in the experimental phase, this feature may not be suitable for all production environments. There are still some feature interactions that this implementation doesn’t handle, such as --experimental-detect-module
, --experimental-loader
, or --experimental-wasm-modules
. Some edge cases involving cycles might exhibit undefined behaviors.
Conclusion
The introduction of synchronous require()
support for ESM in Node.js marks a pivotal development in JavaScript's modular architecture. However, if you are wondering why it took so long to start supporting ESM, you can find clues in this issue. Finally, with the active participation of the community, Node.js is very likely to achieve perfect support for ESM.
If you find this helpful, please consider subscribing to my newsletter for more insights on web development. Thank you for reading!