Module loading in Node.js
Learn how Node.js wraps CommonJS files, resolves packages, applies export conditions, runs loader hooks, and requires synchronous ES modules.
- 01Trace a CommonJS loadExplain the wrapper, exports object, resolved filename, and cache entry created by a require call.
- 02Predict resolutionFollow local paths, node_modules lookup folders, package exports, and ordered import or require conditions.
- 03Choose the right loading toolRecognize loader hooks and know why require can load a synchronous ES module but rejects top-level await.
A request becomes a module
When Node sees require("cart-utils"), it does not simply open a file with that name. It first decides what kind of request this is, finds one module target, loads and evaluates it if needed, and returns that module's public value. Those steps explain many confusing errors.
Module loading is the process of turning a module request into an evaluated module and its exported value. Resolution is the earlier step that chooses the target location for that request.
Node supports CommonJS and ECMAScript modules (ESM). This lesson follows the Node-specific path around CommonJS require, package files, and loader hooks. The earlier Module loading in browsers lesson explains URL-based browser loading; the published CommonJS and ESM lesson introduces the two module styles.
Keep three questions separate. What text did the code request? Which filename or URL did Node resolve? What object or namespace did the load return? A package name can resolve differently from different folders, and one cache entry is about the resolved filename rather than the text you typed.
The CommonJS wrapper
Every CommonJS file gets a private function-like boundary before its code runs. This is why a top-level const cart in one file does not collide with a top-level const cart in another file. It also explains where CommonJS helper names come from.
console.log(typeof module);console.log(typeof exports);console.log(typeof require);console.log(typeof __dirname);console.log(arguments.length);Line 1 prints object for module. Line 2 prints object for exports. Line 3 prints function for require. Lines 4 and 5 print string and 5. The first four names are values Node supplies, and the final line counts every wrapper argument.
Node documents the wrapper shape as a function receiving exports, require, module, __filename, and __dirname. They look global inside a CommonJS file, but they are local parameters. ESM uses a different model and does not receive this CommonJS wrapper.
Replay of instrumented teaching code. It models the CommonJS wrapper; it is not a Node debugger.
script
const exports = module.exports;const require = (name) => ({ name });const __filename = "/shop/cart.js";const __dirname = "/shop"; function wrapped(exports, require, module, __filename, __dirname) { const cart = { item: "tea" }; module.exports.item = cart.item; return arguments.length;} console.log(wrapped(exports, require, module, __filename, __dirname));console.log(module.exports.item);Think of each CommonJS file as its own lunch box. Its variables do not spill into another box. Once Node has prepared a box, the fridge can hand back that same box to the next person who asks for it.
- In real life: One lunch box belongs to one person
- In JavaScript: One wrapper scope belongs to one CommonJS file
- In real life: Food inside stays in its box
- In JavaScript: Top-level variables stay local
- In real life: The fridge keeps a prepared lunch box
- In JavaScript: require.cache keeps a loaded module
- In real life: Two requests get the same prepared box
- In JavaScript: A cache hit returns the same exports object
Where the analogy stops: A module is executable code with references and lifecycle details; a lunch box only helps explain privacy and reuse.
The require cache
The require cache is the CommonJS loader's object of already loaded modules. After Node resolves and evaluates a file, it normally stores the result under that resolved filename. Another require that resolves to the same filename receives the same module.exports object.
const first = { cart: ["tea"] };const second = first;console.log(first === second);second.cart.push("milk");console.log(first.cart.join(","));Line 1 creates one cart object. Line 2 gives second the same object, so line 3 prints true. Line 4 changes that object through second. Line 5 prints tea,milk through first. A normal cache hit has this same shared-identity idea.
A real CommonJS module can run setup code once, export a service object, and then let later callers share it. This supports cycles too: Node can put a partly initialized exports object in the cache while another module asks for it. Read the in-batch Circular dependencies lesson for that partial-exports case.
You can delete require.cache[require.resolve("./cart")] to make the next require load the file again. That does not update old references held by other modules. Use cache eviction carefully in tests or tooling, not as a normal application refresh button.
How Node resolves a request
Resolution begins with the request text. A request starting with node: names a built-in module. A request beginning with ./, ../, or / is a file path. A bare name like cart-utils is normally a package lookup.
// Node-only CommonJS fileconst cart = require("./cart");console.log(cart.total);Line 2 asks for a file relative to the current module. Node's CommonJS rules can try file extensions and older folder entry points, although modern packages should prefer explicit package exports. If the target is not found, Node throws MODULE_NOT_FOUND.
| Request | Kind | First move |
|---|---|---|
| node:fs | Built-in module | Use the built-in module immediately. |
| ./cart | Relative request | Try a nearby file and CommonJS file or folder rules. |
| cart-utils | Bare package request | Walk node_modules folders from nearest to farthest. |
| #config | Package import | Read the nearest package's imports mapping. |
| cart-utils/price | Package subpath | Check that the package exports that public subpath. |
The full algorithm has more branches for package imports, package self-references, file types, and exports. The useful beginner habit is smaller: classify the request first. A missing relative file is not fixed by installing npm packages, and a blocked package subpath is not fixed by adding another ./.
Replay of the lesson's lookup and conditional-export models. It simplifies Node's complete resolver to make the ordered decisions visible.
script
const packageFile = resolveExports(exportsField, ["node", "require"]);console.log(paths[0]);console.log(packageFile);Walking node_modules folders
For a bare package request, Node looks beside the requiring module first. If it cannot find the package there, it moves up one directory and tries again. This lets a package use its own nested dependency before it falls back to a parent installation.
function lookupPaths(fromDir) { const pieces = fromDir.split("/").filter(Boolean); const paths = []; for (let index = pieces.length; index > 0; index -= 1) { if (pieces[index - 1] !== "node_modules") { paths.push("/" + pieces.slice(0, index).join("/") + "/node_modules"); } } return paths;} console.log(lookupPaths("/shop/cart/checkout").join("\n"));Line 1 declares a small model, not Node's internal resolver. Line 5 starts at the deepest directory. Line 6 adds a node_modules shelf unless that path segment already has that name. The example prints checkout, cart, and shop shelves in nearest-first order.
The model for /shop/cart/checkout starts with /shop/cart/checkout/node_modules, then /shop/cart/node_modules, then /shop/node_modules. Node's real module object exposes its searched locations as module.paths; the lesson test compares that real array for a nested temporary folder with this same ordered idea.
Looking for cart-utils is like looking for a book. Check your room shelf first, then the family shelf one floor up, and keep moving upward until you find it or run out of shelves.
- In real life: Your room shelf
- In JavaScript: Nearest node_modules folder
- In real life: Family shelf one floor up
- In JavaScript: Parent directory node_modules folder
- In real life: More shelves upward
- In JavaScript: More parent lookup folders
- In real life: No shelf has the book
- In JavaScript: MODULE_NOT_FOUND
Where the analogy stops: Node has extra rules for built-ins, exports, and files; the shelves only explain bare-package lookup order.
Package exports and conditions
The exports field is a package's public doorway list in package.json. When it exists, package-name imports can use only the entry points the package declares. This protects internal files from becoming accidental public API.
{
"exports": {
"import": "./dist/cart.mjs",
"require": "./dist/cart.cjs",
"default": "./dist/cart.js"
}
}The first branch is for import and import(). The second is for CommonJS require. The final default branch is a fallback. The Node documentation says object key order matters: write the most specific conditions first and default last.
Conditions can be nested too. A package may first choose a Node-specific branch, then choose import or require inside it. The target is still just one file for one request. Adding an exports field can be a breaking package change because private paths that callers used before become unavailable.
Choose export conditions
The next model uses one exports object with a node branch and nested import, require, and default choices. It is intentionally smaller than Node's real package resolver so you can see the ordered decision.
const exportsField = { node: { import: "./dist/cart.mjs", require: "./dist/cart.cjs", default: "./dist/cart.js", }, default: "./dist/cart.js",}; function resolveExports(field, conditions) { for (const [condition, target] of Object.entries(field)) { if (condition === "default" || conditions.includes(condition)) { return typeof target === "string" ? target : resolveExports(target, conditions); } } return undefined;} console.log(resolveExports(exportsField, ["node", "require"]));node, requireConditions are checked in the order of the exports object.
./dist/cart.cjsThe CommonJS branch wins after node.
The active conditions are node, require. The model chooses ./dist/cart.cjs.
With node plus require, the model chooses ./dist/cart.cjs. Switch to import and it chooses ./dist/cart.mjs. The selected file changes because the condition list changes, not because the package name changes.
Do not treat conditional exports as a file-extension trick. The condition set is part of resolution. It also means a dual package should test both ways that consumers load it. A file that works with import may expose a different API shape through require.
Module customization hooks
Most applications should use the normal resolver. Advanced tools sometimes need to map a special name, load source from somewhere unusual, or transform a supported source format. Node exposes customization hooks for that boundary.
// Node-only sketch: hooks.mjsexport async function resolve(specifier, context, nextResolve) { if (specifier === "cart-config") { return { url: new URL("./cart-config.js", context.parentURL).href, shortCircuit: true }; } return nextResolve(specifier, context);} export async function load(url, context, nextLoad) { return nextLoad(url, context);} // registerer.mjsimport { register } from "node:module";register("./hooks.mjs", import.meta.url);Lines 2 through 7 define resolve. It either returns a custom URL for cart-config or calls nextResolve to preserve ordinary behavior. Lines 9 through 11 define load, which delegates to the next loader in this short sketch. Lines 14 and 15 register the hook module.
Node documents module.register() hooks as asynchronous hooks that run on a separate loader thread. They cannot directly mutate application globals, so communication needs a message channel or other explicit design. Current Node also has synchronous registerHooks(); it is a distinct API with different behavior.
Hooks are powerful enough to make debugging harder. Keep them small, preserve the next hook unless you intentionally short-circuit, and use a build step instead of runtime transforms when that is simpler. The hook is part of loading, before your requested module evaluates.
Using require with ES modules
Modern Node can let CommonJS require() load an ES module when the whole ES module graph is synchronous. The result is the ES module namespace object. That means named exports are properties on the returned value.
// Node-only CommonJS fileconst cart = require("./cart.mjs");console.log(cart.total); // cart.mjsexport const total = 3;Line 2 requires cart.mjs. Line 3 prints 3 because line 6 exports total. The returned namespace object is similar to what dynamic import() resolves to, but require() returns it synchronously.
The important limit is top-level await. A module with top-level await, or a dependency graph containing it, needs asynchronous evaluation. Synchronous require cannot wait for it.
// Node-only CommonJS filetry { require("./wait-for-price.mjs");} catch (error) { console.log(error.code);} // wait-for-price.mjsawait Promise.resolve();export const price = 3;Line 3 tries to require the ESM file. Line 5 prints its error code. Lines 9 and 10 make that ESM module asynchronous, so the real Node test proves the output is ERR_REQUIRE_ASYNC_MODULE. Use import() for a module graph that awaits.
Practical debugging habits
When an import fails, do not start by changing random paths. First identify the request form. Then inspect where Node resolved it, whether the package exposes that subpath, and which module is making the request. These three facts normally shrink the problem quickly.
- Use
require.resolve()to inspect the filename Node would load. - Use
require.resolve.paths("cart-utils")to inspect bare-package search locations. - Read the package's
exportsbefore importing a deep internal file. - Check nested dependencies when two callers get different package copies.
- Use
import(), not require, when the ESM graph needs top-level await.
Published npm and module resolution lessons cover installing packages and everyday path choices. Here the goal is to inspect the runtime's actual decision before changing code.
__dirnameinside a CommonJS file- Two requires of the same resolved cart file
- The closest
node_modulesfolder - The
requirebranch in package exports - A synchronous
.mjsmodule required from CommonJS - An ES module graph using top-level await
Sort the cards by wrapper/cache, resolver/exports, or ESM/hooks. Read the explanation after each placement.
Common misconceptions
- “require always runs the file again.” It normally returns a cache entry for the same resolved filename.
- “A package name is the cache key.” Nested callers can resolve the same name to different files.
- “exports lists files for humans.” It is an enforced public entry-point map for package-name loading.
- “A loader hook is ordinary application code.” It participates in resolution and loading before the requested module evaluates.
- “require can handle any ES module.” It cannot synchronously require a graph containing top-level await.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| A module is cached | The same resolved filename returns the same exports object until its cache entry changes. | Every textual require request shares one object. |
| A hook resolves a request | It can pass the request on or deliberately short-circuit the next resolver. | Hooks run after all modules are already loaded. |
| A package has exports | Only declared package paths are public to package-name imports. | The field only changes TypeScript types. |
| require reads ESM | It reads a fully synchronous ESM graph and returns its namespace object. | It waits for top-level await like import(). |
There is one final cache detail worth keeping: a built-in requested with a node: prefix bypasses a same-named cache entry. That is another reason to use node: for built-ins in Node-only code.
Practice exercises
Predict the number printed by this tiny wrapper call.
function wrapped(exports, require, module, __filename, __dirname) {
return arguments.length;
}
console.log(wrapped({}, () => ({}), { exports: {} }, "/shop/cart.js", "/shop"));It prints 5. CommonJS receives exports, require, module, filename, and dirname.
A cart file is required twice from the same caller and its resolved filename is identical. What does the second require normally return?
The same resolved file normally returns the same exports object. A second require does not create a fresh module object.
Type the first node_modules folder this teaching model checks.
console.log(lookupPaths("/shop/cart/checkout")[1]);The first lookup folder is /shop/cart/checkout/node_modules. Parent folders are only tried later.
What file should the model choose for node plus require conditions?
console.log(resolveExports(exportsField, ["node", "import"]));The selected target is ./dist/cart.cjs. The import target belongs to a different request mode.
Predict the error code reported when require reaches an ESM module with top-level await.
Node throws ERR_REQUIRE_ASYNC_MODULE when a required ES module graph contains top-level await. Use import() instead.
Your checkout server says it cannot load cart-utils/price after an upgrade. What should you inspect before changing the import?
Inspect the resolved path and package exports first. require.resolve() tells you the file Node would choose; then check whether the package intentionally exposes the requested entry.
Check your understanding
Answer by separating scope, cache identity, resolution order, exports conditions, and synchronous versus asynchronous loading.
Question 1 of 7Why can a CommonJS file use
require,module, and__dirname?Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconst first = { item: "tea" }; const second = first; console.log(first === second);Choose an answer to see the explanation.
Question 3 of 7For
require("cart-utils")from/shop/cart/checkout, which place is checked first?Choose an answer to see the explanation.
Question 4 of 7What does ordered conditional exports mean?
Choose an answer to see the explanation.
Question 5 of 7What does the lesson model choose for these conditions?
Read the code, then predictconst field = { node: { import: "./cart.mjs", require: "./cart.cjs" }, default: "./cart.js" }; const conditions = ["node", "require"]; console.log(field.node.require);Choose an answer to see the explanation.
Question 6 of 7What is the job of a module resolve hook?
Choose an answer to see the explanation.
Question 7 of 7What happens when CommonJS requires an ES module with top-level await?
Choose an answer to see the explanation.
Key takeaways
- CommonJS files run inside a five-argument wrapper, which keeps top-level variables local.
- require.cache normally reuses the exports object for the same resolved filename.
- Node classifies request text, then resolves files or walks node_modules folders for packages.
- Package exports define public entry points and conditional branch order matters.
- module.register hooks customize advanced resolution and loading work on a separate loader thread.
- require can return a synchronous ES module namespace, but top-level await causes ERR_REQUIRE_ASYNC_MODULE.
Remember the one-liner.
Node module loading turns a request into one resolved module, then returns its cached or freshly evaluated public value.
Coming next: Modules through a bundler, where a build tool creates its own module graph, runtime, chunks, and update system.