cf.completefrontendCode editorOpen lab
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
    Trace a CommonJS loadExplain the wrapper, exports object, resolved filename, and cache entry created by a require call.
  • 02
    Predict resolutionFollow local paths, node_modules lookup folders, package exports, and ordered import or require conditions.
  • 03
    Choose 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.

Definition

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.

A CommonJS file receives five wrapper valuesJavaScript
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.

Step through a CommonJS wrapper call
Step 0 of 6Ready
Your turn: follow the blue line

Replay of instrumented teaching code. It models the CommonJS wrapper; it is not a Node debugger.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. 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. 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.

Two names can share one exported 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(","));

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.

A tiny relative requestJavaScript
// 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.

Start the resolver with the request shape
RequestKindFirst move
node:fsBuilt-in moduleUse the built-in module immediately.
./cartRelative requestTry a nearby file and CommonJS file or folder rules.
cart-utilsBare package requestWalk node_modules folders from nearest to farthest.
#configPackage importRead the nearest package's imports mapping.
cart-utils/pricePackage subpathCheck 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 ./.

Step through lookup and exports resolution
Step 0 of 6Ready
Your turn: follow the blue line

Replay of the lesson's lookup and conditional-export models. It simplifies Node's complete resolver to make the ordered decisions visible.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const packageFile = resolveExports(exportsField, ["node", "require"]);console.log(paths[0]);console.log(packageFile);
CallStoreChangeResultRun = next line. 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

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.

Teaching model: list package lookup foldersPop 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"));

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.

Real-life analogyLooking for a library book

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.

A package chooses import or require targetsJSON
{
  "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.

Playground: select one package request mode
Conditional exports modelJavaScript
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"]));
Chosen entryrequire
conditionsnode, require

Conditions are checked in the order of the exports object.

target./dist/cart.cjs

The CommonJS branch wins after node.

Try it yourself
Requested module mode

The active conditions are node, require. The model chooses ./dist/cart.cjs.

This is a teaching model of ordered conditional exports. Node also validates targets and resolves the selected file.

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 asynchronous module.register hook sketchJavaScript
// 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: require a synchronous ES moduleJavaScript
// 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: require cannot 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;

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 exports before 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.

Put each fact with its loading layer
  • __dirname inside a CommonJS file
  • Two requires of the same resolved cart file
  • The closest node_modules folder
  • The require branch in package exports
  • A synchronous .mjs module required from CommonJS
  • An ES module graph using top-level await
Try it yourself
0 of 6 correct

Sort the cards by wrapper/cache, resolver/exports, or ESM/hooks. Read the explanation after each placement.

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.” 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.
Similar module ideas that need different mental models
IdeaWhat it meansDo not confuse it with
A module is cachedThe same resolved filename returns the same exports object until its cache entry changes.Every textual require request shares one object.
A hook resolves a requestIt can pass the request on or deliberately short-circuit the next resolver.Hooks run after all modules are already loaded.
A package has exportsOnly declared package paths are public to package-name imports.The field only changes TypeScript types.
require reads ESMIt 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

Exercise 1 · Warm-upCount wrapper arguments

Predict the number printed by this tiny wrapper call.

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 the cache result

    A cart file is required twice from the same caller and its resolved filename is identical. What does the second require normally return?

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

      Exercise 3 · PracticeFollow the first shelf

      Type the first node_modules folder this teaching model checks.

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

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

        Exercise 4 · PracticePick an export branch

        What file should the model choose for node plus require conditions?

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

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

          Exercise 5 · ChallengePredict the async-module error

          Predict the error code reported when require reaches an ESM module with top-level await.

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

            Exercise 6 · ChallengeApply this to a real app

            Your checkout server says it cannot load cart-utils/price after an upgrade. What should you inspect before changing the import?

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

              Check your understanding

              Answer by separating scope, cache identity, resolution order, exports conditions, and synchronous versus asynchronous loading.

              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 7For require("cart-utils") from /shop/cart/checkout, which place is checked first?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does ordered conditional exports mean?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does the lesson model choose for these conditions?

                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" };
                const conditions = ["node", "require"];
                console.log(field.node.require);

                Choose an answer to see the explanation.

              6. Question 6 of 7What is the job of a module resolve hook?

                Choose an answer to see the explanation.

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

              CompleteFrontend Clear concepts. Working examples.