Skip to content

How Modules Actually Load

CommonJS: every file is wrapped in a function

Section titled “CommonJS: every file is wrapped in a function”

require, module, exports, __dirname, and __filename are not globals — they look like they are because every CJS file is compiled inside a wrapper function that receives them as parameters. Module.wrap() is the exact function Node uses to build that wrapper:

Node.js
Output (verified on Node 24)
require: function | module: object | exports: object | __dirname: string | __filename: string
(function (exports, require, module, __filename, __dirname) { console.log(1)
}

This is why a CJS file can use require and __dirname without importing them from anywhere, and also why none of those five identifiers exist in ESM — an ESM file is not run inside this wrapper at all.

The second require() of the same resolved path returns the exact same exports object — Node caches by resolved file path, not by content. Deleting the cache entry forces the next require() to re-execute the file from scratch, producing a brand-new exports object:

Node.js
Output (verified on Node 24)
same object on a normal second require (cached): true
same object after deleting the cache entry: false

This is the mechanism behind hot-reloading a config file or a plugin at runtime: resolve the path once, delete require.cache[resolvedPath], then require() it again. It only invalidates that one file’s cache entry — anything that file required is still cached separately, so a reload does not cascade unless you walk require.cache and delete every module in the dependency subtree yourself.

require.resolve(specifier) is the resolution algorithm alone, with no execution — useful for exactly this cache-keying purpose, and for checking whether a module is resolvable at all without the side effects of actually loading it.

The ESM loader has phases too — and they are not optional steps you can skip reasoning about

Section titled “The ESM loader has phases too — and they are not optional steps you can skip reasoning about”
flowchart LR
Resolve["Resolve
specifier -> a URL"] --> Load["Load
URL -> source text"]
Load --> Link["Link
bind imported/exported names (live bindings)"]
Link --> Evaluate["Evaluate
run the module body top to bottom"]
What import does before your code runs

Resolve turns a specifier like './a.mjs' or 'lodash' into a fully-qualified URL. Load fetches the source text for that URL. Link connects every import/export name to the live binding it refers to — before any module body has run — which is what makes ESM imports live references rather than copied values. Evaluate finally runs each module’s top-level code, in dependency order, exactly once. --import/register() hooks (below) intercept the first two phases; nothing lets you intercept Link or Evaluate from outside the loader itself.

A cycle is two modules that (directly or transitively) require/import each other. Because CJS resolves values at require time (a snapshot of whatever module.exports looked like at that moment) while ESM resolves live bindings (checked again on every access, with a temporal-dead-zone check), the same cyclic shape fails differently — or does not fail at all — depending on which module system it is written in:

Node.js
Output (verified on Node 24)
CJS cycle -- no error, just a partial object at the point of the cycle:
  b saw a.fromA already set: true
  b saw a.fromBSeenFromA not set yet (a was still mid-execution): true
ESM cycle -- threw ReferenceError (temporal dead zone), not a partial object: true

CJS never throws on a cycle — you silently get whichever properties happened to be assigned before the cycle occurred, and reasoning about which those are requires tracing execution order by hand. ESM’s live bindings make the same shape a hard, catchable error the moment an uninitialized binding is actually read — worse ergonomics in the moment, but it turns a silent, order-dependent bug into a loud one.

--import and register(): hooking the loader from outside

Section titled “--import and register(): hooking the loader from outside”

Node lets you intercept resolve/load with a customization hook module, wired in with register() and loaded up front with --import:

hooks.mjs — registers the hook module
// @skip-verify multi-file: illustrative, run together via the CLI transcript below
import { register } from 'node:module';
register('./resolve-hook.mjs', import.meta.url);
resolve-hook.mjs — the actual hook
// @skip-verify multi-file: illustrative, run together via the CLI transcript below
export async function resolve(specifier, context, nextResolve) {
if (specifier === 'virtual-greeting') {
const source = encodeURIComponent('export default "hello from a virtual module";');
return { url: `data:text/javascript,${source}`, shortCircuit: true };
}
return nextResolve(specifier, context); // not ours -- defer to the default resolver
}
main.mjs
// @skip-verify multi-file: illustrative, run together via the CLI transcript below
const greeting = await import('virtual-greeting');
console.log(greeting.default);
Terminal window
node --import ./hooks.mjs main.mjs
hello from a virtual module

This is the supported mechanism behind tools that transpile on the fly, resolve custom URL schemes, or redirect specifiers — it is also exactly what --experimental-strip-types and TypeScript loaders like ts-node’s ESM mode build on. Just passing --import a plain module (without calling register() inside it) only preloads that module; it does not, by itself, wire up resolve/load interception.

Type stripping is on by default in Node 24.13 — running a .ts file needs no flag at all, and the flag that exists (--no-strip-types) turns it off:

Terminal window
node sample.ts # no flag needed
port is 3000
Terminal window
node --no-strip-types sample.ts
SyntaxError: Missing initializer in const declaration

Default stripping is erase-only: it deletes type annotations but refuses any TypeScript syntax that has no valid JavaScript meaning once types are gone — most notably enum, which is not just an annotation but a runtime construct TypeScript compiles into an object:

Terminal window
node sample-with-enum.ts # default strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

--experimental-transform-types (still experimental on 24.13, and it prints an ExperimentalWarning) goes further than stripping — it actually transforms constructs like enum into their JavaScript equivalent instead of refusing them:

Terminal window
node --experimental-transform-types sample-with-enum.ts
(node:12345) ExperimentalWarning: Transform Types is an experimental feature and might change at any time
0

For a single-file executable, --experimental-sea-config (Single Executable Applications) takes a JSON config and, combined with postject, injects a bundled script into a copy of the node binary itself — worth knowing this exists, but it is a packaging step for distribution, not something you reach for while developing.

import.meta.resolve(specifier) runs the same resolution algorithm as import, synchronously, without loading anything — useful for “does this specifier even resolve” checks and for building tooling that needs a real file URL:

Node.js
Output (verified on Node 24)
function
node:fs
string string

import.meta.dirname/import.meta.filename are the direct ESM equivalents of CJS’s __dirname/__filename — no more fileURLToPath(dirname(import.meta.url)) boilerplate needed for the common case. Separately, node --print "expr" (-p) evaluates an expression and prints the result — handy for one-off checks (node -p "process.version", node -p "require('node:os').cpus().length") without opening a REPL or writing a file.

A CJS file is a function call: require() invokes the wrapper function synchronously, right there, and whatever module.exports looks like at that exact moment is what you get back — a snapshot. An ESM module is more like a contract that gets negotiated before anyone runs anything: every file’s exports are wired up as live, checked references first (Link), and only once every file in the graph has agreed on what connects to what does anyone’s code actually run (Evaluate) — which is why a badly-timed read during that negotiation is a hard error instead of a silently wrong snapshot.

CJS’s synchronous, snapshot-based model is simpler to reason about line by line and never needs an async loader step, at the cost of cycles resolving to whatever partial state happened to exist — a correctness footgun that is easy to miss in review. ESM’s phased, live-binding model catches that exact footgun as a hard error, at the cost of a more complex mental model (four phases, not one function call) and a loader that is asynchronous by nature, which is part of why interop between the two (require(esm), dynamic import() from CJS) has real, documented limits rather than being a seamless bridge.

  • “require() re-reads and re-runs the file every time.” Only the first require() of a given resolved path executes the file; every subsequent require() of that same path returns the cached exports object until you manually evict it from require.cache.
  • “ESM cycles are impossible, or always crash.” They are possible and common (two modules that reference each other is enough) — they only crash when code actually reads a live binding before its declaration has finished initializing. A cycle where each side only defines functions (not read at cycle time) works fine.
  • “Type stripping means Node runs TypeScript.” Default type stripping deletes annotations and syntax that has no runtime meaning — it does not check types, and it rejects TypeScript syntax that requires actual transformation (like enum) rather than silently compiling it.

Generated code that assumes require() and import() are interchangeable, or that a cyclic import will “just work” the way it apparently does in CJS, is a frequent, subtle source of ESM bugs.

🐛 Spot the bug

config.mjs
// AI-written pair of ESM modules meant to share state
export let ready = false;
import './init.mjs';
// init.mjs
import { ready } from './config.mjs';
console.log('config ready at init time:', ready);
// ... later, some other module sets ready = true after real init work
Show the answer

This mirrors the ESM cycle demonstrated above: init.mjs imports ready from config.mjs while config.mjs is still in the middle of evaluating (it is on the very line that imports init.mjs). Because ready is declared with let (a live binding with a temporal dead zone), reading it from init.mjs at this exact point in the cycle throws a ReferenceError, rather than silently returning false the way an equivalent CJS setup would. The generated code assumed import ordering “just works” the way require() ordering intuitively does — the fix is to break the cycle (have a third module both depend on, with no import back to either) or to only read ready from inside a function called after both modules have finished evaluating, never at a module’s own top level during the cycle.

What does Module.wrap() reveal about require, module, and __dirname in a CJS file?
What does this print?
const first = require(resolvedPath);
delete require.cache[resolvedPath];
const second = require(resolvedPath);
console.log(first === second);
Why can a cyclic ESM import throw a ReferenceError where the equivalent CJS cycle would not?
On Node 24.13, what happens when you run node script.ts with no flags, given this file content?
// script.ts
const port: number = 3000;
console.log(port);
What is the actual effect of passing a hook module to --import without that module calling register()?