Get E-Book
The Module System

Node.js ES Modules: import/export & Linking

Ishtmeet Singh @ishtms/February 22, 2026/30 min read
#nodejs#modules#ESM#import#export#static-analysis

ES Modules in Node.js are built around static module records, not just around different syntax. Before a module body runs, Node has already determined the module format, resolved its static imports, linked the dependency graph, and prepared the bindings that imports and exports will use. Format detection begins with file extensions, the nearest package.json type field, and explicit input mode; after that, the loader can move through resolution, linking, and evaluation in a predictable order.

ES Modules

The first signal is usually the file itself. .mjs is ESM, .cjs is CommonJS, and .js follows the package scope it lives under. Once a file is treated as ESM, its imports are resolved before evaluation, its exports are live bindings, and top-level await can delay modules that depend on it. Dynamic import() enters the same ESM loader at runtime and returns a promise for the module namespace object.

Format Detection

Every load starts with a format decision: CommonJS or ESM. That decision controls the parser rules, the scope variables available to the file, and the shape of the module's exports. When a file gives Node an explicit marker, Node can choose the loader before parsing. When input is ambiguous in Node v24, syntax detection can parse enough source text to look for ESM-only syntax before choosing the format.

The priority order is mostly mechanical, with syntax detection reserved for ambiguous cases.

File extension wins first. A .mjs file is always ESM, and a .cjs file is always CommonJS. There is no package configuration involved in that choice. If a file ends in .mjs, Node sends it through the ES module loader regardless of any package.json settings around it.

For .js files, Node walks upward from the file's directory looking for the nearest package.json. If that file contains "type": "module", .js files in that package scope load as ESM. If it contains "type": "commonjs" or has no type field, they load as CommonJS. The default remains CommonJS.

json
{
  "name": "my-app",
  "type": "module"
}

That single field flips every .js file in the package scope to ESM. A nested directory can change the rule by adding its own package.json with a different type, because Node uses the nearest package scope rather than the repository root by default.

String input has its own explicit marker: --input-type=module for --eval, -e, or STDIN. Around that explicit path, Node also has syntax detection. It was added in v21.1 and v20.10, enabled by default in v22.7 and v20.19, and can still be controlled with --experimental-detect-module and --no-experimental-detect-module. Detection looks for ESM-only syntax such as static import, static export, import.meta, top-level await, or lexical redeclarations of CommonJS wrapper names. If it finds that syntax in otherwise ambiguous input, Node treats the file as ESM.

That fallback is useful for compatibility, but it is a poor package scope. Declare the module type instead of making readers and tools infer it from syntax.

Static Analysis

CommonJS discovers dependencies while the program runs. require() is an ordinary function call, so it can sit inside an if block, build its path with string concatenation, or run inside a loop. Until execution reaches that call, the runtime does not know which module will be loaded.

ESM moves that dependency information into declarations. import and export are parsed before evaluation, so the engine can read the source text, extract the module relationships, and build the dependency graph before any JavaScript in the module body executes. The specifier, the string after from, must therefore be a literal string.

js
const mod = require(condition ? './a.js' : './b.js');

That is valid CommonJS. The equivalent static ESM form is a syntax error:

js
import something from (condition ? './a.js' : './b.js');

The second line fails before condition could possibly be evaluated. At parse time there is no runtime value to inspect, so the parser requires a specifier it can extract directly from the source.

That constraint is what gives the host enough information to build and validate the graph ahead of evaluation. With the full graph known, the engine can detect cycles, allocate bindings, validate imported names, and prepare optimization opportunities before user code starts. CommonJS loading is a runtime discovery process; ESM loading begins as a static declaration.

The same property is what makes ESM friendlier to tools. Bundlers, tree-shakers, type checkers, and linters can analyze an import graph without running it. If a module exports foo and nothing imports foo, a bundler can often prove that the binding is unused and remove it. That proof is much harder with CommonJS, because any module may call require() during execution, and the path may be hidden behind a branch the tool cannot evaluate.

Errors also move earlier. If a module imports a name that the target module does not export, the engine reports the problem during linking, before the application logic runs. The equivalent CommonJS mistake often becomes an undefined value that fails later, after the broken value has already traveled through the program.

The Three-Phase Loading Pipeline

ESM loading in Node follows three phases: parsing, instantiation, and evaluation. Keeping those phases separate explains why missing exports fail early, why imports are live, how many circular imports can be represented at all, and why top-level await can pause dependent modules.

Three-panel line diagram of ES module graph discovery, binding wiring, and evaluation order.

Figure 3.1 — ESM loading separates graph discovery from binding wiring and evaluation. The loader can validate imported names during linking before any module body runs.

Parsing

Node first reads the source text using ES module grammar. This is where static analysis happens. The parser records the module's import declarations and export declarations, but it does not execute the module, resolve imported values, or initialize exported variables. It only records which names are imported from which specifiers and which names the module promises to export.

Each recorded import then leads Node to resolve the specifier to a URL, load that dependency's source, and parse it as well. The process is recursive. If a.js imports b.js, and b.js imports c.js, all three modules are fetched and parsed before any of their top-level code runs.

The result of this phase is a module graph: a directed graph whose nodes are parsed modules and whose edges are import relationships. At this point every module in the graph has been discovered and parsed, but none has been evaluated.

Instantiation

Instantiation turns that parsed graph into binding infrastructure. For each module, the engine allocates slots for the exports declared by that module. Those slots exist before they contain values; they are initially uninitialized.

The engine then connects every import to the corresponding export slot. If module A imports count from module B, A's local count binding is wired to B's exported count slot. They are not two variables holding copied values. They are references to the same binding.

No module body has run yet, so the values are still missing. The wiring, however, is complete across the graph. This is also where validation becomes possible. If an importer asks for a name the target module does not export, or if a re-export points at a missing name, the engine throws a SyntaxError at link time, before evaluation.

js
// b.js
export let count = 0;

// a.js
import { count } from './b.js';

After instantiation, a.js's count refers to the same binding slot as b.js's exported count. The slot will receive its value during evaluation, but the relationship already exists.

Evaluation

Evaluation is the first phase that runs module bodies. The engine works through the dependency graph so dependencies evaluate before the modules that depend on them. Leaf modules, the ones with no further imports, run first. Their top-level code initializes their export bindings with real values.

Once those dependencies have evaluated, their dependents can run, and then the dependents above them. By the time a module's body starts, the modules it directly depends on have already evaluated unless top-level await has introduced an asynchronous pause.

Top-level await pauses evaluation of the current module and of everything waiting on it. If b.js contains a top-level await, and a.js imports from b.js, then a.js will not start evaluating until b.js's awaited promise settles.

Each module evaluates exactly once for a given cache identity. Later imports receive the cached module namespace, backed by the same binding slots and whatever values those live bindings currently hold.

This separation has a startup cost that CommonJS does not pay in the same way. CommonJS loads depth-first as require() calls execute, compiling and running each file as it is encountered. ESM must discover and link the graph before running module bodies. For a deep dependency tree, that can mean more file reads and parses before the first module body runs. The tradeoff is that names are validated, cycles are represented, and dependencies are resolved before application code starts executing.

Import Syntax

Named Imports

Named imports bind specific exported names from another module.

js
import { readFile, writeFile } from 'node:fs/promises';
import { EventEmitter } from 'node:events';

The names inside the braces must exist as named exports in the target module. If node:fs/promises does not export readFile, the module fails during linking. When the local code wants a different name, the import can rename the binding:

js
import { readFile as read } from 'node:fs/promises';

read is only a local alias. It still points at the same exported binding as readFile; no value is copied and no extra binding is created in the exporting module.

Default Imports

js
import EventEmitter from 'node:events';

A default import binds the export named default. The local name, EventEmitter here, is chosen by the importer. At the binding level there is no special storage for default exports; default is an export name with dedicated syntax around it.

Namespace Imports

js
import * as fs from 'node:fs/promises';

fs becomes the module namespace object. Its properties expose the module's named exports, so the importer reads them as fs.readFile, fs.writeFile, and so on. The object is sealed and non-extensible, and assignment to export properties is rejected. It is not frozen in the Object.isFrozen() sense because namespace property descriptors report writable: true; the object is an exotic module namespace object whose internal [[Set]] operation rejects writes while reads stay connected to live bindings.

Side-Effect Imports

js
import './setup.js';

This form imports no bindings. The target module is still loaded, instantiated, and evaluated, but nothing is added to the importing module's scope. If the target registers event handlers, initializes instrumentation, installs a polyfill, sets up configuration, or changes global state, those effects happen through evaluation.

Side-effect imports still run the module pipeline. The target is parsed, its own imports are resolved and linked, and its body runs exactly as it would for any other static import. The importing module simply does not bind any of its exports.

Combining Forms

A default import can appear beside named imports in one declaration:

js
import fs, { readFile, writeFile } from 'node:fs';

Here fs is the local name for the default export, while readFile and writeFile bind named exports. This works because the default is still an export named "default" under the syntax.

Export Syntax

Named Exports

A module exports a binding by adding export to the declaration:

js
export const PORT = 3000;
export function startServer() { /* ... */ }
export class Router { /* ... */ }

These declarations create named exports called PORT, startServer, and Router. The module can also declare the bindings first and export them later:

js
const PORT = 3000;
function startServer() { /* ... */ }
export { PORT, startServer };

Both forms expose the same bindings. Export lists can also rename the public name:

js
export { startServer as start };

The local binding is still startServer, but importing modules see an exported name called start.

Default Export

js
export default function createApp() { /* ... */ }

This exports the function as the default binding. A module may have only one default export. The syntax has two important forms: a declaration form, such as export default function createApp() {}, and an expression form, such as export default count. The declaration form exports the function or class binding; anonymous default functions and classes receive the inferred name "default".

The expression form can use any expression. export default 42 and export default { foo: 1, bar: 2 } are both valid because the expression is evaluated and the result becomes the value of the default export.

The sharp edge is liveness. export default with an expression does not create the same live binding behavior as a named export. If the module writes export default count, the default binding receives the current value of count during evaluation. If count later changes, that default export keeps the earlier value. A named export such as export { count } keeps the live reference.

Re-Exports

A module can forward bindings from another module without creating local variables for them:

js
export { readFile, writeFile } from 'node:fs/promises';
export { default as EventEmitter } from 'node:events';

The first line makes readFile and writeFile available from the current module even though they are not local variables in it. The second line takes the default export of node:events and re-exports it as a named export called EventEmitter.

Wildcard re-exports forward named exports in bulk:

js
export * from './utils.js';

All named exports from ./utils.js become named exports of the current module. Default exports are not included in export *, so they must be re-exported explicitly. If two wildcard sources both provide the same name and a consumer tries to import that name, the ambiguity is reported at link time.

This forwarding mechanism is the basis of barrel files, often named index.js, that gather exports from several submodules into one public surface. It is also how packages expose a curated API while keeping their internal file layout private. The binding does not need to pass through an intermediate local variable; V8 can wire the source export directly to the final consumer.

Live Bindings

Live bindings are one of the places where ESM differs most from CommonJS. A CommonJS require() returns the current module.exports value. If that value is an object, the caller receives a reference to the same object held in the CommonJS cache. Stale values usually appear when the caller destructures properties into local variables, not because require() copied the whole export object.

ESM imports avoid that stale-destructuring case for imported bindings. The importer receives a reference to the exporter's binding slot, not a copy of the current value. When the exporting module changes the binding, the importing module observes the new value.

js
// counter.js
export let count = 0;
export function increment() { count++; }
js
// main.js
import { count, increment } from './counter.js';
console.log(count);  // 0
increment();
console.log(count);  // 1

In main.js, count is not an ordinary local variable initialized to 0. It is a live reference to the count binding owned by counter.js. When increment() mutates that binding inside counter.js, the next read in main.js sees the updated value.

The ownership still controls assignment. The importer can read the binding, but it cannot reassign it. Writing count = 5 inside main.js throws a TypeError. Only the module that owns the exported binding may change it.

CommonJS destructuring behaves differently because it creates a local variable from a property value:

js
// CJS equivalent
const { count, increment } = require('./counter.js');
console.log(count);  // 0
increment();
console.log(count);  // still 0 - it's a copy

Here count holds the number that was read from the exports object at destructuring time. increment() can update state inside counter.js, but this local primitive does not update with it.

Line diagram showing an exporter-owned binding slot shared by two importers, with a detached snapshot value separate from the slot.

Figure 3.2 — An imported ESM binding points back to the exporter's slot. When the exporter updates that slot, importers read the new value; a detached snapshot does not.

If the CommonJS caller keeps the exports object, property reads can still observe mutation:

js
const counter = require('./counter.js');
console.log(counter.count);  // 0
counter.increment();
console.log(counter.count);  // 1

The ESM rule is stronger for imported bindings: the binding remains connected to the exporter. That is important in long-running processes such as servers, workers, and daemons, where module-level state may change long after startup.

The same binding model also explains why many circular imports can be represented. If module A imports from module B and module B imports from module A, instantiation creates both sets of export slots and links the imports before either module evaluates. The danger is not the cycle itself; it is reading an uninitialized binding too early. Lexical exports declared with let, const, or class remain in the temporal dead zone until their declarations run. Reading one before initialization throws a ReferenceError. A var export can instead be observed as undefined before its assignment.

js
// b.js
import { a } from './a.js';
console.log(a); // ReferenceError if a is a lexical export not initialized yet

The binding exists, but the value may not. Circular imports survive when modules avoid touching each other's uninitialized exports during evaluation.

There is one more subtle consequence. Live bindings apply naturally to mutable let and var exports. A const export is still live at the binding level because importers point to the exporter's slot, but the binding cannot be reassigned after initialization, so the value never changes. If the const value is an object, the reference to the object is fixed while the object's properties can still mutate. Those property mutations are visible through the live binding because every importer reads the same object reference.

The import() Expression

Static imports cover the usual case, but they cannot be conditional, lazy, or computed from runtime state. The import() expression exists for those cases.

js
const mod = await import('./heavy-module.js');
mod.doSomething();

import() looks like a function call, but it is a function-like syntax form rather than a normal function. It cannot be aliased with const myImport = import, bound with .bind(), or passed around as a callback. It accepts a specifier expression and returns a promise that resolves to the module namespace object. Because the specifier is an expression, it can be a variable or a computed string.

js
const lang = getUserLanguage();
const i18n = await import(`./locales/${lang}.js`);

That computed specifier is valid because import() runs at evaluation time. It is the ESM path for conditional loading and for deferring work until a module is actually needed. Static import declarations cannot express that shape.

The expression works in both ESM and CommonJS contexts. A CommonJS file can use import() to load an ES module, which makes it one of the interop bridges between the two module systems. The promise resolves to a namespace object containing named exports as properties and a default property for the default export.

If the target module has not been loaded, import() starts the same resolution, linking, and evaluation path used by static imports. If the target is already cached under the same resolved URL, the promise resolves to the cached namespace object. The module is not evaluated again.

The promise return value introduces an asynchronous handoff. CommonJS require() is synchronous: the module is loaded, compiled, and executed before require() returns. import() starts the ESM path and delivers the result later. Code that needs exports to establish synchronous startup state must be structured around that async gap, which is one reason some initialization paths still use require() even in codebases that otherwise prefer ESM.

URL-Based Module Resolution

ESM in Node uses URLs internally. Relative and absolute file specifiers resolve to file: URLs. Bare package specifiers usually become file: URLs after package resolution. Built-ins use node: URLs, and Node also supports data: modules. This differs from CommonJS, which works primarily with resolved filesystem paths.

js
import { something } from './utils.js';
// Resolves to: file:///Users/dev/project/utils.js

For ordinary imports, the URL detail is mostly invisible. The important consequence is that the cache key is the full URL, including query strings and fragments.

js
import a from './module.js?v=1';
import b from './module.js?v=2';
// a and b are different module instances

Those two imports evaluate the same source file as two separate module instances. That can be useful for cache-busting in development or tests, but it can also produce accidental duplicate state when query strings or fragments differ.

Bare specifiers, names without ./, ../, or /, still go through Node's package resolution rules. Node checks package metadata and node_modules; for file-backed packages, the final result is a file: URL, and that URL becomes the identity used for caching and comparison.

The URL model also includes inline modules:

js
import { name } from 'data:text/javascript,export const name="inline"';

A data: URL creates a module from the URL contents. It is mostly useful in tests and edge-case tooling, but it follows the same URL-based model. Built-in modules use another special scheme, node:, as in import fs from 'node:fs'; those specifiers bypass filesystem resolution and load from Node's compiled-in module set.

Mandatory File Extensions

Relative ESM imports must name the file explicitly, including its extension.

js
// Works in CJS
const utils = require('./utils');

// Fails in ESM
import utils from './utils';
// ERR_MODULE_NOT_FOUND

// Must be explicit
import utils from './utils.js';

CommonJS probes for .js, .json, and .node automatically. ESM does not. The explicit extension matches browser ESM behavior, where module specifiers are URLs and URLs do not search for alternate extensions.

For node: built-ins and bare specifiers resolved through packages, the package's exports field controls the file target. For relative and absolute specifiers, the importing source writes the extension itself.

Directory imports follow the same explicitness. import './utils/' does not mean import './utils/index.js' in ESM, and commonly fails with ERR_UNSUPPORTED_DIR_IMPORT. CommonJS performs that directory-to-index lookup; ESM requires the path to the file. Older Node releases had experimental escape hatches around specifier resolution, but current Node v24 does not restore extension and directory probing for this path. Write explicit paths.

Implementation in Node

The ESM loader lives under lib/internal/modules/esm/ in the Node source tree. These files are not public API, and internal names move between releases, but the useful shape in Node v24 is stable enough to reason about. A JavaScript-side loader resolves and loads sources, ModuleJob tracks each module's lifecycle, and ModuleWrap bridges into V8's module implementation.

ModuleLoader

In Node v24, loader.js defines ModuleLoader; older class names still appear in stale articles and comments. ModuleLoader coordinates loading through resolution and loading hooks. When a module writes import { foo } from './bar.js', the loader resolves './bar.js' to an absolute URL, loads the source for that URL while determining its format, and creates or retrieves a ModuleJob for the resolved URL.

The format may be module, commonjs, json, wasm, or builtin. Node does not expose this machinery as one public "module map." In the v24 source, ModuleLoader keeps a resolve cache and a load cache. The user-visible contract is that once a module has been resolved and a job exists for its URL and import attributes, later imports can reuse that job instead of starting again.

Custom loader hooks enter at this layer as well. The CLI spelling is --loader or --experimental-loader, and programmatic hooks come from node:module. Async hooks registered with module.register() run on a separate loader thread. Synchronous hooks registered with module.registerHooks() run in the same thread. That difference is important when hook code expects shared state, thread-local behavior, or synchronous access to application objects.

ModuleJob

Each module in the graph gets a ModuleJob instance, implemented in module_job.js. The job manages one module's progress through parsing, linking, and evaluation. It holds the ModuleWrap, tracks dependencies, and coordinates the work needed before dependents can continue.

When Node creates a ModuleJob, it begins resolving that module's dependencies. For each static import in the source, the job asks the loader for the dependency's job, which may trigger resolution and loading of that dependency. The resulting graph of jobs mirrors the import graph parsed from source.

After the graph is ready, the job drives linking and evaluation. It instantiates the underlying ModuleWrap, which triggers V8's linking phase, and then evaluates the module. If the module or one of its dependencies uses top-level await, evaluation is asynchronous from the job's perspective, and dependents wait for the resulting promise to settle.

ModuleWrap

ModuleWrap is the bridge between Node's JavaScript-side loader and V8's C++ module API. It wraps V8's internal representation of an ES module.

When Node creates a ModuleWrap from JavaScript source, V8 compiles the text as a module, extracts the import and export declarations, and creates a module record that has not yet been instantiated. Node can ask V8 for the module requests, resolve them using Node's loader rules, and then provide the matching modules back to V8 during instantiation.

Instantiation asks V8 to link the graph. V8 walks the module records and calls a host-provided resolve callback for each import. Node's callback supplies the matching ModuleWrap for the dependency. V8 then creates the internal binding infrastructure for exports and cross-module import references. Those allocations live in V8-managed state, not as ordinary properties on the namespace object.

Only after that does evaluation run the module's code. Module code differs from script code in its top-level environment: it always runs in strict mode, has its own module scope, and stores exports as module bindings rather than as object properties.

Inside the engine, evaluation is promise-shaped. For modules without top-level await, the promise is already resolved by the time control returns to Node. For modules with top-level await, it remains pending until the awaited operation completes. ModuleJob uses that result to determine when dependent modules may continue.

The Module Graph and Cycle Detection

V8 handles cycle detection during instantiation. When it encounters a cycle, such as module A importing B, B importing C, and C importing A, it does not recurse forever. It marks each module's instantiation status and skips modules already being instantiated. The cyclic module's exports are allocated, though some may still be uninitialized.

During evaluation, modules in a cycle form a strongly connected component and run according to the spec's cyclic module record algorithm. The first module that touches a dependency's export may observe an uninitialized binding. For lexical exports, that read throws a temporal-dead-zone ReferenceError; for var exports, the binding exists with undefined before assignment.

This is why circular imports can avoid an infinite load loop while still failing at runtime. Instantiation creates the binding, but evaluation has to initialize it before other modules can safely read its value.

Host-Created Modules

Most application modules begin as JavaScript source text. JSON modules have a different public contract: Node parses the JSON and exposes a module with a single default export containing the parsed value.

The loader decides that from the loaded format. If the format is JavaScript module source, V8 parses the source as a module. If the format is JSON, Node does not expose named exports for each JSON property. It creates one default binding for the parsed JSON value. The exact internal representation is less important than the visible rule: JSON imports are default-only and require an import attribute in current Node.js releases.

Custom Loader Hooks

Async customization hooks registered through module.register() run away from the application thread. That isolation prevents hook code from directly sharing application globals and avoids reentrancy problems during module loading. Data sent to those hooks must cross a structured-clone handoff, so plain configuration is safer than functions or class instances.

Synchronous hooks registered with module.registerHooks() have a different execution model. They run in-thread and are meant for cases where synchronous resolution or loading is required. The two APIs should not be collapsed into one mental model, because their observable constraints differ.

Without custom hooks, the built-in resolve and load logic stays on Node's normal loader path. Custom hooks put user code in the middle of resolution, so they should stay small, deterministic, and explicit about configuration.

Cache Identity

The cache identity is URL-based. When the loader sees an import specifier, it resolves it to a URL and checks its caches. A hit means the module is already somewhere in the lifecycle: maybe loading, maybe linked, maybe fully evaluated. In each case, Node can reuse the existing job or namespace instead of evaluating the module again.

This has a direct consequence for import(). If two places call import('./foo.js'), the second call receives the same module instance as the first. The module evaluates once, and both callers receive the same namespace object with the same live bindings. The important difference from CommonJS is identity: CommonJS caches by resolved file path, while ESM caches by resolved URL. Because URLs include query strings and fragments, import('./foo.js') and import('./foo.js?bust') are different cache entries and produce different module instances from the same source file.

Module Namespace Object

When a module writes import * as ns from './module.js', ns is the module namespace object. It is created during instantiation, after the engine knows the module's exports and can expose them through one object-shaped view.

The namespace object's properties correspond to named exports. The object is sealed, so Object.isSealed(ns) returns true, and it is non-extensible. Properties cannot be added, deleted, or reconfigured. Object.isFrozen(ns) returns false in Node because namespace property descriptors report writable: true, but assignment still fails. The object has special module-namespace behavior: reading ns.count reads the current export binding, while writing ns.count = 5 is rejected.

js
import * as counter from './counter.js';
console.log(counter.count);          // 0
counter.increment();
console.log(counter.count);          // 1
console.log(Object.keys(counter));   // ['count', 'increment']

A default export appears as a property named "default" on the namespace object. counter.default therefore works, although most code imports defaults with default import syntax instead.

The namespace object also has a null prototype, so Object.getPrototypeOf(ns) returns null. Its Symbol.toStringTag property is "Module", which makes Object.prototype.toString.call(ns) return "[object Module]".

import.meta

Every ES module receives an import.meta object containing metadata about the current module. In Node, local file modules expose URL and filesystem-derived properties:

js
console.log(import.meta.url);
// file:///Users/dev/project/src/app.js

console.log(import.meta.filename);
// /Users/dev/project/src/app.js

console.log(import.meta.dirname);
// /Users/dev/project/src

For local file modules, import.meta.url is the fully resolved file: URL. import.meta.filename and import.meta.dirname are the ESM equivalents of CommonJS's __filename and __dirname, added in Node v21.2 and backported to v20.11. Before those properties existed, code had to convert the URL manually with fileURLToPath(import.meta.url).

Those filesystem properties only exist for local file: modules. A data: module still has import.meta.url, but it has no filesystem filename or dirname.

import.meta.resolve() uses the current module as the base for resolving a specifier without loading it:

js
const resolvedPath = import.meta.resolve('./config.json');
// file:///Users/dev/project/src/config.json

In Node this is synchronous and returns a string, not a promise. It follows the same resolution algorithm as import: package exports maps, node: built-ins, and bare specifiers through node_modules all apply. The difference is that resolution is where it stops. It returns the URL without fetching, parsing, linking, or evaluating the target.

The object is also module-specific. Each module gets its own import.meta with metadata derived from that module's URL. Another module cannot read it from the outside. The object is created during instantiation and populated by Node through the host-defined HostInitializeImportMeta callback before the module's code runs.

Strict Mode, Scoping, and Other Differences

ES module code always runs in strict mode. No "use strict" directive is needed. Top-level this is undefined rather than globalThis, assigning to an undeclared variable throws, duplicate parameter names are forbidden, and with statements are illegal.

Each module also has its own scope. Top-level variables are local to the module unless explicitly exported. CommonJS also has module-level scope through its wrapper function, but ESM scope is part of the engine's module environment record rather than a wrapper function created by the CommonJS loader.

That difference is visible in the names that do not exist. At the top level of an ES module there is no arguments, require, module, exports, __filename, or __dirname. Those are CommonJS wrapper variables from the earlier module system. In ESM, imports come from import declarations, and module metadata comes from import.meta.

Other strict-mode rules apply normally. typeof works normally. eval() runs under strict-mode semantics. new Function() creates functions in the global scope, not in the surrounding module scope.

The top-level this difference is a common migration trap. In CommonJS, top-level this equals module.exports because of the wrapper function. In ESM, it is undefined. Code ported from CommonJS that writes to this at the top level will fail once it tries to access a property on that value.

JSON loading is another difference. CommonJS can call require('./config.json') and receive the parsed object synchronously. ESM can import JSON too, but current Node.js releases require an import attribute:

js
import config from './config.json' with { type: 'json' };

The attribute tells the loader to treat the target as JSON and expose the parsed value as the default export. For configuration files that do not need module semantics, reading the file and calling JSON.parse remains explicit and portable. Code that specifically wants the CommonJS behavior can use createRequire() from node:module to obtain a CommonJS require function and load JSON through that path.

Putting the Pieces Together

The parse, instantiate, evaluate sequence explains behavior that can seem unusual when compared with CommonJS. A small example shows the phases working together:

js
// config.js
export const debug = process.env.DEBUG === '1';
export let requestCount = 0;
export function trackRequest() { requestCount++; }
js
// server.js
import { debug, requestCount, trackRequest } from './config.js';

During parsing, Node finds that server.js depends on config.js, so both modules are parsed before either body runs. During instantiation, config.js gets export slots for debug, requestCount, and trackRequest, and server.js's imported bindings are wired to those slots. During evaluation, config.js runs first because it is the dependency. It reads process.env.DEBUG, initializes debug, sets requestCount to 0, and creates trackRequest.

Only after that does server.js evaluate. Its imported debug binding already has a value, and requestCount starts at 0. If server.js calls trackRequest(), the update is visible through requestCount immediately because both modules are observing the same binding.

For production code, the rules follow from those mechanics: declare the package type, write explicit relative specifiers, and remember that URL identity controls ESM caching. Static declarations give the engine a complete dependency graph before execution. Three-phase loading separates linking from evaluation. Live bindings prevent stale imported bindings, but they do not remove temporal-dead-zone failures in cycles.

  • Previous: Node.js Module Resolution Algorithm: node_modules, package.json, and exports
  • Next: Node.js CommonJS and ES Modules Interop: require(), import, and Dual Packages