Understanding Node.js ESM Module Resolution Algorithm
The Node.js ESM Resolution Algorithm has only two responsibilities:
- Resolve the specifier in
importto a final URL - Determine the module format corresponding to that URL
For example:
import react from "react";
import util from "./utils.js";
import config from "#config";
Node.js ultimately needs to obtain:
react -> file:///project/node_modules/react/index.js
./utils.js -> file:///project/src/utils.js
#config -> file:///project/src/config/index.js
And:
module
commonjs
json
wasm
And other module formats.
The entire ESM specification essentially describes:
specifier
↓
resolved URL
↓
module format
↓
load
↓
execute
1. ESM_RESOLVE Overall Flow
The entry algorithm for Node.js ESM:
ESM_RESOLVE(specifier, parentURL)
Can be simplified to:
Determine specifier type
URL
path
#imports
bare package name
↓
Parse to get URL
↓
Check if file is valid
↓
Determine module format
↓
Return to Loader
The core goal of the entire algorithm:
specifier
↓
unique URL
Rather than the constant guessing used in CommonJS.
2. Specifier Classification
Node.js divides specifiers into four categories.
1. URL Specifier
For example:
import x from "file:///app/src/a.js";
Or:
import x from "data:text/javascript,export default 1";
If it's already a valid URL:
new URL(specifier)
Succeeds.
Then return directly.
2. Relative Specifier
For example:
import x from "./foo.js";
import y from "../bar.js";
Node.js resolves based on the current module's location.
Assuming:
file:///app/src/main.js
Executing:
import "./utils.js";
Results in:
file:///app/src/utils.js
Essentially:
new URL("./utils.js", parentURL)
3. Package Imports
For example:
import db from "#db";
Or:
import logger from "#utils/logger";
Starts with:
#
Enters:
PACKAGE_IMPORTS_RESOLVE()
Reads the current package:
{
"imports": {
"#db": "./src/db/index.js",
"#utils/*": "./src/utils/*.js"
}
}
For example:
import db from "#db";
Ultimately obtains:
./src/db/index.js
4. Bare Specifier
For example:
import react from "react";
import lodash from "lodash";
import axios from "axios";
Neither:
URL
path
#import
Is a bare package name.
Enters:
PACKAGE_RESOLVE()
Starts searching node_modules.
3. PACKAGE_RESOLVE
This is the core logic for Node.js to find npm packages.
For example:
import react from "react";
Current file:
/app/src/pages/home/index.js
Node.js keeps searching upward:
/app/src/pages/home/node_modules/react
/app/src/pages/node_modules/react
/app/src/node_modules/react
/app/node_modules/react
/node_modules/react
Until found.
Equivalent to:
while(currentDirectory){
查找 node_modules/packageName
找不到继续向上
}
4. Reading package.json
After finding the package:
node_modules/react/package.json
Node.js starts reading the configuration.
Priority order:
exports
↓
main
↓
direct path
5. exports Mechanism
Modern Node.js package resolution almost entirely relies on exports.
For example:
{
"exports": {
".": "./dist/index.js",
"./jsx-runtime": "./dist/jsx-runtime.js"
}
}
Allows:
import React from "react";
Corresponds to:
.
Obtains:
./dist/index.js
Allows:
import jsx from "react/jsx-runtime";
Corresponds to:
./jsx-runtime
Obtains:
./dist/jsx-runtime.js
However:
import internal from "react/internal";
If:
"./internal"
Doesn't exist in exports, it errors with:
Package Path Not Exported
6. Nature of exports
exports can be understood as:
the public API a package exposes externally
For example:
{
"exports": {
".": "./dist/index.js",
"./api": "./dist/api.js"
}
}
Allows:
import x from "my-lib";
import y from "my-lib/api";
Prohibits:
import z from "my-lib/dist/internal.js";
Even if the file actually exists.
7. Conditional Exports
exports doesn't have to be a string.
Can be an object.
For example:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
Node.js selects based on conditions.
ESM:
import x from "lib";
Matches:
"import"
Obtains:
./dist/index.mjs
CommonJS:
require("lib");
Matches:
"require"
Obtains:
./dist/index.cjs
8. PACKAGESELFRESOLVE
Assuming:
{
"name": "my-lib",
"exports": {
".": "./src/index.js",
"./core": "./src/core.js"
}
}
The current code is inside:
my-lib
Then:
import core from "my-lib/core";
Won't search external node_modules.
Node.js will detect:
current package name is my-lib
Directly uses the current package.json's exports.
This is:
PACKAGE_SELF_RESOLVE
9. imports Mechanism
imports is very similar to exports.
The difference:
exports is for others to use
imports is for yourself to use
For example:
{
"imports": {
"#db": "./src/db/index.js",
"#utils/*": "./src/utils/*.js"
}
}
Then:
import db from "#db";
Actual resolution:
./src/db/index.js
Another example:
import stringUtil from "#utils/string.js";
Obtains:
./src/utils/string.js
10. Pattern Match
imports and exports support wildcards.
For example:
{
"exports": {
"./features/*": "./src/features/*.js"
}
}
Executing:
import user from "pkg/features/user";
Matches:
*
Obtains:
user
Finally:
./src/features/user.js
11. PACKAGETARGETRESOLVE
This is where exports/imports actually perform mapping.
Supports four types.
String
{
"exports": {
".": "./dist/index.js"
}
}
Resolves directly.
Object
{
"exports": {
".": {
"node": "./node.js",
"browser": "./browser.js",
"default": "./index.js"
}
}
}
Selects based on conditions.
Array
{
"exports": {
".": [
"./native.js",
"./fallback.js"
]
}
}
If the first one fails.
Continues trying the next.
null
{
"exports": {
"./internal/*": null
}
}
Explicitly prohibits export.
12. ESMFILEFORMAT
After URL location is complete.
Node.js needs to determine:
what format should this file be loaded as?
.mjs
module
.cjs
commonjs
.json
json
.wasm
wasm
.node
addon
Native extension module.
13. role of type Field
For:
.js
Files.
Node.js searches for the nearest package.json.
For example:
{
"type": "module"
}
Then:
app.js
Is interpreted as:
ESM
If:
{
"type": "commonjs"
}
Then:
app.js
Is interpreted as:
CommonJS
Therefore:
.mjs
Is always ESM.
.cjs
Is always CommonJS.
.js
Depends on type.
14. Why ESM Doesn't Support Directory Import
CommonJS:
require("./foo");
Node.js will try:
foo.js
foo.json
foo.node
foo/index.js
foo/index.json
ESM:
import "./foo";
Doesn't guess.
If:
foo
Is a directory.
Errors directly:
Unsupported Directory Import
Correct写法:
import "./foo/index.js";
Or:
import "./foo.js";
15. LOOKUPPACKAGESCOPE
How does Node.js find the nearest package.json?
Algorithm:
current directory
↓
parent directory
↓
continue upward
↓
until root directory
For example:
/app/src/pages/home/index.js
Searches:
/app/src/pages/home/package.json
/app/src/pages/package.json
/app/src/package.json
/app/package.json
Stops at the first one found.
16. Custom ESM Resolver
Node.js default resolution:
import x from "./a.js";
import y from "react";
If you want to support:
import x from "@/utils";
What to do?
Answer:
Loader Hook
For example:
import { pathToFileURL } from "node:url";
import path from "node:path";
export async function resolve(
specifier,
context,
nextResolve
) {
if (specifier.startsWith("@/")) {
return {
url: pathToFileURL(
path.resolve(
process.cwd(),
"src",
specifier.slice(2)
)
).href,
shortCircuit: true
};
}
return nextResolve(specifier, context);
}
Run:
node --loader ./loader.mjs app.js
After that:
import util from "@/utils.js";
Will automatically map to:
src/utils.js
17. Complete Resolution Chain
Node.js ESM resolution can be summarized as:
import specifier
↓
ESM_RESOLVE
↓
Determine type
URL
path
#imports
bare package name
↓
PACKAGE_RESOLVE
↓
exports
imports
main
↓
Get final URL
↓
ESM_FILE_FORMAT
↓
module
commonjs
json
wasm
↓
Loader
↓
Execute
Summary
The core idea of Node.js ESM is not "finding files."
But rather:
specifier
↓
determine URL
↓
determine module format
↓
load and execute
The entire exports, imports, type, conditional exports, and loader mechanisms are fundamentally built around this goal.
The ESM resolution model is stricter, more static, and closer to the browser compared to CommonJS, making it more suitable for modern toolchains (Vite, Webpack, Rspack, Rollup, Turbopack) to analyze and optimize.