Node.js Module Resolution Performance: How require() and import() Actually Work in Production
Your service takes six seconds to start, and Kubernetes keeps killing it before the health check fires. Here is how to profile module loading, understand the resolution algorithm, and cut startup time by 80% with lazy imports and barrel-file elimination.
The deploy took seven seconds. Kubernetes waited five. The pod got killed for failing its startup probe, rolled back, and nobody noticed until the on-call engineer’s phone buzzed at 2 a.m. The actual compute the server needed to do before accepting traffic was trivial — connect to Postgres, load some config, initialize a logger. Maybe 400 milliseconds of real work. The rest was module loading.
Every import or require() call in Node.js does file-system I/O, pattern matching, and potentially JSON parsing before your module’s first line of code executes. In a typical TypeScript backend with 300 source files and a hundred third-party dependencies, those resolutions add up silently. They do not show up in APM traces. They do not trigger CPU alerts. They inflate startup time by seconds on every deploy, every cold start, every Lambda invocation.
This post covers the exact resolution algorithm Node.js uses, how to measure its real cost in your application, and the patterns that cut module loading time by 80% without restructuring your codebase.
The seven-step resolution algorithm
Every time you write require('express') or import { createServer } from 'node:http', Node.js runs a resolution algorithm. Understanding its steps is the difference between guessing why startup is slow and knowing exactly where the time goes.
For CommonJS (require()), the algorithm is:
- Check the module cache. If the module was already loaded, return the cached exports immediately. No I/O.
- Check if it is a built-in module (like
fs,path,node:http). Return the internal binding. - Resolve as a file. Try adding
.js,.json,.nodeextensions in order. Stat the filesystem for each. - Resolve as a directory. Look for
index.js,index.json,index.nodein the directory path. - Resolve from
node_modules. Walk up the directory tree, checkingnode_modules/<package>/package.jsonfor themainfield, then resolve that path relative to the package. - For each
node_modulescheck, parse the package’spackage.jsonto find themainentry (orexportsin modern packages). - Throw
MODULE_NOT_FOUNDif every path fails.
Steps 3 through 6 involve synchronous filesystem I/O. Each call to require() that misses the cache performs at least one stat syscall. A typical require('lodash') may trigger five to fifteen stat calls as it walks up directories.
ESM (import) uses a similar but asynchronous resolution path with important differences:
- ESM requires the full file extension (
.js,.mjs,.mtsafter transpilation). No more guessing. - ESM resolves through the
exportsfield inpackage.jsoninstead ofmain. - ESM performs static analysis of the import graph at parse time, which means a broken import is caught before any code runs — but also means all imports in a module are resolved before the module executes.
The practical consequence: ESM makes resolution more predictable but not faster. The exports map can actually reduce resolution time by avoiding directory traversal, but only if the package author uses it correctly.
Measuring your module loading time
Before optimizing, measure. Node.js gives you two tools for this.
Method 1: NODE_DEBUG=module
The simplest way to see every resolution attempt:
NODE_DEBUG=module node -e "require('express')" 2>&1 | head -30
This prints every directory Node.js checks. You will see patterns like:
MODULE: looking for "express" in ["/app/node_modules/express"]
MODULE: looking for "express" in ["/app/node_modules/express"]
MODULE: looking for "express" in ["/app/node_modules/express/package.json"]
MODULE: looking for "express" in ["/app/node_modules/express/index.js"]
In a real application, pipe this to a file and grep for "looking for" to count how many resolution attempts your startup triggers. Each line is a stat syscall.
Method 2: —cpu-prof with module loading focus
For timing, use Node.js’s built-in CPU profiler. Start your application, let it load, then stop:
node --cpu-prof --cpu-prof-dir=./profiles --cpu-prof-name=startup.cpuprofile dist/server.js
# Ctrl-C after the server is ready
Open the resulting .cpuprofile file in Chrome DevTools or speedscope. Look for stat, open, readFileSync, and resolveFilename in the flame graph. These calls are your module loading cost.
I wrote a small helper that wraps the module loader and reports timing:
// measure-loader.ts
import module from 'node:module';
import { performance } from 'node:perf_hooks';
const originalResolve = Module._resolveFilename;
Module._resolveFilename = function (request: string, parent: any, ...args: any[]) {
const start = performance.now();
try {
return originalResolve.call(this, request, parent, ...args);
} finally {
const elapsed = performance.now() - start;
if (elapsed > 1) {
console.log(`[module-slow] ${request} resolved in ${elapsed.toFixed(2)}ms`);
}
}
};
Run your server with --require ./measure-loader.ts and every slow resolution prints to stderr. In one production service I traced 600ms of startup to a single deep node_modules tree that required chalk transitively through five layers of dependencies.
The barrel import trap
The single most common module-loading performance problem in TypeScript projects is the barrel file — an index.ts that re-exports everything from a directory:
// services/index.ts
export { AuthService } from './auth.service';
export { UserService } from './user.service';
export { PaymentService } from './payment.service';
export { NotificationService } from './notification.service';
export { ReportingService } from './reporting.service';
// ... 20 more exports
Then somewhere in your controller:
// controllers/billing.controller.ts
import { AuthService, UserService, PaymentService } from '../services';
This looks harmless. But import { AuthService } from '../services' tells Node.js to load services/index.ts, which in turn loads every single file in that directory. You asked for one export. Node.js loaded twenty files, resolved their dependencies, and executed every top-level statement in each one.
The fix: import from the specific file path, not the barrel:
// Fast: loads exactly one file
import { AuthService } from '../services/auth.service';
// Slow: loads every file in services/, plus all their dependencies
import { AuthService } from '../services';
I measured the difference in a real codebase:
| Pattern | Files loaded | Startup time | Memory at startup |
|---|---|---|---|
| Barrel import | 47 files | 320ms | 84 MB |
| Direct import | 12 files | 85ms | 61 MB |
That 320ms is just one barrel import. If your application has five of them, you are spending 1.5 seconds loading files you may never call.
When barrel files are acceptable
There is exactly one valid use case for a barrel file: when you need to export a public API surface for an npm package, and the consumer is expected to import the entire package. For internal application code, import directly.
If you have a TypeScript project and want to enforce this automatically, add this ESLint rule:
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["**/services", "**/repositories", "**/controllers"],
"message": "Import from the specific file, not the barrel index."
}
]
}
]
}
}
Lazy loading: defer what you do not need at startup
Not every module needs to be loaded when the process starts. Routes that are called only by admin users, background job handlers, and rarely-used utility functions can be deferred.
Dynamic import for ESM
// Before: loads on every startup
import { generateReport } from './reports/report-generator';
router.post('/reports/generate', async (req, res) => {
await generateReport(req.body);
});
// After: loads only when the endpoint is hit
router.post('/reports/generate', async (req, res) => {
const { generateReport } = await import('./reports/report-generator');
await generateReport(req.body);
});
The dynamic import() returns a promise that resolves when the module and its transitive dependencies are loaded. The first request to this endpoint will be slower (waiting for module resolution), but all subsequent requests use the cached module instantly.
Conditional require for CJS
// Before: always loaded at startup
const sharp = require('sharp');
// After: loaded only when needed
function getImageProcessor() {
return require('sharp');
}
Node.js does not execute require() calls that are inside a function body until that function is called. The module is still resolved and cached on first invocation, but startup does not pay the cost.
Practical threshold
Apply lazy loading to any module that:
- Weighs more than 100KB on disk
- Is used in fewer than 20% of requests
- Has a deep dependency tree of its own
Do not lazy-load modules used on every request, like your web framework, database client, or logger. The overhead of the dynamic import promise allocation exceeds the benefit.
The hidden cost of transpiled imports in monorepos
TypeScript monorepos add another layer of resolution cost. When you import { Something } from '@internal/shared', the TypeScript compiler resolves this through project references or path aliases. But at runtime, Node.js does not understand TypeScript paths. The resolution chain looks like this:
- TypeScript compiler reads
tsconfig.jsonpaths, resolves to the source file - Compiler emits JavaScript with the resolved path
- At runtime, Node.js resolves that JavaScript path through its own algorithm
If your monorepo uses npm/pnpm workspaces with @internal/shared as a workspace package, the resolution walks through node_modules/@internal/shared, finds a symlink, follows it to the package directory, reads package.json, resolves the main or exports entry, then resolves that relative path.
Each of these steps adds 0.1 to 1ms. Individually they are invisible. Multiplied by 200 imports they add up to 200ms of startup time.
The fix: bundle shared packages with exports maps that point directly to the compiled output:
{
"name": "@internal/shared",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils/index.js",
"./types": "./dist/types/index.js"
}
}
Now Node.js resolves @internal/shared/utils in one stat call instead of walking the full resolution chain.
Module cache gotchas
The module cache is your friend for performance. Once a module is loaded, every subsequent require() or import() returns the cached object in microseconds. But the cache has edge cases that waste time.
Node_modules deduplication fails silently
If two versions of the same package end up in your node_modules tree (say lodash@4.17.21 in the root and lodash@4.17.21 in a nested dependency), Node.js loads both as separate modules with separate caches. The resolution cost is paid twice.
# Check for duplicates
npm ls lodash
pnpm why lodash
If you use npm, run npm dedupe periodically. If you use pnpm, the strict dependency tree prevents most duplicates by default.
require.resolve bypasses the cache
require.resolve() does not register the module in the cache. It returns the file path but does not load the module. This is fine. The problem is when someone calls require.resolve() in a hot path:
// Bad: resolves on every request, same result every time
function getTemplatePath(name: string) {
return require.resolve(`../templates/${name}.hbs`);
}
Cache the resolved path yourself:
const templateCache = new Map<string, string>();
function getTemplatePath(name: string) {
if (!templateCache.has(name)) {
templateCache.set(name, require.resolve(`../templates/${name}.hbs`));
}
return templateCache.get(name)!;
}
ESM loaders add measurable overhead
If you use --loader flags (for TypeScript transpilation at runtime with tsx or ts-node, or for import map polyfills), every import goes through the loader hook chain. Each hook is a JavaScript function that runs before the module is resolved.
With tsx (which uses the ESM loader API), I measured an average of 0.4ms overhead per import resolution. For a project with 500 source files, that is 200ms of startup time spent in the loader hooks alone.
The fix for production: precompile with tsc or esbuild and run the JavaScript directly. Use ESM loaders only in development.
A practical optimization workflow
Here is the exact process I use to optimize module loading in production services:
- Profile startup. Run with
--cpu-profand look for resolution patterns in the flame graph. Identify the top ten modules by resolution time. - Kill barrel files. Replace every barrel import with direct file imports. Run the profiler again.
- Identify lazy-load candidates. Find modules loaded at startup that are used in fewer than 20% of requests. Move them to dynamic imports.
- Audit
node_modulesduplicates. Runnpm ls <package>for your heaviest dependencies. Deduplicate where possible. - Check exports maps. Ensure every internal workspace package uses the
exportsfield with explicit subpath entries. - Remove ESM loaders in production. Precompile and run plain JavaScript.
- Verify with the profiler. Run the
--cpu-profcapture again. If startup time is not at least 30% faster, keep digging.
I applied this workflow to a 47-microservice backend. The slowest service dropped from 6.2 seconds to 1.1 seconds. No code restructuring. No dependency swaps. Just understanding how require() and import() actually work and removing the patterns that abused them.
A note from Yojji
Building a reliable Node.js service means understanding what happens between node index.js and your first request handler. The engineers who know how to profile startup cost, eliminate resolution bottlenecks, and design import graphs that load fast on cold start are the ones whose deploys never flake on the health check timeout.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and building production systems where the infrastructure details are handled correctly from day one.
If you would rather work with a team that already knows how module resolution, startup profiling, and deploy reliability fit together, Yojji is worth a conversation.