ECMAScript Modules
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with
the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; all
versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on
all three lines unless the text says otherwise. ECMAScript modules (ESM) are the language’s own module format and the one to use
for new code; Node.js still ships its older CommonJS format next to it, and the two can load each other
(see ESM & CommonJS Interop). The stability level of what this page covers is not uniform:
the module system as a whole is Stability 2 (Stable), as are import.meta.url, import.meta.dirname, import.meta.filename,
dynamic import(), top-level await, import attributes and JSON modules. Syntax detection and import.meta.resolve() are
Stability 1.2 (Release candidate). import.meta.main is Stability 1.0 (Early development). Text modules are Stability 1.0
(Early development) behind a flag, WebAssembly source phase imports and the JavaScript string builtins are Stability 1.2, and
importing a WebAssembly module instance is Stability 1.1 (Active development).
For the language-level syntax (how export, export default, namespace imports and re-exports are spelled and what they mean in the
browser too) read Modules in the JavaScript reference first; this page concentrates
on what Node.js adds: how a file becomes a module, how specifiers are resolved, and what is different from CommonJS. The
TypeScript flavour of the same syntax is in TypeScript Modules. The running
scenario is the Bookshelf application that grows across this section.
The stability and version map
| Feature | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
ES modules as a whole |
2 Stable |
No flag since v13.2.0 and v12.17.0; top-level await since v14.8.0; stabilised in v15.3.0 and v14.17.0. |
|
Stable |
Defined as in browsers. |
|
Stable |
Added in v21.2.0 and v20.11.0; no longer experimental in v24.0.0 and v22.16.0. |
|
1.0 Early development |
Added in v24.2.0 and v22.18.0 (not in earlier 22 releases). |
|
1.2 Release candidate |
Synchronous since v20.0.0 and v18.19.0; the non-standard second argument still needs |
Syntax detection |
1.2 Release candidate |
Added in v21.1.0 and v20.10.0; on by default since v22.7.0 and v20.19.0. |
Import attributes ( |
Stable |
|
JSON modules |
Stable |
No longer experimental in v23.1.0, v22.12.0, v20.18.3 and v18.20.5. |
Text modules ( |
1.0 Early development |
Added in v26.5.0 behind |
Wasm source phase imports ( |
1.2 Release candidate |
Added in v24.0.0; a syntax error on Node.js 22. |
Wasm module instance imports |
1.1 Active development |
No |
|
2 Stable |
No flag since v22.12.0; no longer experimental in v25.4.0. Covered in ESM & CommonJS Interop. |
import and export
An ES module is a file whose top-level code uses import and export declarations. Three facts distinguish a module from a
classic script and are worth stating before the example: the module code is always in strict mode, its top-level variables are
private to the file (nothing leaks to the global object), and the value of this at the top level is undefined. Exports are
live bindings: an importer sees the current value of the exporting module’s variable, not a copy taken at import time, and it
cannot assign to the binding. The module namespace object that import * as ns produces is a sealed object whose
Symbol.toStringTag is Module.
// lib/book.js
export const GENRES = ['fiction', 'science', 'history'];
export function describe(book) {
return `${book.title} (${book.year})`;
}
export default class Book {
constructor(title, year) {
this.title = title;
this.year = year;
}
}
export let counter = 0;
export function increment() {
counter += 1;
}
// main.js
import Book, { describe, GENRES as genres, counter, increment } from './lib/book.js';
import * as bookModule from './lib/book.js';
export { describe as format } from './lib/book.js';
const b = new Book('Dune', 1965);
console.log(describe(b), genres.length);
console.log(Object.keys(bookModule), bookModule[Symbol.toStringTag]);
console.log('before', counter);
increment();
console.log('after', counter);
try {
counter = 5;
} catch (e) {
console.log(e.constructor.name + ':', e.message);
}
The two files live in a package whose package.json declares "type": "module" (explained in .mjs, .cjs and the "type" field). The output was identical on
Node.js 26, 24 and 22:
Dune (1965) 3
[ 'GENRES', 'counter', 'default', 'describe', 'increment' ] Module
before 0
after 1
TypeError: Assignment to constant variable.
The counter import reflects the change made by increment() (the live binding), the namespace keys are listed in alphabetical order with default
among them, and assigning to an imported binding throws a TypeError even though the exporting module declared it with let.
Imports are hoisted: all import declarations are resolved and loaded before any code of the importing module runs, which is why
they must be static strings. When the specifier is only known at run time, use dynamic import().
Specifiers and mandatory file extensions
The string after from is called the specifier, and Node.js distinguishes three kinds. How each one is resolved is summarised
below and specified precisely in The resolution and loading algorithm.
| Kind | Examples | Rule |
|---|---|---|
Relative |
|
Resolved against the URL of the importing file with standard URL resolution. The file extension is always required. |
Bare |
|
Resolved by the package algorithm: built-in module names first, then |
Absolute |
|
A full URL. |
Unlike CommonJS, ESM resolution performs no extension search and does not treat directories as modules: ./lib/book and
./lib/shelf (a folder with an index.js) both fail. This matches how a typically configured server behaves for browser
imports. The error messages suggest the fix.
// noext.js
import { describe } from './lib/book';
// dirimp.js
import { name } from './lib/shelf';
// dirimp-ok.js
import { name } from './lib/shelf/index.js';
console.log(name);
Running each file reported (paths shortened, identical on Node.js 26, 24 and 22):
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../ex1/lib/book' imported from .../ex1/noext.js
Did you mean to import "./lib/book.js"?
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '.../ex1/lib/shelf' is not supported resolving ES modules imported from .../ex1/dirimp.js
Did you mean to import "./lib/shelf/index.js"?
shelf
TypeScript files follow the same rule when Node.js strips their types: the specifier must name the real file, including the .ts
extension, and type-only imports must be marked with type. A relative import './book' from a .ts file also fails with
ERR_MODULE_NOT_FOUND on all three lines (checked), and import { label, type Book } from './book.ts' runs. The details are in
TypeScript in Node.js; the API reference is the TypeScript page of the Node.js
documentation (https://nodejs.org/docs/latest/api/typescript.html).
Specifiers are URLs
Internally, ES modules are resolved and cached by URL. Two consequences matter in practice. Special characters in a path must be
percent-encoded in a specifier (# as %23, ? as %3F), and a different query string or fragment is a different module, which
is evaluated again. When you have a file system path rather than a specifier, convert it with url.pathToFileURL() instead of
concatenating strings.
const a = await import('./src/util.js?v=1');
const b = await import('./src/util.js?v=2');
const c = await import('./src/util.js?v=1');
console.log('different query => different instance:', a !== b, '| same URL => cached:', a === c);
The output was different query ⇒ different instance: true | same URL ⇒ cached: true on Node.js 26, 24 and 22. The query-string
trick is occasionally used to bust the module cache in tests and file watchers, at the price of leaking the old instance, because
ES modules cannot be evicted (see Evaluation order, caching and cycles).
Other URL schemes are handled by the default loader as follows.
| Scheme | Behaviour |
|---|---|
|
The normal case. Relative and absolute specifiers become |
|
Built-in modules, for example |
|
Inline modules, added in v12.10.0. MIME types |
|
Fail with |
.mjs, .cjs and the "type" field
Node.js has to decide, for every file, whether to parse it as an ES module or as CommonJS. Explicit markers always win, and
they are checked in this order of strength: the file extension, then the "type" field of the nearest parent package.json, then
(for strings from --eval or standard input) the --input-type flag. Only if no marker exists does Node.js fall back to
syntax detection.
| Marker | Interpreted as | Notes |
|---|---|---|
|
ES module |
Always, whatever the nearest |
|
CommonJS |
Always, whatever the nearest |
|
ES module |
The field applies to entry points and to files reached by |
|
CommonJS |
Explicit, so no detection and no performance cost. |
|
Ambiguous |
CommonJS first; see Syntax detection. |
|
Same as the value |
Only for |
|
TypeScript text |
With type stripping; |
The .mjs and .cjs extensions can mix both formats within a single package, for instance a .cjs configuration file inside a
"type": "module" project. The official recommendation is that every package states its "type", even when all sources are
CommonJS, which avoids the detection overhead, protects against a future change of the default, and helps build tools.
// probe.js, probe.mjs and probe.cjs contain the same line
console.log(typeof require === 'undefined' ? 'ESM' : 'CommonJS', typeof module);
With a package.json that has no type, "type": "module" and "type": "commonjs" respectively, the result was the same on
Node.js 26, 24 and 22:
notype probe.js CommonJS object
notype probe.mjs ESM undefined
notype probe.cjs CommonJS object
typemod probe.js ESM undefined
typemod probe.mjs ESM undefined
typemod probe.cjs CommonJS object
typecjs probe.js CommonJS object
typecjs probe.mjs ESM undefined
typecjs probe.cjs CommonJS object
To turn the whole project into ES modules, set the field once, for example with npm pkg set type=module.
Syntax detection
A file is ambiguous when it has a .js extension (or no extension) and no controlling package.json, or one without a "type"
field; string input from --eval or standard input is ambiguous too unless --input-type is given. For ambiguous input Node.js
runs the code as CommonJS first and, if the parser reports syntax that is only valid in an ES module, evaluates it again as an ES
module. The syntax that counts as ES module syntax is exactly the syntax that would throw in CommonJS:
-
importstatements (but notimport()expressions, which are valid in CommonJS), -
exportstatements, -
import.metareferences, -
awaitat the top level of a module, and -
top-level lexical declarations (
const,let,class) of a CommonJS wrapper variable:require,module,exports,filenameordirname.
// esm-import.js
import { sep } from 'node:path';
console.log('import statement ->', typeof require === 'undefined' ? 'ESM' : 'CommonJS', sep);
// esm-tla.js
const v = await Promise.resolve(42);
console.log('top-level await ->', v);
// esm-meta.js
console.log('import.meta ->', import.meta.url.startsWith('file:'));
// esm-redecl.js
const require = (name) => `fake ${name}`;
console.log('redeclared require ->', require('x'), typeof module);
// cjs-dyn.js stays CommonJS: import() alone is not ES module syntax
import('node:os').then((o) => console.log('dynamic import() only ->', typeof o.platform, typeof require));
All four ES module files ran on Node.js 26, 24 and 22 (output import statement → ESM /, top-level await → 42,
import.meta → true, redeclared require → fake x undefined), while the dynamic-import file stayed CommonJS
(dynamic import() only → function function).
Detection is not free. When the nearest package.json exists but has no "type", all three lines also printed a process warning on
standard error, whose code is MODULE_TYPELESS_PACKAGE_JSON (observed on all three lines; the code is not listed in the API
reference):
(node:89704) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../notype/esm-import.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /.../notype/package.json.
The warning is the practical argument for always writing the "type" field: the file is parsed twice. With no package.json at all
the file ran silently. Detection can be switched off with --no-experimental-detect-module, after which the same
import statement fails with SyntaxError: Cannot use import statement outside a module; and the string inputs are detected as well:
node --eval "import { sep } from 'node:path'; console.log('eval detect', sep)"
echo "import { sep } from 'node:path'; console.log('stdin detect', sep);" | node
node --input-type=module -e "console.log(typeof require, import.meta.url.slice(0,5))"
node --no-experimental-detect-module -e "import 'node:path'"
The four commands printed eval detect /, stdin detect /, undefined file: and the SyntaxError on Node.js 26, 24 and 22.
The flags are explained with the others in
CLI Options, Environment & Config File. The earlier
--experimental-default-type flag, which let a project change what an ambiguous file means, still existed in the Node.js 22
documentation (Stability 1.0) and is rejected as a bad option by Node.js 24.21.0 and 26.10.0.
import.meta
import.meta is an object that exists only inside ES modules. On Node.js 26.10.0 it has exactly five keys:
| Property | Stability | Meaning |
|---|---|---|
|
Stable |
The absolute |
|
Stable |
The absolute path of the file with symlinks resolved; equals |
|
Stable |
The directory of the module; equals |
|
1.0 Early development |
|
|
1.2 Release candidate |
Returns the URL string a specifier would resolve to from this module, without loading it. |
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
console.log('url :', basename(import.meta.url));
console.log('filename :', basename(import.meta.filename));
console.log('dirname :', basename(import.meta.dirname));
console.log('main :', import.meta.main);
console.log('keys :', Object.keys(import.meta).sort().join(', '));
// read a sibling file relative to this module, whatever the working directory is
const books = JSON.parse(readFileSync(new URL('./data/books.json', import.meta.url), 'utf8'));
console.log('books :', books.length);
// import.meta.resolve() returns a URL string and never loads the module
console.log(import.meta.resolve('./config.js').endsWith('/ex4/config.js'));
console.log(import.meta.resolve('shelf-ui').endsWith('/shelf-ui/index.js'));
console.log(import.meta.resolve('shelf-ui/asset.css').endsWith('/css/asset.css'));
console.log(import.meta.resolve('#config').endsWith('/config.js'));
console.log(import.meta.resolve('node:fs'));
try {
import.meta.resolve('shelf-ui/private.js');
} catch (error) {
console.log(error.code);
}
console.log(import.meta.resolve('./does-not-exist.js').endsWith('does-not-exist.js'));
The scratch project has a node_modules/shelf-ui package whose "exports" lists only . and ./asset.css, and a "#config" entry
in "imports". Started from the file system root (cd / and an absolute path), the output was the same on all three lines:
url : meta.js
filename : meta.js
dirname : ex4
main : true
keys : dirname, filename, main, resolve, url
books : 2
true
true
true
true
node:fs
ERR_PACKAGE_PATH_NOT_EXPORTED
true
Several details are worth remembering:
-
Starting the program from another working directory does not matter:
new URL('./data/books.json', import.meta.url)is anchored to the module, unlike a relative path handed tofs, which is anchored toprocess.cwd(). -
When the same module is imported by another file instead of being the entry point,
import.meta.mainisfalse(checked with animporter.jsthat only importsmeta.js). Inside an inlinedata:moduleimport.meta.dirnameandimport.meta.filenameareundefined,import.meta.urlstarts withdata:andimport.meta.mainisfalse. -
import.meta.filenamefollows symlinks: for a symlinklinkmeta.jspointing atmeta.js,urlandfilenameboth reportmeta.js; with--preserve-symlinks-mainthey reportlinkmeta.js. -
import.meta.resolve()applies the whole resolution algorithm, including"exports"encapsulation and"imports"aliases, and so it throws for a subpath that a package does not export. It does not touch the target file: a relative specifier to a file that does not exist still returns a URL (it stopped throwing in v20.6.0 and v18.19.0), and the load fails later when youimportit. -
It can perform synchronous file system operations like
require.resolve(), and it is not available inside custom loaders (it would deadlock). The non-standard secondparentargument needs--experimental-import-meta-resolve.
The usual CommonJS idioms translate directly: filename becomes import.meta.filename, dirname becomes import.meta.dirname,
require.main === module becomes import.meta.main, and require.resolve(x) becomes import.meta.resolve(x) (a URL string rather than a path).
On Node.js releases before v22.18.0 (22 line) or v24.2.0 (24 line) import.meta.main does not exist; compare
process.argv[1] with import.meta.filename there. The Bookshelf server in the scratch project uses the guard shown next, so
that its handlers can be imported by tests without opening a port.
export function foo() {
return 'Hello, world';
}
function main() {
console.log(foo());
}
if (import.meta.main) main();
Dynamic import() and top-level await
import(specifier) is an expression, not a statement. It returns a promise for the module’s namespace object, accepts any string
computed at run time, and works in both ES modules and CommonJS files; it can load both module formats. In a CommonJS file it is the
way to reach an ES module that must stay asynchronous (for example one with top-level await), and in an ES module it is the way to
load code lazily, conditionally or by name.
An ES module may also use await at its top level, without an enclosing async function. A module that awaits becomes
asynchronous: modules that import it wait until it finishes evaluating, while unrelated modules keep going. The exports become
available after the awaited work completes. That makes initialisation code (reading configuration, opening a connection) a normal
part of the module graph.
// catalogue.js
import { readFile } from 'node:fs/promises';
console.log('catalogue: start loading');
// Top-level await: importers wait until this module has finished evaluating.
export const books = JSON.parse(
await readFile(new URL('./books.json', import.meta.url), 'utf8'),
);
console.log('catalogue: loaded', books.length);
// app.js
import { books } from './catalogue.js';
console.log('app: running with', books.length, 'books');
const format = process.argv[2] ?? 'csv';
try {
// Dynamic import(): runtime specifier, returns a promise for the module namespace.
const plugin = await import(`./plugins/${format}.js`);
console.log(plugin.name, '->', plugin.default(books));
} catch (error) {
console.log(error.code, '-', format);
}
// cjs-caller.cjs -- a CommonJS file cannot use static import, but import() works anywhere.
import('./plugins/json.js').then((plugin) => {
console.log('from CommonJS:', plugin.name, typeof require);
});
With plugins/csv.js and plugins/json.js exporting a name and a default render function, node app.js, node app.js json and
node app.js nope printed, on Node.js 26, 24 and 22:
catalogue: start loading
catalogue: loaded 2
app: running with 2 books
csv -> Dune,Emma
...
json -> [{"title":"Dune"},{"title":"Emma"}]
...
ERR_MODULE_NOT_FOUND - nope
The three runs each began with the same three catalogue and app lines (elided above after the first). node cjs-caller.cjs
printed from CommonJS: json function: the plugin loaded from CommonJS, and require is still available in the caller. The
catalogue message order proves that app.js did not start until the awaited read had finished.
A top-level await on a promise that never settles has a special outcome: the event loop becomes empty, so Node.js prints a
warning and exits with status code 13 (Unsettled Top-Level Await).
console.log('before');
await new Promise(() => {});
console.log('never printed');
On all three lines the output was before, then Warning: Detected unsettled top-level await at file:///…/never.mjs:2, and the exit
status (echo $?) was 13. Two pitfalls follow from this design. First, an exception thrown by a top-level await rejects the
whole module, and with it every module that imports it. Second, top-level await in a module that is itself loaded through
require() is not supported: such a graph fails with ERR_REQUIRE_ASYNC_MODULE, which is why libraries that must be require()-able
avoid it (see ESM & CommonJS Interop).
Import attributes and JSON modules
Import attributes are inline metadata that travel with the specifier, written with { key: 'value' } in a static import and as the
with property of the second argument of import(). Node.js supports exactly one key, type, with two values: 'json' for JSON
modules and 'text' for text modules. The attribute is mandatory for those module types: Node.js does not infer a JSON or text
module from the file extension alone.
import book from './book.json' with { type: 'json' };
const again = await import('./book.json', { with: { type: 'json' } });
console.log(book.title, again.default === book, Object.keys(again));
// A data: URL can carry JSON, too; the attribute is required here as well.
const inline = await import('data:application/json,{"ok":true}', { with: { type: 'json' } });
console.log(inline.default.ok);
Output on all three lines: Dune true [ 'default' ] and true. A JSON module exposes only a default export, with no named
exports, and the same object is returned however many times it is imported. Node.js also creates an entry in the CommonJS cache
for it, so require('./book.json') in a CommonJS file returns the identical object (checked: ns.default === viaRequire was true).
Because the object is shared, mutating it is visible to every importer, exactly as it is for CommonJS.
Every wrong use is rejected with a distinct error code:
for (const [label, load] of [
['no attribute', () => import('./book.json')],
['wrong type ', () => import('./book.json', { with: { type: 'css' } })],
['unknown key ', () => import('./book.json', { with: { type: 'json', mode: 'x' } })],
['js with json', () => import('./json.js', { with: { type: 'json' } })],
]) {
try {
await load();
console.log(label, 'loaded');
} catch (error) {
console.log(label, error.code);
}
}
no attribute ERR_IMPORT_ATTRIBUTE_MISSING
wrong type ERR_IMPORT_ATTRIBUTE_UNSUPPORTED
unknown key ERR_IMPORT_ATTRIBUTE_UNSUPPORTED
js with json ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE
These four lines were the result on Node.js 26, 24 and 22. The older proposal that used the keyword assert (assert { type: 'json' })
was dropped in Node.js 22.0.0 after import attributes replaced it (v21.0.0, v20.10.0, v18.20.0): the same line now throws
SyntaxError: Unexpected identifier 'assert', checked on all three lines. Code and tutorials that still use assert must be
updated to with.
Text modules
A file can also be imported as a string. The module’s only export is default, whose value is the file’s source text, and the
attribute with { type: 'text' } is mandatory. The feature is Stability 1.0 (Early development), was added in Node.js 26.5.0, and
is gated by --experimental-import-text; the Node.js 24 documentation lists the flag since v24.19.0, and Node.js 22 does not have it
(node: bad option).
import note from './note.txt' with { type: 'text' };
console.log(JSON.stringify(note));
Without the flag, Node.js 26 and 24 fail with TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".txt".
With node --experimental-import-text text.js, Node.js 26.10.0 and 24.21.0 printed
"Hello from a text file\nsecond line\n" after an ExperimentalWarning ("Text import is an experimental feature and might change
at any time"). For anything that must run on Node.js 22, read the file with fs.readFileSync(new URL('./note.txt', import.meta.url), 'utf8').
WebAssembly modules
WebAssembly files (.wasm) can be imported like any other module, following the ES Module Integration proposal for WebAssembly. Since
Node.js v24.5.0 and v22.19.0 no --experimental-wasm-modules flag is needed. There are two forms:
-
Instance imports:
import { add } from './add.wasm'instantiates the module and exposes its exports as module exports. If the module imports other things, those imports are resolved as ordinary ES module specifiers (the Wasm module name is the specifier). Stability 1.1 (Active development). -
Source phase imports:
import source mod from './add.wasm'gives aWebAssembly.Modulethat is not yet instantiated, so you can instantiate it several times with different import objects. The dynamic variant isimport.source('./add.wasm'). Stability 1.2 (Release candidate), added in v24.0.0.
Wasm modules must also avoid import and export names that begin with the reserved prefixes wasm-js: or wasm: (importing
such a module instance throws a WebAssembly.LinkError). When WebAssembly modules are imported, the JavaScript String Builtins proposal is enabled automatically
(Stability 1.2, v24.5.0 and v22.19.0), which lets a module call compile-time string builtins from the wasm:js-string
namespace and import string constants from wasm:js/string-constants; this page does not exercise those.
The examples avoid external tooling by assembling two tiny modules byte by byte: add.wasm exports add(a, b), and add-offset.wasm
imports offset() from ./offset.js and exports addOffset(n), which returns n + offset().
import { writeFileSync } from 'node:fs';
const hex = (text) => Buffer.from(text).toString('hex');
const bytes = (...sections) => Buffer.from(sections.join(''), 'hex');
const header = '0061736d01000000';
// add.wasm: export function add(a: i32, b: i32): i32
writeFileSync('add.wasm', bytes(
header,
'01070160027f7f017f', // type section: (i32, i32) -> i32
'03020100', // function section: one function of type 0
'070701' + '03' + hex('add') + '0000', // export section: "add" = function 0
'0a09010700200020016a0b', // code section: local.get 0, local.get 1, i32.add
));
// add-offset.wasm imports offset() from "./offset.js" and exports addOffset(n) = n + offset()
writeFileSync('add-offset.wasm', bytes(
header,
'010a02' + '60017f017f' + '6000017f', // types: (i32) -> i32 and () -> i32
'0216' + '01' + '0b' + hex('./offset.js') + '06' + hex('offset') + '0001',
'03020100',
'070d01' + '09' + hex('addOffset') + '0001',
'0a09010700200010006a0b', // code: local.get 0, call 0 (the import), i32.add
));
import { add } from './add.wasm';
import * as ns from './add-offset.wasm';
console.log(add(2, 3));
console.log(Object.keys(ns), ns.addOffset(5));
5
[ 'addOffset' ] 105
(node:6222) ExperimentalWarning: Importing WebAssembly module instances is an experimental feature and might change at any time
The result was the same on Node.js 26, 24 and 22, including the ExperimentalWarning (suppress it with --no-warnings if
needed). The second module’s import of ./offset.js was resolved through the normal ES module graph.
import source addModule from './add.wasm';
console.log(addModule instanceof WebAssembly.Module);
const a = await WebAssembly.instantiate(addModule);
const b = await WebAssembly.instantiate(addModule);
console.log(a.exports.add(1, 2), a !== b);
const dynamicModule = await import.source('./add.wasm');
console.log(dynamicModule === addModule);
Output on Node.js 26 and 24: true, 3 true, true. On Node.js 22.23.3 the first line fails to parse with
SyntaxError: Unexpected identifier 'addModule', because the import source syntax is not available there. Native add-ons, as
opposed to WebAssembly, are covered in
Native Addons, WebAssembly & FFI.
Differences from CommonJS
An ES module has its own scope and its own loader, so several CommonJS facilities do not exist and have a replacement.
| CommonJS | In an ES module | Replacement |
|---|---|---|
|
Not defined ( |
|
|
Not defined |
|
|
Not available |
|
|
Not available |
|
|
Unused; the ES loader has its own separate cache |
None; see Evaluation order, caching and cycles. |
|
Unused |
Customization hooks. |
|
Not consulted when resolving import specifiers |
Symlinks, or |
Loading native add-ons |
Not supported in the ES module reference page |
|
Extension and folder search |
None |
Spell the full path; see Specifiers and mandatory file extensions. |
Top-level |
|
Use |
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// None of the CommonJS wrapper variables exist in an ES module
console.log(typeof require, typeof module, typeof exports, typeof __filename, typeof __dirname);
console.log('top-level this:', this);
// The replacements
const cjsRequire = createRequire(import.meta.url);
console.log(typeof cjsRequire('node:os').platform);
console.log(import.meta.filename === fileURLToPath(import.meta.url));
console.log(import.meta.dirname === dirname(import.meta.filename));
// NODE_PATH only affects require(), never import
try {
await import('viaenv.cjs');
} catch (error) {
console.log('import :', error.code);
}
console.log('require:', cjsRequire('viaenv.cjs').kind);
Run with NODE_PATH=./np node diffs.js, where np/viaenv.cjs contains exports.kind = 'from NODE_PATH';, the output was the same on
all three lines:
undefined undefined undefined undefined undefined
top-level this: undefined
function
true
true
import : ERR_MODULE_NOT_FOUND
require: from NODE_PATH
Importing CommonJS from ES modules
An import can reference a CommonJS file. The CommonJS module.exports value is always the default export; in addition Node.js
runs a heuristic static analysis over the CommonJS source (it is performed before the file is evaluated, using the merve lexer)
to expose property assignments such as exports.title = … as named exports. Detection is a convenience and is not guaranteed to
find every export; the default import is the dependable form. Since Node.js 23.0.0 the namespace also has a 'module.exports'
export that unambiguously refers to module.exports.
// legacy.cjs
exports.title = 'Dune';
exports.year = 1965;
setTimeout(() => { exports.late = true; }, 0);
// cjs-import.js
import legacy, { title } from './legacy.cjs';
import * as ns from './legacy.cjs';
console.log(title, legacy.title);
console.log(Object.keys(ns).sort());
await new Promise((resolve) => setTimeout(resolve, 10));
console.log('late on default:', legacy.late, '| late on namespace:', ns.late);
Dune Dune
[ 'default', 'late', 'module.exports', 'title', 'year' ]
late on default: true | late on namespace: undefined
That is the result on Node.js 26.10.0 and 24.21.0. On Node.js 22.23.3 the namespace keys were [ 'default', 'late', 'title', 'year' ]:
the 'module.exports' marker is not present in the 22 line (the documentation lists it from v23.0.0). The last line is the important
one: late is detected as a name, but a named export is a snapshot taken when the module is imported, so later changes and
new properties are visible only through the default export. Live bindings do not exist for CommonJS named exports. The
reverse direction (require() of an ES module, ERR_REQUIRE_ASYNC_MODULE, createRequire(), customization hooks and migrating a
project) is the subject of ESM & CommonJS Interop, and CommonJS itself is covered in
CommonJS Modules.
Built-in modules
Built-in modules (node:fs, node:path…) provide a named export for every public property of their API and a default export
that is the CommonJS exports object. The node: prefix is the recommended spelling: it is unambiguous and it is mandatory for a
few modules that exist only with the prefix. All named exports are populated eagerly when you import a built-in module, which
makes the first import marginally slower than require() or process.getBuiltinModule(), where some properties of the exports
object can be initialised lazily. Named exports are snapshots of the default export: patching fs.readFileSync changes the
default object, and the named export updates only after module.syncBuiltinESMExports() is called.
import fs, { readFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { Buffer } from 'node:buffer';
fs.readFileSync = () => Buffer.from('patched');
console.log('before sync:', readFileSync === fs.readFileSync);
syncBuiltinESMExports();
console.log('after sync :', readFileSync === fs.readFileSync, readFileSync().toString());
The output, identical on Node.js 26, 24 and 22, was before sync: false, then after sync : true patched. Monkey-patching built-ins
like this is for instrumentation libraries, not application code.
Evaluation order, caching and cycles
Each module URL is loaded and evaluated at most once per process; later imports receive the same module namespace. The ES module
loader keeps its own cache, which is not require.cache, and there is no public way to remove an entry (a different query string
creates a new entry, see Specifiers are URLs). Evaluation follows a depth-first, post-order walk of the import graph, which defines what
happens in a cycle: a module that is already being evaluated is not entered again, and its bindings may still be in the
temporal dead zone for the importer.
// once.js -- three imports of the same file, one evaluation
import './counter.js';
import './counter.js';
await import('./counter.js');
// counter.js:
// globalThis.runs = (globalThis.runs ?? 0) + 1;
// console.log('counter.js evaluated, runs =', globalThis.runs);
// a.js
import { b, readA } from './b.js';
console.log('a runs; b =', b);
export const a = 'A';
console.log('a sees its own export through b:', readA());
// b.js
import { a } from './a.js';
console.log('b runs; a is in the TDZ:', (() => { try { return a; } catch (e) { return e.name; } })());
export const b = 'B';
export const readA = () => a;
Running node once.js printed counter.js evaluated, runs = 1 once, and node a.js printed, on all three lines:
b runs; a is in the TDZ: ReferenceError
a runs; b = B
a sees its own export through b: A
The cycle works because b.js only reads a lazily inside a function; reading a while b.js was evaluating, before a.js had
declared it, throws a ReferenceError. The standard rule applies: keep top-level code in cyclic modules free of reads of the other
module’s bindings, or break the cycle.
The resolution and loading algorithm
Loading a module specifier happens in two phases. Resolution turns a specifier plus the URL of the importing module (the
parent URL) into a resolved URL and a suggested format. Loading then fetches the source for that URL and checks whether the
protocol and the extension are acceptable. Resolution does not decide whether a protocol can be loaded; for example it will happily
resolve to an https: URL, and the load phase refuses it. The default resolver is URL-based, has no default extensions and no
folder mains, looks bare specifiers up through node_modules, and does not fail on unknown extensions or protocols. The default
loader supports node: built-ins, inline data: modules and file: modules, and fails on any other protocol and on unknown file
extensions (it supports only .cjs, .js and .mjs as JavaScript, plus JSON, Wasm and, when enabled, add-ons).
The documentation specifies the algorithm as pseudo-code (ESM_RESOLVE). In prose it is the following sequence, applied in order.
| Step | Condition | Result |
|---|---|---|
First |
The specifier is a valid URL |
Used as parsed and reserialised ( |
Second |
It starts with |
URL resolution relative to the parent URL. |
Third |
It starts with |
|
Fourth |
Anything else (a bare specifier) |
|
Then, for |
Always |
Percent-encoded |
PACKAGE_RESOLVE handles bare specifiers: an empty specifier is invalid; a built-in module name returns node: plus the name; the
package name is the part before the first / (two segments for a scope such as @scope/pkg); a package may then self-reference
through its own "name" and "exports"; otherwise the lookup walks up from the parent URL through node_modules/<name> folders
until the file system root. In the first folder that exists, if the package’s package.json has "exports" it alone decides
(PACKAGE_EXPORTS_RESOLVE); without "exports" the "main" field (or the bare path) is used for the package root and the subpath
is taken literally. If nothing is found the result is ERR_MODULE_NOT_FOUND. The "exports" and "imports" matching is explained
in package.json, exports & imports; the points that matter here are these:
-
An exact key wins over a pattern; among keys with one
*, the longest prefix (and then the longest key) wins. -
Targets in
"exports"must start with./, and may not contain..,.ornode_modulessegments;"imports"targets may also be bare specifiers, which are resolved as packages. -
Conditions are matched in the object’s key order, and
"default"matches always; the default condition list in the documented algorithm is["node", "import"]. Thepackagespage also listsmodule-syncandnode-addonsas conditions implemented in core, and more can be enabled with--conditions/-C. -
A target that is
null, or a path that is not exported, producesERR_PACKAGE_PATH_NOT_EXPORTED: exported packages are encapsulated, so internals cannot be imported with a deep path, except by absolute URL.
ESM_FILE_FORMAT decides how the file is parsed from its URL: .mjs is module, .cjs is commonjs, .json is json, .wasm
is wasm, .node is addon when add-on imports are enabled; for .js the nearest "type" wins, otherwise syntax detection runs
(module if the source has ES module syntax, otherwise commonjs); an extensionless file follows the package type or detection too;
any other extension gives an undefined format, and the load phase throws ERR_UNKNOWN_FILE_EXTENSION.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
async function attempt(label, specifier) {
try {
const ns = await import(specifier);
console.log(label.padEnd(24), '->', ns.kind ?? typeof ns.join);
} catch (error) {
console.log(label.padEnd(24), '!!', error.code);
}
}
await attempt('relative + extension', './src/util.js');
await attempt('relative, no extension', './src/util');
await attempt('folder', './src/folder');
await attempt('self-reference', 'bookshelf-resolve/util');
await attempt('imports: #db', '#db');
await attempt('imports: pattern', '#lib/format.js');
await attempt('imports: undefined', '#nope');
await attempt('package "main"', 'legacy-lib');
await attempt('no exports: file', 'legacy-lib/lib/extra.js');
await attempt('no exports: no ext', 'legacy-lib/lib/extra');
await attempt('exports: import cond.', 'modern-lib');
await attempt('exports: pattern', 'modern-lib/feature/a');
await attempt('exports: not listed', 'modern-lib/internal/secret.js');
await attempt('built-in', 'node:path');
await attempt('missing package', 'no-such-package');
await attempt('https URL', 'https://example.com/x.js');
console.log('require(): "require" cond.', require('modern-lib').kind);
console.log('require(): adds extension', require('./src/util.js') === require('./src/util'));
console.log('require(): folder index', require('./src/folder').kind);
The scratch package had these pieces: the root package.json (type module, "exports" with . and ./util, "imports" with a
conditional #db that maps node to ./src/db-node.js and default to ./src/db-browser.js, and a #lib/.js pattern); a
legacy-lib with only "main"; and a modern-lib with an "exports" map of conditions (import, require, default), a ./feature/
pattern and no entry for its internal/ folder. The output was identical on Node.js 26, 24 and 22:
relative + extension -> util
relative, no extension !! ERR_MODULE_NOT_FOUND
folder !! ERR_UNSUPPORTED_DIR_IMPORT
self-reference -> util
imports: #db -> db-node
imports: pattern -> lib/format
imports: undefined !! ERR_PACKAGE_IMPORT_NOT_DEFINED
package "main" -> legacy main
no exports: file -> legacy extra
no exports: no ext !! ERR_MODULE_NOT_FOUND
exports: import cond. -> modern esm
exports: pattern -> feature a
exports: not listed !! ERR_PACKAGE_PATH_NOT_EXPORTED
built-in -> function
missing package !! ERR_MODULE_NOT_FOUND
https URL !! ERR_UNSUPPORTED_ESM_URL_SCHEME
require(): "require" cond. modern cjs
require(): adds extension true
require(): folder index folder index
The last three lines show the contrast with CommonJS resolution: require() picks the "require" condition (and so loads a different
file of the same package, the root of the dual package hazard discussed in the packages page), adds extensions, and treats a folder
as a module.
The resolver can be changed from outside. Module customization hooks (the successor of the old "loaders") can implement CommonJS-style
extension resolution, load other formats, or rewrite sources; the supported interface is module.registerHooks()
(Stability 1.2, synchronous, in-thread and working for require() too), whereas module.register() with off-thread async hooks has
been a runtime deprecation (DEP0205) since v26.0.0. Details are in
ESM & CommonJS Interop.
Error codes at a glance
| Code or symptom | Typical cause | Fix |
|---|---|---|
|
Missing extension, missing package or wrong path. |
Spell the full path; install the package; check the |
|
Importing a folder. |
Import |
|
Importing a file type Node.js does not load (for example |
Use the right attribute or flag, or read the file explicitly. |
|
The package encapsulates the subpath. |
Use an exported path; ask the package to export it. |
|
A |
Add it to the nearest |
|
JSON imported without |
Add or correct the attribute. |
|
|
Download the file, or write a customization hook. |
|
The file was treated as CommonJS (detection disabled, or an explicit CommonJS marker). |
Use |
|
CommonJS code in a file that is an ES module because of |
Rename the file to |
Exit status 13 |
An unsettled top-level |
Make sure every awaited promise settles. |
Source: Node.js API: Errors
What changed recently
| Version | Change |
|---|---|
v26.5.0 |
Text modules added behind |
v26.0.0 |
|
v25.4.0 (and v24.13.1 for hooks) |
|
v24.5.0 and v22.19.0 |
Wasm modules no longer need |
v24.2.0 and v22.18.0 |
|
v24.0.0 and v22.16.0 |
|
v23.1.0, v22.12.0, v20.18.3 and v18.20.5 |
Import attributes and JSON modules are no longer experimental. |
v22.12.0 and v23.0.0 |
|
v22.7.0 and v20.19.0 |
Syntax detection enabled by default. |
v22.0.0 |
Support for the import assertions syntax ( |
v21.2.0 and v20.11.0 |
|
v20.6.0 and v18.19.0 |
|
v20.0.0 and v18.19.0 |
|
v14.8.0 |
Top-level await unflagged. |
v13.2.0, v12.17.0, v15.3.0 |
ES modules no longer need a flag; the implementation was stabilised in v15.3.0. |
The version-by-version picture of the whole platform is in Upgrading Node.js & What Changed, and the release lines in Release Lines & LTS.
Where the books are outdated
Efficient Node.js (Samer Buna, O’Reilly 2025) gives a sound introduction to the two loaders, .mjs and the "type" field, but several
points have moved on. It treats mixing the formats as something to be careful about because one is synchronous and the other asynchronous;
require() can now load synchronous ES modules (flag-free since v22.12.0, stable since v25.4.0), leaving only top-level await as
an obstacle. Its description of how files become modules mentions only the extension and the "type" field, whereas syntax
detection (default since v22.7.0 and v20.19.0) now runs ambiguous files containing import or export as ES modules, at the cost of a
warning when a package.json without "type" exists. For the "am I the entry point" check it points to a third-party package; since
v24.2.0 and v22.18.0 there is import.meta.main (still Stability 1.0). It says that import.meta.resolve() throws when the
module does not exist, which stopped being true for file URLs in v20.6.0 and v18.19.0 (a missing bare package or a non-exported
subpath still throws). Its NODE_PATH explanation applies to CommonJS only, which is also what the ESM differences table above says.
Node.js Projects (Jonathan Wexler, O’Reilly 2025) tells readers to add "type": "module" for import syntax and to use .mjs for individual
files, and says the ES module support was stabilised in v13.2.0 (that version only removed the flag; the stabilisation is documented in
v15.3.0, v14.17.0 and v12.22.0), and that import syntax is not enabled by default (syntax detection has changed this for
files without markers). Its remarks that top-level await arrives with Node 18 understate history: it was unflagged in v14.8.0.
Neither book covers import attributes and JSON modules, text modules or Wasm integration, nor the removal of the assert
keyword that older articles still show; code copied from such sources fails with a syntax error on every supported line.
References
-
Node.js API: Modules: ECMAScript modules, https://nodejs.org/docs/latest/api/esm.html
-
Node.js API: Modules: Packages, https://nodejs.org/docs/latest/api/packages.html
-
Node.js API: Modules:
node:moduleAPI, https://nodejs.org/docs/latest/api/module.html -
Node.js API: Modules: CommonJS modules, https://nodejs.org/docs/latest/api/modules.html
-
Node.js API: Modules: TypeScript, https://nodejs.org/docs/latest/api/typescript.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Errors, https://nodejs.org/docs/latest/api/errors.html
-
Node.js API: Deprecated APIs (DEP0205), https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: Process (exit codes), https://nodejs.org/docs/latest/api/process.html
-
Node.js API: URL (
url.pathToFileURL(),url.fileURLToPath()), https://nodejs.org/docs/latest/api/url.html -
Node.js Learn: Running TypeScript natively, https://nodejs.org/learn/typescript/run-natively
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/