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

Table 1. Features covered on this page and when they became usable without flags
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.

import.meta.url

Stable

Defined as in browsers.

import.meta.dirname, import.meta.filename

Stable

Added in v21.2.0 and v20.11.0; no longer experimental in v24.0.0 and v22.16.0.

import.meta.main

1.0 Early development

Added in v24.2.0 and v22.18.0 (not in earlier 22 releases).

import.meta.resolve()

1.2 Release candidate

Synchronous since v20.0.0 and v18.19.0; the non-standard second argument still needs --experimental-import-meta-resolve.

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 (with { type: 'json' })

Stable

assert syntax dropped in v22.0.0; attributes no longer experimental in v23.1.0, v22.12.0, v20.18.3 and v18.20.5.

JSON modules

Stable

No longer experimental in v23.1.0, v22.12.0, v20.18.3 and v18.20.5.

Text modules (with { type: 'text' })

1.0 Early development

Added in v26.5.0 behind --experimental-import-text; the 24 documentation lists it since v24.19.0; not available on Node.js 22.

Wasm source phase imports (import source)

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 --experimental-wasm-modules flag since v24.5.0 and v22.19.0; still prints an ExperimentalWarning.

require() of an ES module

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.

A module with named and default exports, and a module that consumes them (lib/book.js and main.js)
// 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.

Table 2. The three kinds of import specifier
Kind Examples Rule

Relative

./startup.js, ../config.mjs

Resolved against the URL of the importing file with standard URL resolution. The file extension is always required.

Bare

some-package, some-package/shuffle, path

Resolved by the package algorithm: built-in module names first, then node_modules lookup. An extension is only needed for packages without an "exports" field.

Absolute

file:///opt/nodejs/config.js, node:path, data:text/javascript,…​

A full URL. file:, node: and data: are supported; https: is not supported natively.

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.

Omitting the extension, and importing a directory
// 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.

A query string makes a separate module instance; the same URL is cached
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.

Table 3. URL schemes accepted by import
Scheme Behaviour

file:

The normal case. Relative and absolute specifiers become file: URLs. import.meta.url is a file: URL.

node:

Built-in modules, for example node:fs/promises. Available since v14.13.1 and v12.20.0 for import, and in require() since v16.0.0 and v14.18.0.

data:

Inline modules, added in v12.10.0. MIME types text/javascript (ES modules), application/json and application/wasm. Only builtin and absolute specifiers resolve inside, because data: has no relative resolution.

https: and others

Fail with ERR_UNSUPPORTED_ESM_URL_SCHEME; a custom loader is needed to import over HTTP.

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

Table 4. How a file is classified (rows are the explicit markers)
Marker Interpreted as Notes

.mjs

ES module

Always, whatever the nearest package.json says.

.cjs

CommonJS

Always, whatever the nearest package.json says.

.js and "type": "module"

ES module

The field applies to entry points and to files reached by import and import().

.js and "type": "commonjs"

CommonJS

Explicit, so no detection and no performance cost.

.js and no "type", or no package.json

Ambiguous

CommonJS first; see Syntax detection.

--input-type=module or commonjs

Same as the value

Only for --eval, --print or standard input; combining it with a file raises ERR_INPUT_TYPE_NOT_ALLOWED. Also accepts module-typescript and commonjs-typescript since v23.6.0 and v22.18.0.

.ts, .mts, .cts

TypeScript text

With type stripping; .mts is always ES module and .cts always CommonJS, like .mjs and .cjs.

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.

A probe file in three packages, saved with three extensions
// 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:

  • import statements (but not import() expressions, which are valid in CommonJS),

  • export statements,

  • import.meta references,

  • await at the top level of a module, and

  • top-level lexical declarations (const, let, class) of a CommonJS wrapper variable: require, module, exports, filename or dirname.

Four ambiguous files, each running as an ES module thanks to detection
// 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:

Detection for --eval and standard input, and the explicit flag
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:

Table 5. The import.meta properties
Property Stability Meaning

import.meta.url

Stable

The absolute file: URL of the module, defined as in browsers.

import.meta.filename

Stable

The absolute path of the file with symlinks resolved; equals url.fileURLToPath(import.meta.url). Only present for file: modules.

import.meta.dirname

Stable

The directory of the module; equals path.dirname(import.meta.filename). Only present for file: modules.

import.meta.main

1.0 Early development

true when the module is the entry point of the process, false otherwise; the equivalent of require.main === module.

import.meta.resolve(specifier)

1.2 Release candidate

Returns the URL string a specifier would resolve to from this module, without loading it.

Everything import.meta offers in one module (meta.js)
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 to fs, which is anchored to process.cwd().

  • When the same module is imported by another file instead of being the entry point, import.meta.main is false (checked with an importer.js that only imports meta.js). Inside an inline data: module import.meta.dirname and import.meta.filename are undefined, import.meta.url starts with data: and import.meta.main is false.

  • import.meta.filename follows symlinks: for a symlink linkmeta.js pointing at meta.js, url and filename both report meta.js; with --preserve-symlinks-main they report linkmeta.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 you import it.

  • It can perform synchronous file system operations like require.resolve(), and it is not available inside custom loaders (it would deadlock). The non-standard second parent argument 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.

Run something only when the file is the entry point
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.

Top-level await in a catalogue module, and dynamic import() of a plugin chosen at run time
// 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).

A never-resolving top-level await (never.mjs)
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.

A JSON module, imported statically and dynamically, and from a data: URL
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:

Mistakes with import attributes
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).

Importing a text file (text.js)
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 a WebAssembly.Module that is not yet instantiated, so you can instantiate it several times with different import objects. The dynamic variant is import.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().

Generating two minimal Wasm files (make-wasm.js)
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
));
Importing the Wasm files as instances (instance.js, with offset.js exporting offset() that returns 100)
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.

Source phase imports: one compiled module, several instances
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.

Table 6. CommonJS facilities that do not exist in ES modules
CommonJS In an ES module Replacement

require(), module, exports

Not defined (typeof gives 'undefined')

import / export; module.createRequire(import.meta.url) when a require function is needed.

filename, dirname

Not defined

import.meta.filename, import.meta.dirname, or new URL('.', import.meta.url).

require.main === module

Not available

import.meta.main.

require.resolve()

Not available

import.meta.resolve() (URL string), or createRequire().

require.cache

Unused; the ES loader has its own separate cache

None; see Evaluation order, caching and cycles.

require.extensions

Unused

Customization hooks.

NODE_PATH

Not consulted when resolving import specifiers

Symlinks, or "imports" and "exports" in package.json.

Loading native add-ons

Not supported in the ES module reference page

createRequire() or process.dlopen(). The CLI reference also lists --experimental-addon-modules (enabled by default since v26.5.0, Stability 1.2) for importing .node files.

Extension and folder search

None

Spell the full path; see Specifiers and mandatory file extensions.

Top-level this

undefined

Use globalThis.

Proving the table, and the replacements
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.

Named-export detection, and what it cannot see
// 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.

Named exports of a built-in follow the default object only after syncBuiltinESMExports()
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.

Single evaluation, and a two-module cycle (counter.js, once.js, a.js and b.js)
// 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.

Table 7. ESM_RESOLVE(specifier, parentURL)
Step Condition Result

First

The specifier is a valid URL

Used as parsed and reserialised (file:, node:, data:…​).

Second

It starts with /, ./ or ../

URL resolution relative to the parent URL.

Third

It starts with #

PACKAGE_IMPORTS_RESOLVE: the nearest package.json "imports" map, with the default conditions. # alone is an invalid specifier, and an unmatched key throws a "Package Import Not Defined" error (ERR_PACKAGE_IMPORT_NOT_DEFINED).

Fourth

Anything else (a bare specifier)

PACKAGE_RESOLVE, described below.

Then, for file: results

Always

Percent-encoded / or \ (%2F, %5C) is an error; a directory is ERR_UNSUPPORTED_DIR_IMPORT; a missing file is ERR_MODULE_NOT_FOUND; otherwise the real path is taken (symlinks resolved) and the format is computed by ESM_FILE_FORMAT.

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 .., . or node_modules segments; "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"]. The packages page also lists module-sync and node-addons as conditions implemented in core, and more can be enabled with --conditions/-C.

  • A target that is null, or a path that is not exported, produces ERR_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.

Resolution outcomes in a scratch package with every kind of specifier (resolve.js)
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

Table 8. Errors you will meet most often with ES modules
Code or symptom Typical cause Fix

ERR_MODULE_NOT_FOUND

Missing extension, missing package or wrong path.

Spell the full path; install the package; check the "exports" map.

ERR_UNSUPPORTED_DIR_IMPORT

Importing a folder.

Import ./folder/index.js, or expose a subpath in "exports".

ERR_UNKNOWN_FILE_EXTENSION

Importing a file type Node.js does not load (for example .txt without the text flag).

Use the right attribute or flag, or read the file explicitly.

ERR_PACKAGE_PATH_NOT_EXPORTED

The package encapsulates the subpath.

Use an exported path; ask the package to export it.

ERR_PACKAGE_IMPORT_NOT_DEFINED

A #alias that is not in "imports".

Add it to the nearest package.json.

ERR_IMPORT_ATTRIBUTE_MISSING, ERR_IMPORT_ATTRIBUTE_UNSUPPORTED, ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE

JSON imported without with { type: 'json' }, an unknown attribute, or a type that does not match the file.

Add or correct the attribute.

ERR_UNSUPPORTED_ESM_URL_SCHEME

https: or another unsupported scheme.

Download the file, or write a customization hook.

SyntaxError: Cannot use import statement outside a module

The file was treated as CommonJS (detection disabled, or an explicit CommonJS marker).

Use .mjs or "type": "module".

ReferenceError: module is not defined in ES module scope

CommonJS code in a file that is an ES module because of "type": "module".

Rename the file to .cjs.

Exit status 13

An unsettled top-level await.

Make sure every awaited promise settles.

What changed recently

Table 9. Notable changes to ES module support, newest first
Version Change

v26.5.0

Text modules added behind --experimental-import-text (the 24 line lists v24.19.0); --experimental-addon-modules (importing .node files) became enabled by default.

v26.0.0

module.register() is a runtime deprecation (DEP0205); documentation-only since v25.9.0. Use module.registerHooks().

v25.4.0 (and v24.13.1 for hooks)

require() of ES modules is no longer experimental (v25.4.0); synchronous in-thread customization hooks (module.registerHooks()) are release candidate (v25.4.0 and v24.13.1).

v24.5.0 and v22.19.0

Wasm modules no longer need --experimental-wasm-modules; the JavaScript String Builtins proposal is on and wasm: and wasm-js: names are reserved.

v24.2.0 and v22.18.0

import.meta.main added (Stability 1.0).

v24.0.0 and v22.16.0

import.meta.dirname and import.meta.filename are no longer experimental. Wasm source phase imports added in v24.0.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

require() of synchronous ES modules works without a flag (v23.0.0 and v22.12.0), and the 'module.exports' interop export name is supported in require(esm); CommonJS namespaces got the same marker in v23.0.0.

v22.7.0 and v20.19.0

Syntax detection enabled by default.

v22.0.0

Support for the import assertions syntax (assert) dropped.

v21.2.0 and v20.11.0

import.meta.dirname and import.meta.filename added.

v20.6.0 and v18.19.0

import.meta.resolve() available by default and no longer throws for file: URLs that do not map to an existing file.

v20.0.0 and v18.19.0

import.meta.resolve() returns a string synchronously instead of a promise; customization hooks run off the main thread.

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