Node.js import.meta: URLs, Caching & Module State
import.meta is the per-module metadata object for ES modules in Node.js. It gives the runtime a place to expose module location and loader-provided state without relying on the CommonJS wrapper variables. In current Node v24 builds, common fields include import.meta.url, import.meta.filename, import.meta.dirname, import.meta.main, and import.meta.resolve().
Those fields are small on the surface, but they point at a larger idea: in ESM, module identity is URL-based. The same identity that tells a module where it lives also determines how the loader caches it, how often it is evaluated, and what happens when two modules depend on each other during startup.
import.meta and ESM Caching
ESM caching is separate from require.cache. The ESM loader keys module records by URL and tracks them through parsing, linking, evaluating, and evaluated state. Circular dependencies then expose the consequences of that model: imports are live bindings created during linking, so the point where evaluation reaches an export assignment affects what importers observe.
The visible part of this machinery is import.meta. From there, the same URL identity leads naturally into loader caches, singleton state, V8 module status, and circular dependency behavior.
import.meta
CommonJS gave each module __filename and __dirname by injecting them through the module wrapper function, covered in Chapter 1. ESM does not have that wrapper. Instead, module-specific information comes through import.meta.
The ECMAScript spec defines the syntax - import.meta as an expression - but leaves the object itself to the host environment. Browsers normally expose import.meta.url. Node fills in more properties, and that set has grown over the past few major releases.
The object is created lazily. V8 allocates it the first time code accesses import.meta in a given module. If the module never touches it, V8 never calls into Node's initialization callback for it. Each module receives its own distinct import.meta object, not a shared object for the whole graph.
import.meta.url
Every file-backed ES module gets import.meta.url set to a file:// URL that points to the module's source file on disk.
console.log(import.meta.url);
// file:///home/app/src/index.mjsThe URL scheme controls the shape. It is file:// with a leading slash on the authority component, which means three slashes total on Unix (file:///home/...). On Windows, the value looks more like file:///C:/Users/app/src/index.mjs. The path component still uses forward slashes, and special characters in directory names are percent-encoded: a space becomes %20, and a hash becomes %23.
Because the value is a proper URL string, it also gives you a reliable base for resolving nearby files.
const dataUrl = new URL('./data.json', import.meta.url);
console.log(dataUrl.pathname);
// /home/app/src/data.jsonThe URL constructor applies the URL resolution rules and returns a URL object. Its .pathname property gives you the path component, but that path is still URL-encoded. A file at /home/my app/data.json produces /home/my%20app/data.json as the pathname. When you need a real operating-system path string - decoded, and with backslashes on Windows - use fileURLToPath() from node:url.
Before Node added import.meta.filename and import.meta.dirname, the new URL plus fileURLToPath() pattern was the standard ESM replacement for __filename and __dirname:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);That ceremony existed because ESM exposed the module location as a URL, not as a path. It was also easy to get subtly wrong. Code might forget the fileURLToPath() step, use .pathname as if it were a decoded filesystem path, or pass a URL string such as file:///home/app/data.json to fs.readFileSync(). The fs APIs accept URL objects and operating-system path strings. A URL string is treated as a literal path.
import.meta.filename and import.meta.dirname
Node v21.2 introduced import.meta.filename and import.meta.dirname. Both are stable in Node v24, and they expose the two path strings that ESM users previously derived by hand.
console.log(import.meta.filename);
// /home/app/src/index.mjs
console.log(import.meta.dirname);
// /home/app/srcThese are regular filesystem paths: decoded, absolute, and formatted for the current platform. import.meta.filename returns the same value as fileURLToPath(import.meta.url), and import.meta.dirname is equivalent to dirname(import.meta.filename). For file-backed modules, the old six-line pattern is no longer needed.
The file-backed part is the key distinction. A data: module still has import.meta.url, but import.meta.filename and import.meta.dirname are both undefined. For https:, Node v24 requires a custom HTTPS loader rather than native network imports. In practice, code running from disk gets the path helpers.
There is also a symlink detail. By default, import.meta.filename resolves symlinks. If /home/app/lib/index.mjs is a symlink to /home/shared/lib/index.mjs, the default value is /home/shared/lib/index.mjs. The symlink spelling is preserved when Node runs with --preserve-symlinks-main for the entry module or --preserve-symlinks for imported modules. Use import.meta.url when you need the exact URL used by the loader.
import.meta.main
import.meta.main is a boolean that tells a module whether it is the process entry point.
function main() {
// parse CLI args, run the command, or start the process
}
if (import.meta.main) {
main();
}That is the ESM version of the old CommonJS check:
if (require.main === module) {
main();
}Libraries use this pattern when a file can run as a CLI and also export functions for other modules. The entry module gets true; imported modules get false. Node added import.meta.main in v24.2, and the Node v24 docs still mark it as Stability 1.0, early development. Code that must run on older Node releases should keep a version gate or use a wrapper entry point.
import.meta.resolve()
import.meta.resolve() takes a module specifier and returns a fully resolved URL string, using the current module as the parent for resolution.
const resolved = import.meta.resolve('node:fs');
console.log(resolved);
// node:fsThe return value is always a URL string. Local file-backed targets use the file:// scheme. Built-in modules use the node: scheme, so import.meta.resolve('fs') returns 'node:fs'. Bare specifiers such as 'lodash' go through the package resolution algorithm: node_modules, the exports map in package.json, conditions, and subpath patterns. Relative paths such as './utils.js' resolve against the current module's URL.
In Node v24, import.meta.resolve() is synchronous. The spec originally left room for a Promise return value, and earlier Node releases briefly exposed async behavior behind a flag. Current releases return synchronously, though Node v24 still marks the API as Stability 1.2, release candidate. The call resolves the specifier only. It does not load the module, and it does not evaluate any code.
That makes it useful for some of the same tasks as require.resolve() in CommonJS: finding where a package lives, checking whether a module can be resolved, building a path relative to a dependency, or passing the resolved location to another API. Package resolution failures commonly throw ERR_MODULE_NOT_FOUND or an exports-related error such as ERR_PACKAGE_PATH_NOT_EXPORTED. A missing relative file is different: in Node v24, import.meta.resolve('./definitely-missing.js') can still return a file: URL. The missing file surfaces later, when code tries to load it or read it.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const schemaUrl = import.meta.resolve('my-lib/schema.json');
const schema = readFileSync(fileURLToPath(schemaUrl), 'utf8');Here the specifier resolves to a URL, the URL becomes a filesystem path, and fs reads the file. The module my-lib is not imported. Its package resolution context is only used to locate a file it ships, which assumes that my-lib exposes schema.json through its package entry points.
Package encapsulation still applies. import.meta.resolve() uses the target package's exports field, so import.meta.resolve('my-lib/schema.json') fails if that subpath is hidden by exports, even when the file exists on disk. CommonJS package specifiers follow the same rule: require.resolve('my-lib/schema.json') also fails when exports hides the subpath. An absolute filesystem path bypasses package encapsulation because package resolution has already been skipped.
How import.meta Is Populated
V8 does not know about file paths or Node's module system. When code in a module evaluates import.meta, V8 calls a host-defined hook. Node registers its import-meta initialization callback during bootstrap.
The default callback receives the import.meta object and the ModuleWrap for the source-text module that referenced it. ModuleWrap is the C++ binding class that sits between Node's JavaScript layer and V8's v8::Module. From there, Node passes the module URL and main-module flag into the JavaScript initializer.
That initializer sets url to the module URL stored in the ModuleWrap, sets main from the entry-point flag, installs lazy filename and dirname helpers for file: URLs, and creates a resolve function bound to the current module's URL. The bound resolver calls the active ESM loader's synchronous resolver with that URL as the parent, which is why specifiers resolve relative to the module that called import.meta.resolve().
This all happens lazily. V8 triggers the callback the first time code actually accesses import.meta in a given module. If a module exports functions but never references import.meta, the callback never fires for that module. The savings are small per file, but large dependency trees often contain many modules that never need their own location information.
The JavaScript initializer lives in lib/internal/modules/esm/initialize_import_meta.js in the Node source as of v24. Related callback wiring lives in lib/internal/modules/esm/utils.js, with C++ support behind ModuleWrap. The JavaScript side is small; most of the complexity is in the bridge between V8's module hook and Node's loader.
Module Caching
Both CommonJS and ESM cache loaded modules. Once a module has been loaded and evaluated, later requests for the same module return the cached result without re-reading or re-executing the source. A project with 500 files might import or require the same utility from 200 different places. The file is read once, executed once, and every consumer receives the same cached module identity.
The two systems diverge in how they define that identity. CommonJS exposes a mutable cache object keyed by filenames. ESM keeps internal loader caches keyed by URLs. That difference affects hot reload, symlinks, duplicate package instances, and cycles.
Module._cache
When code calls require('./foo'), Node resolves the request to an absolute filename, such as /home/app/foo.js, and checks Module._cache['/home/app/foo.js']. On a hit, Node returns the cached module.exports object immediately. There is no file read, no compilation, and no evaluation.
Module._cache is a plain JavaScript object. Its keys are fully resolved absolute filesystem paths. Its values are Module instances, the same objects with .exports, .id, .filename, .loaded, .children, and .paths properties.
require.cache is the same object. They are literally the same reference:
const Module = require('node:module');
console.log(require.cache === Module._cache);
// trueBecause that cache is a normal object, userland code can inspect it, iterate over it, and delete entries from it. The keys show which CommonJS files have been loaded in the process. In a medium-sized Express app, there might be thousands of entries.
delete require.cache[require.resolve('./myModule')];After that deletion, the next require('./myModule') re-reads the file from disk, re-compiles it, re-evaluates it, and inserts a fresh entry into the cache. Some in-process hot reloaders use this exact shape: watch for file changes, delete the relevant cache entries, and re-require the changed modules. Process restart tools such as nodemon take the cleaner route and start Node again from scratch.
The deletion only affects future cache lookup. It does not retroactively update anything that already holds a reference to the old module.exports object. Every const foo = require('./foo') statement that already ran still has its local foo pointing at the stale exports. The old closures survive, and the old state persists.
For that reason, CommonJS cache invalidation is only partial. A new require() call gets fresh exports, but existing references remain attached to the previous module instance. A more complete hot reload has to re-require every module in the dependency chain, starting from the changed module and working up toward the entry point. Some hot-reload libraries track the dependency graph and invalidate transitively, but the approach remains error-prone.
The module.children array adds another detail. When module A requires module B, B gets pushed into A's children array. Deleting B from require.cache does not remove it from A's children. A testing framework that wants module isolation between tests has to account for those child links as well.
CJS Cache Keys and Symlinks
The CommonJS cache key is the resolved filename: the real, absolute path after following symlinks via fs.realpathSync(). If /home/app/node_modules/foo is a symlink to /home/shared/foo, the cache key is /home/shared/foo/index.js. Two different require() calls through two different symlink paths hit the same cache entry as long as they resolve to the same physical file.
Node also keeps internal resolution caches so repeated lookups do not redo every filesystem check. In Node v24, node:module exposes _pathCache; realpath caching is an internal implementation detail rather than a public property on node:module.
The --preserve-symlinks flag changes this behavior. With the flag set, Node uses the symlink path itself as the cache key instead of the resolved target. The entry module has its own flag, --preserve-symlinks-main. This changes deduplication behavior: the same physical file required through two different symlink paths produces two separate cache entries. Monorepos with workspace symlinks sometimes need this flag, though it can also create duplicate module instances.
ESM Loader Caches
ESM caching is internal to the ESM loader. There is no userland import.cache object and no public cache-deletion API. In Node v24, lib/internal/modules/esm/module_map.js defines ResolveCache and LoadCache, both backed by Node's internal primordials.SafeMap.
The load cache stores module jobs by resolved URL and import-attribute type. When code writes import './foo.mjs', Node resolves the specifier to a URL such as file:///home/app/foo.mjs and looks it up in the loader cache. If the entry exists, the import gets the same module job and the same module instance.
Because the cache key is a URL, query strings and fragments create distinct cache entries:
import './foo.mjs'; // cached as file:///home/app/foo.mjs
import './foo.mjs?v=1'; // cached as file:///home/app/foo.mjs?v=1
import './foo.mjs?v=2'; // cached as file:///home/app/foo.mjs?v=2All three specifiers load the same file on disk, but each URL gets its own ES module instance and its own evaluation. The ?v=1 and ?v=2 parts are included in the URL key. This behavior applies when the target is an ESM file, such as .mjs or a .js file inside a package with "type": "module". A default CommonJS .js target imported from ESM goes through the CommonJS loader, where the file path can collapse back to one CommonJS cache entry.

Figure 5.1 — ESM cache identity follows the resolved URL. Reusing the same URL reaches the same module instance; adding a query string or fragment creates another cache entry for the same file.
Query strings can be useful for development cache busting: change the query string, and dynamic import() sees a different URL. Each variant stays in memory for the lifetime of the process, though. Repeating that pattern with unbounded query values accumulates module instances.
There is no public Node API to clear the ESM loader cache. Once a module is loaded, it stays loaded until the process exits. The reason is tied to V8's internal Module records, which move through an irreversible state machine. A module that has reached the evaluated state cannot be rewound to uninstantiated. Existing module records and namespace objects already hold references to its live binding cells, so deleting a loader-cache entry would change only future lookup identity while leaving the evaluated graph intact.
Singletons Through Caching
Because both CommonJS and ESM cache modules, module-level state is shared by every consumer that resolves to the same cache entry inside the same Node worker. There is one evaluation, one set of variables, and one module scope for that cache identity.
// counter.mjs
let count = 0;
export function increment() { count++; }
export function getCount() { return count; }Every file that imports counter.mjs gets the same live bindings. Calling increment() from anywhere modifies the same count variable because all importers point back to the same module instance.
CommonJS reaches the same singleton behavior through the shared module.exports object:
// counter.js
let count = 0;
module.exports = {
increment() { count++; },
getCount() { return count; },
};The first require('./counter') evaluates the module and caches its exports. Every later require('./counter') returns the same object, so the closure over count is shared.
No special singleton class is required. Module caching supplies the single instance as long as every consumer resolves to the same cache entry.
The limit is cache identity. If two consumers resolve to different cache entries, they get separate module instances with separate state. A package installed once in the project root and again inside a nested dependency's node_modules directory produces two cache entries, two evaluations, and two singletons that do not share state. Many duplicate-package bugs in monorepos come from this shape: a logging library has two instances, a database pool is created twice, or a configuration object exists in two copies with different values. Tools such as npm ls <package> can help expose that duplication.
Workers form another boundary. A worker_threads worker runs its own module graph with its own CommonJS and ESM caches. Importing counter.mjs in the main thread and importing the same file in a worker gives each worker its own count, even though both modules live in the same operating-system process.
V8 Module States and Cache Internals
The ESM loader caches in Node live in lib/internal/modules/esm/module_map.js. The load cache is a SafeMap keyed by resolved URL string and import-attribute type. Its values are ModuleJob instances, which track a module from source fetching through final evaluation.
Each ModuleJob wraps a ModuleWrap, the C++ binding class between Node's JavaScript layer and V8's native v8::Module. V8's internal Module class has a status field that progresses through a strict sequence of states. Those states explain why ESM caching is immutable and why circular dependencies behave differently from CommonJS cycles.
The walkthrough below describes a synchronous ESM graph. Top-level await keeps the same broad parse, link, and evaluate model, but evaluation returns through a promise path and parts of the graph can remain pending. That changes timing, so the synchronous cycle examples should not be applied directly to a graph with TLA.
Uninstantiated. The source has been parsed and the module record has been created. V8 knows the module's imports and exports from static analysis of the import and export statements, but binding storage has not been allocated yet. At this point, the module exists as metadata.
Instantiating. The engine allocates binding slots and links them across the module graph. V8 walks the graph depth-first, calling back into Node's resolve hook for each import specifier. For each import, it finds the target module, locates the matching export, and wires the import slot to the exporter's binding. The import does not receive a copy. It receives a live reference.
Cycle detection happens during this phase. If V8 encounters a module that is already in the instantiating state while walking the graph, it has found a circular reference. The cycle does not abort linking. The binding still exists, and reads before lexical initialization still trigger TDZ checks.
Instantiated. All bindings are linked. Every import in the graph points to a specific export slot in another module. Function declarations and var exports have their usual instantiation-time behavior, while lexical let and const exports remain uninitialized until evaluation reaches them. No module code has run yet.
Evaluating. V8 executes the module's top-level code. As declarations and assignments run, the corresponding bindings receive runtime values. Other modules that imported those bindings can observe the values through their linked references. If a dependent module reads a lexical export before the exporting module reaches its declaration, as can happen in a circular dependency, accessing the binding throws ReferenceError because the slot is still in the temporal dead zone.
Evaluated. Execution completed successfully. All exported bindings have values, at least the values they had when top-level execution finished. Bindings exported as let, or mutable objects exported through any binding, can still change after this point, and importers see those changes because the bindings are live. The module status is permanent. V8 provides no mechanism to revert it.
Errored. Evaluation threw an exception. The error object is cached on the module record itself. Any future attempt to access that module, even from a different part of the code, re-throws the same error. There is no retry mechanism for that URL. This applies to evaluation errors; resolution, load, and parse failures happen before a successfully created evaluated-or-errored module record exists. If you fix the source file and want to try again after an evaluation error, you need to restart the process or, in development, use query-string cache busting with dynamic import() to load the fixed file as a different URL.
CommonJS reaches cycles through a different timing point. When Module._load() handles a fresh module, the sequence is:
- It creates a new
Moduleobject withmodule.exportsset to an empty object{}. - It inserts that
ModuleintoModule._cache[filename], before any evaluation happens. - It compiles the source, wrapping it in the module wrapper function.
- It evaluates the compiled function, which populates
module.exportsas the code runs.
The second step happens before the fourth. A module enters the cache with an empty exports object before its code begins running. If that code calls require() on another module that circles back and requires the first module, the circular require() finds the partially populated cache entry and returns whatever module.exports looks like at that exact moment. It does not wait for evaluation to finish.
The ESM loader creates cache entries early as well, because the ModuleJob is created during the fetch and parse path. But the visible module interface is not an empty exports object. It is a set of live bindings, and lexical export bindings can remain uninitialized until evaluation reaches their declarations. Accessing one too early throws ReferenceError. The failure mode is explicit rather than a silent read from a half-filled object.
That same state machine is why ESM cache invalidation has no public Node API. Once a V8 Module record reaches evaluated, V8 provides no API to reset it to uninstantiated. Other module records and namespace objects already reference its live binding cells. Removing the loader-cache entry would only affect future lookups; the existing evaluated graph would still point at the old module record.
The CommonJS cache is much simpler. delete require.cache[key] is a property deletion on a regular object. The old Module instance stays in memory as long as anything references it, but the next require() will miss the cache and create a fresh one. That is crude, but effective enough for some development hot-reload workflows and insufficient for anything that needs complete graph replacement.
Circular Dependencies
Two modules that import each other create a circular dependency. Both CommonJS and ESM can represent cyclic graphs, but support for cycles does not mean every cycle is safe. A top-level read in an ESM cycle can throw during startup and terminate the process if nothing catches the failure.
Partial Exports in CJS
Here is the classic CommonJS cycle: a.js and b.js each require the other.
// a.js
module.exports.x = 1;
const b = require('./b');
module.exports.y = 2;
console.log('a sees b:', { ...b });// b.js
const a = require('./a');
module.exports.value = 42;
console.log('b sees a:', { ...a });Run node a.js. The sequence depends on the cache-before-evaluation behavior described above.
Node starts loading a.js. It creates a Module object, sets module.exports to {}, inserts it into Module._cache, and begins evaluating. The first line sets module.exports.x = 1, so the cached exports object is now { x: 1 }.
The next line calls require('./b'). Node starts loading b.js, creates a new Module, caches it, and begins evaluating. The first line of b.js calls require('./a'). Node checks the cache, finds a.js already present, and returns its current module.exports: { x: 1 }. The y: 2 assignment has not happened yet because a.js is suspended at its require('./b') call.
b.js continues, sets module.exports.value = 42, logs b sees a: { x: 1 }, and returns. Control goes back to a.js, which resumes after require('./b'), sets module.exports.y = 2, and logs a sees b: { value: 42 }.
The output is:
b sees a: { x: 1 }
a sees b: { value: 42 }b.js saw only the part of a.js's exports that existed before the circular require() call. The later y property was not visible at the time b.js read a.
The object reference itself still controls what b.js can observe. b.js has a reference to the actual module.exports object from a.js. If it defers access until after a.js finishes evaluation, it sees the later properties too:
// b.js (deferred access)
const a = require('./a');
module.exports.value = 42;
module.exports.getA = () => a;Calling b.getA() after a.js finishes returns { x: 1, y: 2 }, because the local a variable in b.js points to the same object that a.js mutated. The reference stays the same; only the timing of the property read changed.
The dangerous version is a full reassignment. If a.js replaces module.exports entirely near the end, such as module.exports = { x: 1, y: 2 }, it creates a new object and updates the cache entry to point to that new object. But b.js already captured the old object, the one created before evaluation and later given x. The reassignment orphans b.js's reference. This is one reason CommonJS modules involved in cycles are safer when they add properties to module.exports instead of replacing it entirely.
Live Bindings and TDZ in ESM
ESM handles cycles through live bindings. An import does not receive a snapshot of an exports object. It receives a reference to a binding slot in the exporter's module scope. That slot can change over time, but it can also be uninitialized when another module tries to read it.
// a.mjs
import { value } from './b.mjs';
export const x = 1;
console.log('a sees value:', value);// b.mjs
import { x } from './a.mjs';
export const value = 42;
console.log('b sees x:', x);The ESM loader first parses both files. It discovers the dependency graph and identifies the cycle. It then instantiates both modules, allocating binding slots and wiring imports to exports. The x binding in b.mjs points to the x export slot of a.mjs; the value binding in a.mjs points to the value export slot of b.mjs.
During evaluation, the loader has to choose an order for the strongly connected part of the graph. For node a.mjs, Node evaluates the dependency side first, so b.mjs starts running before a.mjs has executed export const x = 1.
b.mjs then reaches console.log('b sees x:', x). The x binding points to a.mjs's export slot, but that slot has not been initialized yet. Accessing it throws:
ReferenceError: Cannot access 'x' before initializationThat is the key difference from CommonJS. CommonJS gives you an object, possibly half-populated, but still an actual value you can hold. ESM gives you a binding that may or may not have been initialized. There is no partial exports object. Either the exporting module's code has assigned the binding a value, or the binding is still in the temporal dead zone.

Figure 5.2 — CommonJS cycles expose whatever the cached exports object contains at that moment. ESM cycles wire live bindings first, so a read can arrive before a lexical export has been initialized.
Once the value is assigned, however, every importer sees the current value through the live binding. There are no stale copies and no orphaned references. If the code is structured so the read happens after initialization, the cycle can work:
// c.mjs
export let count = 0;
import { logCount } from './d.mjs';
count = 10;
logCount();// d.mjs
import { count } from './c.mjs';
export function logCount() {
console.log('count is:', count);
}When c.mjs calls logCount(), the function in d.mjs reads count, the live binding from c.mjs. By the time the function body executes, count has been assigned 10, so the function sees 10. The read happens at call time, not import time.
Function exports are a common way to make ESM cycles less fragile. The function declaration's binding is filled during instantiation, before evaluation starts, and the function body does not execute until called. By the time someone calls it, the bindings referenced inside the body have usually been initialized. A const initializer that reads across the cycle is different. If d.mjs exported export const snapshot = count, the read would use whatever value c.mjs had assigned by that point, or it would throw if count was still in TDZ. In this shape, changing the entry point can move the result between a TDZ failure and the later assigned value. Wrapping the access in a function defers it past the risky startup window.
Detecting and Breaking Cycles
Circular dependencies usually indicate tangled responsibilities between modules. If two modules each need something from the other, there is often a shared concern that belongs in a third module. Several approaches help either detect the cycle or remove the static edge that creates it.
Detection. The most direct ESM signal is a ReferenceError at startup. In CommonJS, cycles often stay quiet at load time: the program receives partial exports, and the bug appears later as undefined properties or missing functions. Node may also warn when code inspects missing properties on exports inside a circular dependency. Third-party tools such as madge can parse import and require statements and report cycles in the dependency graph. dpdm does similar work for TypeScript projects. Running madge -circular src/ gives you a list of cycles.
Extract shared logic. If a.js and b.js both need the same function, move that function into shared.js and have both modules import from shared.js. The shared module has no reason to import back from either side, so the graph becomes acyclic.
Dependency inversion. Instead of having module A import module B directly for some operation, have module A accept a callback or interface that module B provides at runtime. The static import or require edge disappears. The behavioral coupling still exists, but the module graph no longer has a cycle.
Lazy requires in CommonJS. Moving require() inside a function delays the edge until call time:
// a.js
module.exports.x = 1;
module.exports.getB = () => require('./b');The require('./b') call no longer runs during a.js's initial evaluation. It runs when someone calls getB(), by which time b.js can already be fully loaded and cached with all of its exports populated.
Dynamic import in ESM. The import() expression is the ESM equivalent of lazy loading. It returns a promise that resolves to the module namespace object:
// a.mjs
export const x = 1;
export async function getB() {
const b = await import('./b.mjs');
return b.value;
}The dynamic import() runs at call time. The static graph seen during parsing and instantiation contains no edge from a.mjs to b.mjs. The runtime dependency still exists, but the read happens later, after the caller has passed the startup TDZ window.
Cycles are valid in both module systems, but code that silently depends on evaluation order is fragile. A refactor that changes loading order - renaming a file, restructuring imports, or adding a new entry point - can shift the evaluation sequence and break behavior in ways that are hard to trace. Restructuring the dependency graph to eliminate cycles is usually the better long-term fix.
Cache identity is module identity. In CommonJS, that identity is exposed and mutable through require.cache; in ESM, it is URL-based and closed over by the loader. That difference shapes path handling, singleton state, hot reload strategies, and circular dependency failures.