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.
- 01Follow one require callSay what the wrapper function gives each CommonJS file, and how
require.cachesaves a loaded file under its full path. - 02Predict which file Node picksSort a request into a built-in, a file path, or a package. Then follow the
node_modulesfolder search and theexportsconditions, in order. - 03Pick the right way to loadSay what a loader hook does. Explain why
requirecan load an ES module that doesn't wait, but not one with top-level await.
A request becomes a module
A module is one file of JavaScript that shares some of its values with other files. Node.js (or just Node) is a program that runs JavaScript outside the browser.
When Node sees require("cart-utils"), it does not just open a file with that name. It does four jobs:
- It works out what kind of request this is.
- It finds exactly one file for it.
- It runs that file, unless it already ran it before.
- It hands back the values that file shares.
Knowing these jobs makes many confusing errors easier to read.
Module loading is the whole trip from a request, like "cart-utils", to the values the module shares. Resolution is the first part of that trip. It picks the one file the request means.
Node has two kinds of module:
- CommonJS files use
require()to load andmodule.exportsto share. - ES modules (ESM) use
importandexport.
This lesson is about how Node itself loads files: CommonJS require, package files, and loader hooks. The earlier Module loading in browsers lesson shows how a browser loads modules from web addresses. The CommonJS and ESM lesson introduces the two kinds of module.
Keep three questions apart:
- What text did the code ask for?
- Which file did Node pick for that text?
- What value did Node hand back?
The same package name can point to different files when you ask from different folders. Node remembers a loaded module by its file path, not by the text you typed.
The CommonJS wrapper
Before a CommonJS file runs, Node puts the whole file inside a function. This function is called the wrapper. Your code runs as the body of that function.
The wrapper does two jobs:
- It keeps the file's variables private. A
const cartat the top of one file can't clash with aconst cartat the top of another file. - It hands the file some helper names, like
requireandmodule.
console.log(typeof module);console.log(typeof exports);console.log(typeof require);console.log(typeof __dirname);console.log(arguments.length);typeof gives the kind of a value as text, such as "object" or "string". Inside a normal function, arguments holds the values the function was called with. Run as a CommonJS file in Node, line by line:
- Line 1 prints
object, becausemoduleis an object. - Line 2 prints
object, becauseexportsis an object. - Line 3 prints
function, becauserequireis a function. - Line 4 prints
string, because__dirnameis the folder path as text. - Line 5 prints
5. The wrapper was called with five values.
Node's documentation shows the wrapper as a function with five parameters, in this order:
exports: the object the file shares.require: the function that loads other modules.module: a record about this file. It holdsmodule.exports.__filename: this file's full path.__dirname: the folder this file is in.
These names look global, which means available everywhere. They are not. They are parameters of the wrapper, so each file gets its own. ES modules don't get this wrapper at all.
cart lives inside that function, so it stays private to cart.js.A replay of small lesson code that acts out the CommonJS wrapper. It is not Node's own code.
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. The food in one box doesn't spill into another box. Once a box is packed, it goes in the fridge. The next person who asks for it gets that same box.
- In real life: One lunch box belongs to one person
- In JavaScript: One wrapper belongs to one CommonJS file
- In real life: Food inside stays in its box
- In JavaScript: Variables at the top of the file stay in that file
- In real life: The fridge keeps a packed lunch box
- In JavaScript:
require.cachekeeps a loaded module - In real life: Two people who ask get the same packed box
- In JavaScript: Two
requirecalls get the sameexportsobject
The require cache
A cache is a store of things that are already done, so they don't have to be done again. The require cache is an object called require.cache. It holds every CommonJS module Node has already loaded.
After Node finds a file and runs it, it saves the module in the cache under that file's full path. Later, another require may lead to the same path. Then Node does not run the file again. It hands back 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(","));This small example has no require. It shows the same sharing idea with plain variables. Line by line:
- Line 1 makes one object and calls it
first. - Line 2 gives the same object a second name,
second. It does not copy it. - Line 3 prints
true.===checks whether both names point to the very same object. - Line 4 adds
"milk"to the list, using the namesecond. - Line 5 prints
tea,milk, using the namefirst. There is only one object, so both names see the change.
A cache hit works the same way. A cache hit means Node found the module already in the cache.
second shows through first. Two require calls for the same file share one object in the same way.Here is how real code uses this. A module runs some setup code once and shares an object, such as a shop service. Every later require gets that same object.
The cache also helps when two modules load each other. Node can put a half-finished exports object in the cache while the other module asks for it. The Circular dependencies lesson explains that case.
You can delete a module from the cache. Then the next require runs the file again:
delete require.cache[require.resolve("./cart")]
require.resolve("./cart") gives the full path Node would load, which is the key in the cache. But other modules that already got the old object still keep it. So use this only in tests or tools. It is not a way to refresh a running app.
How Node resolves a request
To resolve a request means to pick the one file it points to. Node starts by looking at how the request text begins:
- Text that starts with
node:names a built-in module, one that comes with Node, likenode:fs. - Text that starts with
./,../, or/is a file path. - A plain name like
cart-utilsis called a bare name. It usually means a package. A package is code someone published, usually installed with npm.
// Node-only CommonJS fileconst cart = require("./cart");console.log(cart.total);Line 2 asks for a file called cart in the same folder as this file. In CommonJS, Node may add a file ending for you, like .js. It may also look inside a folder called cart. If no file is found, Node throws an error with the code MODULE_NOT_FOUND.
The table below shows five kinds of request. Read each row as: "when I write this, it is this kind of request, and Node does this first."
| When you write | What kind of request it is | What Node does first |
|---|---|---|
node:fs | A built-in module, which comes with Node. | Node uses its own built-in module straight away. |
./cart | A file path, starting from this file's folder. | Node looks for a cart file next to this file. It may add an ending like .js, or look inside a cart folder. |
cart-utils | A bare name, which usually means a package. | Node searches node_modules folders, from the nearest one up to the top. |
#config | A private name inside your own package. | Node reads the imports field in your own package.json. |
cart-utils/price | A path inside a package. | Node checks that the package's exports field makes that path public. |
Node's full set of rules has many more cases. The useful habit is small: first sort the request into one of these kinds. This stops you from trying the wrong fix:
- A missing
./file is not fixed by installing an npm package. - A package path the package keeps private is not fixed by adding
./.
Node also decides whether a file is CommonJS or an ES module. It looks at the file ending:
.cjsis always CommonJS..mjsis always an ES module..jsfollows thetypefield in the nearestpackage.jsonfile.
A request that starts with #, like #config, uses the imports field of your own package's package.json. This is not the browser's import map. One more difference: in ES modules, a ./ path usually needs the full file name, ending included, like ./cart.js.
A replay of the lesson's two small functions: the folder search and the exports choice. Node's real rules have more cases.
script
const packageFile = resolveExports(exportsField, ["node", "require"]);console.log(paths[0]);console.log(packageFile);Walking node_modules folders
npm installs packages into folders called node_modules. For a bare name, Node searches these folders in order:
- It looks in the
node_modulesfolder next to the file that asked. - If the package isn't there, it moves up one folder and looks in that folder's
node_modules. - It keeps moving up until it finds the package or runs out of folders.
Because the nearest folder comes first, a package can use its own copy of another package. Node only uses a copy higher up when there is none nearby.
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"));This is a small function written for this lesson. It is not Node's real code, but it lists the folders in the same order. Line by line:
- Line 1 starts the function
lookupPaths. It gets one folder path,fromDir. - Line 2 splits the path at each
/into folder names:shop,cart,checkout..filter(Boolean)drops the empty piece before the first/. - Line 3 makes an empty list,
paths. - Line 4 counts down from the deepest folder to the top one.
- Line 5 skips a folder that is already called
node_modules. - Line 6 joins the first few folder names back into a path and adds
/node_modulesto the end. - Line 12 prints the list, one path per line.
For /shop/cart/checkout, the function prints three paths, nearest first:
/shop/cart/checkout/node_modules/shop/cart/node_modules/shop/node_modules
In real Node, every CommonJS module has this list as module.paths. This lesson's tests check that the real list for a nested folder starts in the same order.
node_modules folder first. If the package is not there, it moves up one folder and looks again.Looking for cart-utils is like looking for a book at home. You check the shelf in your room first. Then you check the family shelf one floor up. You keep going up until you find the book or run out of shelves.
- In real life: The shelf in your room
- In JavaScript: The nearest
node_modulesfolder - In real life: The family shelf one floor up
- In JavaScript: The
node_modulesfolder one folder up - In real life: More shelves further up
- In JavaScript: More
node_modulesfolders higher up - In real life: No shelf has the book
- In JavaScript:
MODULE_NOT_FOUND
Package exports and conditions
Every package has a package.json file that describes it. The exports field in that file is the package's list of public entry points. An entry point is a file that other code is allowed to load by the package's name.
When a package has an exports field, you can load only the entry points it lists. Its other files stay private. This stops other code from depending on files the package author meant to keep inside.
{
"exports": {
"import": "./dist/cart.mjs",
"require": "./dist/cart.cjs",
"default": "./dist/cart.js"
}
}Each key inside exports is a condition: a word that says when its file is used. Here there are three:
importis used byimportstatements andimport().requireis used by CommonJSrequire().defaultis used when nothing above it matched.
Node checks the keys from top to bottom and uses the first one that matches. So the order you write them in matters. Node's documentation says: put the most specific conditions first and default last.
A condition can also hold more conditions inside it. For example, a package may first pick a node group, then pick import or require inside that group. In the end, one request still gets exactly one file.
Adding an exports field to an old package can break its users. Files they loaded before may now be private.
Choose export conditions
The playground below uses a small exports object. It has a node group. Inside that group are three choices: import, require, and default. The function resolveExports is much smaller than Node's real code. That makes each step easy to see.
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, requireNode checks the keys in exports from top to bottom.
./dist/cart.cjsnode matches first. Inside it, require matches, so the CommonJS file is picked.
The conditions are node and require. resolveExports picks ./dist/cart.cjs.
With node and require, the function picks ./dist/cart.cjs. Switch to import and it picks ./dist/cart.mjs. The package name is the same. The file changes only because the list of conditions changed.
Conditions are not a trick based on file endings. They are part of how Node picks the file. So if your package offers both an import file and a require file, test both. The two files might not share the same values.
Module customization hooks
Most apps should let Node pick and load files the normal way. Some tools need to change that. For example, a tool may want to:
- send a special name to a file of its choice,
- load code from an unusual place, or
- change code before it runs.
For this, Node has customization hooks. A hook is a function you give Node, which Node then calls at a certain step of loading.
// 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);This code has two files. The first, hooks.mjs, holds the hooks. The second, registerer.mjs, tells Node to use them. Line by line:
- Lines 2 to 7 are the
resolvehook. Node calls it to pick the file for a request.specifieris the request text. - Lines 3 to 5 handle the name
cart-config. They return the address ofcart-config.jsnext to the file that asked.shortCircuit: truetells Node to stop here and skip the other hooks. - Line 6 handles every other name. It calls
nextResolve, which passes the request on, so Node does its normal work. - Lines 9 to 11 are the
loadhook. Node calls it to get a file's code. This one just passes the job on withnextLoad. - Lines 14 and 15 bring in
registerfrom Node and use it to add the hooks inhooks.mjs.
Hooks added with module.register() are asynchronous: Node may wait for them. They also run on a separate thread. A thread is a separate line of work that runs beside your app. So a hook can't change your app's global variables directly. To talk to your app, it needs a message channel or another planned way.
Newer Node versions also have registerHooks(). Its hooks are synchronous: they run straight away, on the same thread. It is a different tool with different rules.
Hooks are powerful, and that can make bugs harder to find. A few habits help:
- Keep hooks small.
- Call the next hook, unless you really mean to stop with
shortCircuit. - If changing files ahead of time with a build step is simpler, do that instead.
Remember when hooks run: while Node is loading, before the module you asked for runs.
Using require with ES modules
Newer Node versions let a CommonJS file use require() to load an ES module. This works only when the ES module, and every module it loads, is synchronous: it runs from start to end without waiting for anything.
require() then hands back the module's namespace object. That is one object that holds everything the ES module exports. Each named export is a property on it.
Which Node versions have this?
- In Node 22 before 22.12, you had to turn it on with the flag
--experimental-require-module. - From Node 22.12, it is on by default.
- It is still marked experimental, which means it may still change. Early 22.x versions printed a warning. Newer 22.x versions stay quiet unless you pass
--trace-require-module. - You can turn it off with
--no-experimental-require-module.
The Node-only example below expects the feature to be on.
// Node-only CommonJS fileconst cart = require("./cart.mjs");console.log(cart.total); // cart.mjsexport const total = 3;Line 2 loads cart.mjs with require. Line 3 prints 3, because line 6 exports total as 3. The object in cart is the same kind of object that import() gives you. The difference is that import() hands it over later, in a promise, while require() hands it over straight away.
There is one limit: top-level await. That means an await written directly in a module, outside any function. A module with top-level await has to wait. So does any module that loads it. require() can't wait, so it can't load such a module.
// 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 load
wait-for-price.mjswithrequire. - Line 9 has top-level await, so that module has to wait. Line 10 exports
price. requirethrows an error instead. Line 5 catches it and prints its code:ERR_REQUIRE_ASYNC_MODULE. The lesson's tests run this in real Node to check that output.
When a module needs to wait, load it with import() instead.
Practical debugging habits
When loading a module fails, don't start by changing paths at random. Find out three facts first:
- What kind of request is it: built-in, file path, or package?
- Which file did Node pick, and does the package make that path public?
- Which file is making the request?
These three facts usually point straight at the problem. These tools help:
require.resolve()shows the full path of the file Node would load.require.resolve.paths("cart-utils")shows the folders Node would search for a package.- Read the package's
exportsfield before you load a file deep inside the package. - If two files get different copies of one package, look for a second copy in a nearer
node_modulesfolder. - Use
import(), notrequire, when an ES module has top-level await.
The npm and module resolution lessons cover installing packages and choosing paths day to day. The goal here is different: see what Node actually decided before you change your code.
__dirnameinside a CommonJS file- Two
requirecalls that lead to the same cart file - The closest
node_modulesfolder - The
requirekey in a package'sexportsfield - A
.mjsfile that doesn't wait, loaded withrequire - An ES module that uses top-level await
Put each card in one group: wrapper or cache, resolution or exports, or ES modules or hooks. Read the explanation after each card.
Common misconceptions
- “require always runs the file again.” Not usually. If the file's full path is already in the cache, Node hands back the saved module instead.
- “The cache is keyed by the package name.” No, by the file's full path. Two files in different folders can ask for the same name and get different files.
- “exports is just a list of files for people to read.” No. Node enforces it. Loading by package name only works for the entry points it lists.
- “A loader hook is normal app code.” No. It runs while Node picks and loads files, before the module you asked for runs.
- “require can load any ES module.” No. It can't load an ES module that uses top-level await, or one that loads such a module.
The table below compares ideas that are easy to mix up. Read each row as: "this idea means this, and it does not mean that."
| The idea | What it means | What it does not mean |
|---|---|---|
| A module is in the cache | The same full file path gives back the same exports object, until someone deletes it from the cache. | It does not mean every request with the same text shares one object. Two folders can find two different files. |
| A resolve hook handles a request | It can pass the request on to Node's normal rules, or stop there on purpose with shortCircuit. | It does not run after the modules are already loaded. It runs while Node is picking the file. |
A package has an exports field | Only the paths it lists can be loaded by the package's name. | It is not just a hint for code editors or TypeScript. Node enforces it. |
require loads an ES module | It loads an ES module that doesn't wait, and hands back one object with all its exports. | It does not wait for top-level await like import() does. It throws an error instead. |
One last cache detail. When you ask for a built-in module with the node: start, like node:fs, Node always gives you the built-in. It ignores anything in the cache with the same name. That is one more reason to write node: for built-ins in Node code.
Practice exercises
This small function copies the wrapper's shape. What number does it print?
function wrapped(exports, require, module, __filename, __dirname) {
return arguments.length;
}
console.log(wrapped({}, () => ({}), { exports: {} }, "/shop/cart.js", "/shop"));It prints 5. A CommonJS wrapper gets five values: exports, require, module, __filename, and __dirname.
One file calls require twice for a cart file. Both times Node picks the same full path. What does the second require usually hand back?
The same file path usually gives back the same exports object. A second require does not make a new one.
Type the first node_modules folder that lookupPaths lists.
console.log(lookupPaths("/shop/cart/checkout")[0]);The first folder is /shop/cart/checkout/node_modules. The folders higher up come later.
Which file does resolveExports pick for the conditions node and require?
console.log(resolveExports(exportsField, ["node", "require"]));It picks ./dist/cart.cjs. The import file is only for import requests.
A CommonJS file uses require to load an ES module with top-level await. What error code does Node give?
Node throws ERR_REQUIRE_ASYNC_MODULE when require reaches an ES module that uses top-level await. Use import() instead.
After an update, your checkout server says it can't load cart-utils/price. What should you check before you change the code that loads it?
First check the path Node picks and the package's exports field. require.resolve() shows the file Node would load. Then check that the package really makes that path public.
Check your understanding
For each question, ask yourself which part it is about: the wrapper, the cache, the folder search order, export conditions, or modules that wait.
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 7A file in
/shop/cart/checkoutrunsrequire("cart-utils"). Which folder does Node search first?Choose an answer to see the explanation.
Question 4 of 7A package's
exportsfield has several conditions, likeimport,require, anddefault. Which one does Node use?Choose an answer to see the explanation.
Question 5 of 7A request has the conditions
nodeandrequire. Node goes into thenodegroup, then reads itsrequirekey. This code reads the same key. What does it print?Read the code, then predictconst field = { node: { import: "./cart.mjs", require: "./cart.cjs" }, default: "./cart.js" }; console.log(field.node.require);Choose an answer to see the explanation.
Question 6 of 7What does a
resolvehook do?Choose an answer to see the explanation.
Question 7 of 7A CommonJS file uses
requireto load an ES module with top-level await. What happens?Choose an answer to see the explanation.
Key takeaways
- Node runs each CommonJS file inside a wrapper function with five values. So the file's own variables stay private.
require.cachesaves each loaded module under its full file path. The same path gives back the sameexportsobject.- Node first sorts the request: built-in, file path, or package. For a package, it searches
node_modulesfolders from nearest to farthest. - A package's
exportsfield lists its public entry points. Node uses the first condition that matches, so order matters. - Hooks added with
module.registerchange how Node picks and loads files. They run on a separate thread. requirecan load an ES module that doesn't wait. Top-level await gives the errorERR_REQUIRE_ASYNC_MODULE.
Remember the one-liner.
Node turns a request into one file, then hands back that file's shared values, from the cache or from a fresh run.
Coming next: Modules through a bundler. A bundler is a build tool that joins many module files into a few files. It has its own way of finding, loading, and updating modules.