Skip to content
Node.js nd modules 4 min read

Module Resolution Algorithm

Every time you write import x from 'y' or require('y'), Node.js runs a deterministic algorithm to turn that specifier string into an actual file on disk. Understanding this algorithm demystifies the classic “Cannot find module” error, explains why node_modules works the way it does, and clarifies how the exports field controls what a package lets you import. This page walks through each branch of the resolver from highest to lowest priority.

The three kinds of specifiers

Node classifies every specifier into one of three categories, and the category alone decides which resolution path runs.

Specifier kindExampleHow it resolves
Core / built-innode:fs, fsReturned directly from the binary, no filesystem lookup
Relative / absolute./utils.js, ../lib/db, /opt/app/xResolved against the importing file’s location
Bare (package)express, lodash/mergeSearched for in node_modules directories

A bare specifier is anything that does not start with node:, /, ./, or ../. That single distinction is the root of the entire algorithm.

Core module resolution

Built-in modules always win. When you import fs, path, or http, Node recognizes the name as a built-in and returns it immediately without ever touching the filesystem. The modern, unambiguous form uses the node: prefix, which guarantees you get the built-in even if a same-named package exists in node_modules.

import { readFile } from 'node:fs/promises';
import path from 'node:path';

const data = await readFile(path.join(import.meta.dirname, 'config.json'), 'utf8');
console.log(JSON.parse(data).name);

Always prefer the node: prefix for core modules. It is faster to resolve, self-documenting, and immune to being shadowed by a malicious or accidental node_modules package of the same name.

Relative and absolute paths

Specifiers beginning with ./, ../, or / point at a concrete file or directory. Node resolves them relative to the location of the file doing the importing, then applies extension and index resolution if the path does not name an exact file.

In CommonJS, extensions are optional. Given require('./utils'), Node tries in order:

./utils
./utils.js
./utils.json
./utils.node
./utils/  -> ./utils/index.js, then ./utils/index.json, ./utils/index.node

In ES modules, extensions are mandatory. import './utils' fails; you must write import './utils.js'. ESM also does not perform automatic index.js directory lookup unless a directory is reached through a package’s exports map. This strictness lets the ESM resolver run without extra filesystem probing.

// CommonJS — extension optional, index inferred
const utils = require('./utils');        // finds ./utils.js or ./utils/index.js

// ES modules — extension required, no implicit index
import { format } from './utils.js';     // must spell out .js

The node_modules walk-up

Bare specifiers trigger the most distinctive part of the algorithm. Starting from the directory of the importing file, Node looks for a node_modules folder containing the package. If it is not found, Node moves up one directory and tries again, repeating until it reaches the filesystem root.

For a file at /home/app/src/api/server.js importing express, Node checks:

/home/app/src/api/node_modules/express
/home/app/src/node_modules/express
/home/app/node_modules/express
/home/node_modules/express
/node_modules/express

The first match wins. This walk-up is what allows nested dependencies to ship their own copies of a package while shared dependencies live higher up the tree (flattened by npm during install).

Once the package directory is found, Node reads its package.json to decide which file inside it to load:

  1. If an exports field exists, it is consulted exclusively (see below).
  2. Otherwise, the main field names the entry file.
  3. If neither exists, Node falls back to index.js.
import merge from 'lodash/merge';   // resolves a subpath inside the lodash package
import express from 'express';      // resolves express's main/exports entry

How the exports field changes everything

When a package declares an exports field, it becomes the single source of truth for what can be imported, and deep paths into the package are blocked unless explicitly mapped. This is called encapsulation: consumers can only reach the entry points the author intended.

{
  "name": "toolkit",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./parser": "./dist/parser.mjs"
  }
}

With this map, import 'toolkit' and import 'toolkit/parser' work, but import 'toolkit/dist/internal.js' throws ERR_PACKAGE_PATH_NOT_EXPORTED even though the file exists. The nested object also enables conditional exports: the import and require keys let a package ship distinct ESM and CommonJS builds, and Node picks the right one based on how it was loaded.

Output:

node:internal/modules/esm/resolve - ERR_PACKAGE_PATH_NOT_EXPORTED:
Package subpath './dist/internal.js' is not defined by "exports" in
/home/app/node_modules/toolkit/package.json

If a previously-working deep import suddenly breaks after a dependency upgrade, the maintainer almost certainly added an exports field. Use the package’s documented public subpaths instead of reaching into dist/ or lib/.

Best practices

  • Use the node: prefix for every built-in import to make resolution explicit and tamper-resistant.
  • In ES modules, always write full file extensions; do not rely on CommonJS-style extension guessing.
  • Treat the node_modules walk-up as a fallback, not a feature — keep dependencies declared in your own package.json rather than relying on hoisting from a parent.
  • Import packages through their documented public entry points; avoid deep paths into dist/ that an exports field can revoke.
  • When authoring a package, define an exports field with import/require conditions to control your public surface and support both module systems.
  • Use import.meta.dirname (Node 20.11+) instead of recomputing paths from import.meta.url for filesystem operations relative to the current module.
Last updated June 14, 2026
Was this helpful?