Understanding Node.js ESM Module Resolution Algorithm
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 get:
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
Node.js ESM's entry algorithm:
ESM_RESOLVE(specifier, parentURL)
Can be simplified to:
Determine specifier type
URL
Path
#imports
Bare package name
↓
Resolve to get URL
↓
Check if file is valid
↓
Determine module format
↓
Return to Loader
The core goal of the entire algorithm:
specifier
↓
Unique URL
Instead of constantly guessing like CommonJS does.
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 directly return.
2. Relative Specifier
For example:
import x from "./foo.js";
import y from "../bar.js";
Node.js resolves based on the current module location.
Assume:
file:///app/src/main.js
Execute:
import "./utils.js";
Get:
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:
#
Will enter:
PACKAGE_IMPORTS_RESOLVE()
Read current package:
{
"imports": {
"#db": "./src/db/index.js",
"#utils/*": "./src/utils/*.js"
}
}
For example:
import db from "#db";
Ultimately get:
./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
Then it belongs to bare package name.
Enter:
PACKAGE_RESOLVE()
Start searching node_modules.
3. PACKAGE_RESOLVE
This is Node.js's core logic for finding npm packages.
For example:
import react from "react";
Current file:
/app/src/pages/home/index.js
Node.js will keep 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){
Find node_modules/packageName
If not found, continue upward
}
4. Reading package.json
After finding the package:
node_modules/react/package.json
Node.js starts reading the configuration.
Priority is as follows:
exports
↓
main
↓
Direct path
5. exports Mechanism
Modern Node.js package resolution almost entirely depends on exports.
For example:
{
"exports": {
".": "./dist/index.js",
"./jsx-runtime": "./dist/jsx-runtime.js"
}
}
Allows:
import React from "react";
Corresponds to:
.
Get:
./dist/index.js
Allows:
import jsx from "react/jsx-runtime";
Corresponds to:
./jsx-runtime
Get:
./dist/jsx-runtime.js
But:
import internal from "react/internal";
If it doesn't exist in exports:
"./internal"
Then error:
Package Path Not Exported
6. Nature of exports
exports can be understood as:
The public API exposed by the package
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 will select based on conditions.
ESM:
import x from "lib";
Matches:
"import"
Get:
./dist/index.mjs
CommonJS:
require("lib");
Matches:
"require"
Get:
./dist/index.cjs
8. PACKAGESELFRESOLVE
Assume:
{
"name": "my-lib",
"exports": {
".": "./src/index.js",
"./core": "./src/core.js"
}
}
Current code is inside:
my-lib
Then:
import core from "my-lib/core";
Won't search external node_modules.
Node.js will find:
Current package name is my-lib
Directly use current package.json's exports.
This is:
PACKAGE_SELF_RESOLVE
9. imports Mechanism
imports is very similar to exports.
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";
Get:
./src/utils/string.js
10. Pattern Match
imports and exports support wildcards.
For example:
{
"exports": {
"./features/*": "./src/features/*.js"
}
}
Execute:
import user from "pkg/features/user";
Match:
*
Get:
user
Finally:
./src/features/user.js
11. PACKAGETARGETRESOLVE
This is where exports/imports actually perform the mapping.
Supports four types.
String
{
"exports": {
".": "./dist/index.js"
}
}
Directly resolve.
Object
{
"exports": {
".": {
"node": "./node.js",
"browser": "./browser.js",
"default": "./index.js"
}
}
}
Select based on conditions.
Array
{
"exports": {
".": [
"./native.js",
"./fallback.js"
]
}
}
If the first one fails.
Continue trying the next one.
null
{
"exports": {
"./internal/*": null
}
}
Explicitly prohibits export.
12. ESMFILEFORMAT
After URL positioning 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 will search 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
Always ESM.
.cjs
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";
Won't guess.
If:
foo
Is a directory.
Directly error:
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
Search:
/app/src/pages/home/package.json
/app/src/pages/package.json
/app/src/package.json
/app/package.json
Find the first one and stop.
16. Custom ESM Resolver
Node.js default resolves:
import x from "./a.js";
import y from "react";
If 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:
specifier
↓
Determine URL
↓
Determine module format
↓
Load and execute
The entire exports, imports, type, conditional exports, and loader mechanisms are essentially built around this goal.
The ESM resolution model is stricter, more static, and more similar to the browser compared to CommonJS, making it more suitable for modern toolchains (Vite, Webpack, Rspack, Rollup, Turbopack) to analyze and optimize.