completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Follow one require callSay what the wrapper function gives each CommonJS file, and how require.cache saves a loaded file under its full path.
  • 02
    Predict which file Node picksSort a request into a built-in, a file path, or a package. Then follow the node_modules folder search and the exports conditions, in order.
  • 03
    Pick the right way to loadSay what a loader hook does. Explain why require can 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:

  1. It works out what kind of request this is.
  2. It finds exactly one file for it.
  3. It runs that file, unless it already ran it before.
  4. It hands back the values that file shares.

Knowing these jobs makes many confusing errors easier to read.

Definition

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 and module.exports to share.
  • ES modules (ESM) use import and export.

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 cart at the top of one file can't clash with a const cart at the top of another file.
  • It hands the file some helper names, like require and module.
A CommonJS file gets five values from its wrapperJavaScript
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:

  1. Line 1 prints object, because module is an object.
  2. Line 2 prints object, because exports is an object.
  3. Line 3 prints function, because require is a function.
  4. Line 4 prints string, because __dirname is the folder path as text.
  5. 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 holds module.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.

Five value cards, exports, require, module, __filename and __dirname, are passed into a box called cart.js. Inside the box is a card called cart, which stays private to that file.exportsrequiremodule__filename__dirnamepassed incartcart.jscart stays inside
Node wraps each CommonJS file in a function and passes in five values. A variable such as cart lives inside that function, so it stays private to cart.js.
Step through: Node calls the wrapper for one file
Step 0 of 6Ready
Your turn: follow the blue line

A replay of small lesson code that acts out the CommonJS wrapper. It is not Node's own code.

Running in
  1. script
Next: line 1
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Real-life analogyLunch boxes and a fridge

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.cache keeps a loaded module
In real life: Two people who ask get the same packed box
In JavaScript: Two require calls get the same exports object

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.

Two names can share one objectPop out in the code editor (opens in a new tab)JavaScript
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:

  1. Line 1 makes one object and calls it first.
  2. Line 2 gives the same object a second name, second. It does not copy it.
  3. Line 3 prints true. === checks whether both names point to the very same object.
  4. Line 4 adds "milk" to the list, using the name second.
  5. Line 5 prints tea,milk, using the name first. 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.

Two name tags, first and second, both point to one object. The object holds cart: tea, milk.firstsecondcart: tea, milkobjectone shared object
Line 2 does not copy the object. Both names point to the same one, so a change through 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, like node:fs.
  • Text that starts with ./, ../, or / is a file path.
  • A plain name like cart-utils is called a bare name. It usually means a package. A package is code someone published, usually installed with npm.
Load a file next to this oneJavaScript
// 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."

What Node does first for each kind of request
When you writeWhat kind of request it isWhat Node does first
node:fsA built-in module, which comes with Node.Node uses its own built-in module straight away.
./cartA 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-utilsA bare name, which usually means a package.Node searches node_modules folders, from the nearest one up to the top.
#configA private name inside your own package.Node reads the imports field in your own package.json.
cart-utils/priceA 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:

  • .cjs is always CommonJS.
  • .mjs is always an ES module.
  • .js follows the type field in the nearest package.json file.

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.

Step through: search the folders, then pick a file from exports
Step 0 of 6Ready
Your turn: follow the blue line

A replay of the lesson's two small functions: the folder search and the exports choice. Node's real rules have more cases.

Running in
  1. script
Next: line 1
const packageFile = resolveExports(exportsField, ["node", "require"]);console.log(paths[0]);console.log(packageFile);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Walking node_modules folders

npm installs packages into folders called node_modules. For a bare name, Node searches these folders in order:

  1. It looks in the node_modules folder next to the file that asked.
  2. If the package isn't there, it moves up one folder and looks in that folder's node_modules.
  3. 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.

A small copy of Node's search orderPop out in the code editor (opens in a new tab)JavaScript
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_modules to the end.
  • Line 12 prints the list, one path per line.

For /shop/cart/checkout, the function prints three paths, nearest first:

  1. /shop/cart/checkout/node_modules
  2. /shop/cart/node_modules
  3. /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.

Starting from /shop/cart/checkout, Node checks three node_modules folders in order: first /shop/cart/checkout/node_modules, then /shop/cart/node_modules, then /shop/node_modules.lookupPaths("/shop/cart/checkout")/shop/cart/checkout/node_modules/shop/cart/node_modules/shop/node_modules1. look here2. then here3. last
Node looks in the nearest node_modules folder first. If the package is not there, it moves up one folder and looks again.
Real-life analogyLooking for a library book

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_modules folder
In real life: The family shelf one floor up
In JavaScript: The node_modules folder one folder up
In real life: More shelves further up
In JavaScript: More node_modules folders 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.

A package picks one file for import and another for requireJSON
{
  "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:

  • import is used by import statements and import().
  • require is used by CommonJS require().
  • default is 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.

Playground: load the package with import or require
A small copy of how Node picks a file from exportsJavaScript
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"]));
Picked filerequire
conditionsnode, require

Node checks the keys in exports from top to bottom.

file./dist/cart.cjs

node matches first. Inside it, require matches, so the CommonJS file is picked.

Try it yourself
How the code loads the package

The conditions are node and require. resolveExports picks ./dist/cart.cjs.

resolveExports is a small function written for this lesson. Node's real code also checks that the picked file is allowed and that it exists.

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.

Left: the conditions node and require choose ./dist/cart.cjs. Right: the conditions node and import choose ./dist/cart.mjs.requirenoderequire./dist/cart.cjsimportnodeimport./dist/cart.mjs
The package name is the same on both sides. Only the conditions change, and they pick a different file.

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: a sketch of two hooks registered with module.registerJavaScript
// 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 resolve hook. Node calls it to pick the file for a request. specifier is the request text.
  • Lines 3 to 5 handle the name cart-config. They return the address of cart-config.js next to the file that asked. shortCircuit: true tells 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 load hook. Node calls it to get a file's code. This one just passes the job on with nextLoad.
  • Lines 14 and 15 bring in register from Node and use it to add the hooks in hooks.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: require an ES module that doesn't waitJavaScript
// 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: require can't wait for top-level awaitJavaScript
// 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;
  1. Line 3 tries to load wait-for-price.mjs with require.
  2. Line 9 has top-level await, so that module has to wait. Line 10 exports price.
  3. require throws 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:

  1. What kind of request is it: built-in, file path, or package?
  2. Which file did Node pick, and does the package make that path public?
  3. 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 exports field 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_modules folder.
  • Use import(), not require, 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.

Sort each fact into the part of loading it belongs to
  • __dirname inside a CommonJS file
  • Two require calls that lead to the same cart file
  • The closest node_modules folder
  • The require key in a package's exports field
  • A .mjs file that doesn't wait, loaded with require
  • An ES module that uses top-level await
Try it yourself
0 of 6 correct

Put each card in one group: wrapper or cache, resolution or exports, or ES modules or hooks. Read the explanation after each card.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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."

Module ideas that are easy to mix up
The ideaWhat it meansWhat it does not mean
A module is in the cacheThe 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 requestIt 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 fieldOnly 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 moduleIt 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

Exercise 1 · Warm-upCount the wrapper values

This small function copies the wrapper's shape. What number does it print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function wrapped(exports, require, module, __filename, __dirname) {
  return arguments.length;
}
console.log(wrapped({}, () => ({}), { exports: {} }, "/shop/cart.js", "/shop"));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName what the cache hands back

    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?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the first folder Node searches

      Type the first node_modules folder that lookupPaths lists.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      console.log(lookupPaths("/shop/cart/checkout")[0]);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePick the file from exports

        Which file does resolveExports pick for the conditions node and require?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        console.log(resolveExports(exportsField, ["node", "require"]));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePredict the error for a module that waits

          A CommonJS file uses require to load an ES module with top-level await. What error code does Node give?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply this to a real app

            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?

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              Node module loading quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why can a CommonJS file use require, module, and __dirname?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const first = { item: "tea" };
                const second = first;
                console.log(first === second);

                Choose an answer to see the explanation.

              3. Question 3 of 7A file in /shop/cart/checkout runs require("cart-utils"). Which folder does Node search first?

                Choose an answer to see the explanation.

              4. Question 4 of 7A package's exports field has several conditions, like import, require, and default. Which one does Node use?

                Choose an answer to see the explanation.

              5. Question 5 of 7A request has the conditions node and require. Node goes into the node group, then reads its require key. This code reads the same key. What does it print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const field = { node: { import: "./cart.mjs", require: "./cart.cjs" }, default: "./cart.js" };
                console.log(field.node.require);

                Choose an answer to see the explanation.

              6. Question 6 of 7What does a resolve hook do?

                Choose an answer to see the explanation.

              7. Question 7 of 7A CommonJS file uses require to 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.cache saves each loaded module under its full file path. The same path gives back the same exports object.
              • Node first sorts the request: built-in, file path, or package. For a package, it searches node_modules folders from nearest to farthest.
              • A package's exports field lists its public entry points. Node uses the first condition that matches, so order matters.
              • Hooks added with module.register change how Node picks and loads files. They run on a separate thread.
              • require can load an ES module that doesn't wait. Top-level await gives the error ERR_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.

              CompleteFrontend Clear concepts. Working examples.