How require() Works in Node.js: CommonJS Module Loading Explained
Node.js is built on the CommonJS (CJS) module system. The require() function is how you load modules — but what actually happens when you call it? Understanding this is especially useful when working with modules like the Node.js fs module or diagnosing why async I/O works the way it does. In this deep dive, we’ll trace the full lifecycle of require() from path resolution to returned exports, then compare it with the modern ES Module system.
What is CommonJS?
CommonJS defines how modules are loaded and shared in Node.js. Each file is a module that exposes functionality via module.exports or exports.
// math.js
const add = (a, b) => a + b;
module.exports = add;
// app.js
const add = require('./math');
console.log(add(2, 3)); // 5
When require('./math') is called, several things happen behind the scenes.
The 5 Steps of require()
- Resolve the module path — convert the identifier to an absolute filesystem path.
- Check the module cache — return the cached module if it’s been loaded before.
- Create a new module object — initialize a fresh module if not cached.
- Load and execute the module — read the file, wrap it in a closure, and run it.
- Return
module.exports— the result is passed back to the calling code.
How require() Works Internally
Here’s a simplified version of the internal implementation:
function require(moduleId) {
// 1. Resolve to an absolute path
const filename = Module._resolveFilename(moduleId, this);
// 2. Return cached module if available
if (Module._cache[filename]) {
return Module._cache[filename].exports;
}
// 3. Create a new module and cache it immediately
const module = new Module(filename);
Module._cache[filename] = module;
// 4. Load and execute the module
try {
module.load(filename);
} catch (err) {
delete Module._cache[filename]; // Clean up on failure
throw err;
}
// 5. Return the exports object
return module.exports;
}
Module Wrapping
Node.js doesn’t execute your module code directly. It wraps every file’s contents in a function before running it:
(function (exports, require, module, __filename, __dirname) {
// Your module code runs here
});
This wrapper provides each module with:
| Variable | What it is |
|---|---|
exports | Shorthand reference to module.exports |
require | The require function scoped to this module |
module | The current module object |
__filename | Absolute path of the current file |
__dirname | Directory containing the current file |
This is why modules don’t pollute the global scope — every variable you declare is local to the wrapper function.
Module Caching and Singletons
Node.js caches every module after it’s first loaded. Requiring the same module twice returns the same instance:
const mod1 = require('./moduleA');
const mod2 = require('./moduleA');
console.log(mod1 === mod2); // true — same reference
This singleton behavior is intentional and useful for shared state:
// db.js — connection is created once
const db = createDatabaseConnection();
module.exports = db;
// user.js
const db = require('./db'); // same connection
// order.js
const db = require('./db'); // same connection — not a new one
Implication: If a module exports mutable state and one file mutates it, all other files see the mutation.
Module Path Resolution Order
When you call require(identifier), Node.js resolves it in this order:
- Core modules (
require('fs'),require('http')) — loaded from the Node.js binary immediately. - Relative/absolute paths (
require('./utils'),require('/abs/path')) — resolved to the filesystem. node_moduleslookup — if no path prefix, Node.js searches:./node_modules/../node_modules/../../node_modules/- … up to the filesystem root
For a file at /project/src/app.js requiring lodash:
/project/src/node_modules/lodash
/project/node_modules/lodash ← typically found here
/node_modules/lodash
File Extension Resolution
When you require('./math') without an extension, Node.js tries:
./math.js./math.json./math.node(compiled native addon)./math/index.js./math/index.json
Circular Dependencies
CommonJS handles circular dependencies by returning the partially-constructed exports object at the time of the circular call.
// a.js
const b = require('./b');
console.log('in a, b.done:', b.done);
exports.done = true;
// b.js
const a = require('./a');
console.log('in b, a.done:', a.done); // undefined! (a not yet finished)
exports.done = true;
// main.js
require('./a');
Output:
in b, a.done: undefined
in a, b.done: true
When b.js requires a.js while a.js is still loading, Node.js returns a’s exports as they exist at that moment — an empty object {}. a.done is undefined because exports.done = true hasn’t run yet.
To avoid circular dependency problems:
- Extract shared logic into a third module that neither A nor B depends on.
- Use lazy requires inside functions rather than at the top of the file.
- Restructure your dependency graph.
CommonJS vs ES Modules (ESM)
Node.js now supports both CommonJS and the modern ES Module system. Understanding the differences is essential for modern Node.js development.
| Feature | CommonJS (require) | ES Modules (import) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Evaluation | Dynamic (run-time) | Static (parse-time) |
| Tree-shaking | No | Yes |
__dirname / __filename | Available | Not available (use import.meta.url) |
Top-level await | No | Yes |
| File extension | .js or .cjs | .mjs or .js with "type": "module" |
| Cache | require.cache | Separate ESM cache |
Writing ES Modules in Node.js
Add "type": "module" to package.json:
{
"type": "module"
}
Then use import/export syntax:
// math.mjs (or .js with "type": "module")
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
// app.mjs
import { add, multiply } from './math.mjs';
console.log(add(2, 3)); // 5
__dirname in ESM
__dirname and __filename aren’t available in ESM. Use import.meta.url instead:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Dynamic Imports with import()
Both CJS and ESM support dynamic imports — loading modules on demand at runtime:
// Works in both CJS and ESM
async function loadPlugin(name) {
const plugin = await import(`./plugins/${name}.js`);
return plugin.default;
}
// Load only when needed
button.addEventListener('click', async () => {
const { heavyFeature } = await import('./heavy-feature.js');
heavyFeature();
});
Dynamic import() is useful for:
- Code splitting (load modules only when needed)
- Conditional loading based on runtime config
- Loading ESM modules from CJS files (can’t use static
importin CJS)
Loading ESM from CJS
Static import doesn’t work inside CJS files. Use dynamic import():
// In a .cjs file
async function main() {
// Can't: import { foo } from './esm-module.mjs';
// Can:
const { foo } = await import('./esm-module.mjs');
foo();
}
main();
Inspecting the Module Cache
You can inspect and manipulate the module cache at runtime:
// See all loaded modules
console.log(Object.keys(require.cache));
// Invalidate a cached module (force reload)
delete require.cache[require.resolve('./config')];
const freshConfig = require('./config'); // loads fresh copy
Cache invalidation is useful in development for hot-reloading configuration without restarting the process.
Key Takeaways
require()is a synchronous, cached module loader — it reads a file once and caches the result permanently.- Modules are singletons — every
require()for the same module returns the same exported object. - The module wrapper gives each file its own scope via
__filename,__dirname,exports, andmodule. - Circular dependencies work but return partial exports — restructure your code to avoid relying on them.
- ESM (
import/export) is the modern standard — static analysis enables tree-shaking and top-levelawait. - Use dynamic
import()for code splitting, conditional loading, and loading ESM from CJS. - Understanding
require()internals helps you debug module state issues, circular dependencies, and unexpected singletons.
Related Articles
Node.js File System Module: Read, Write & Manipulate Files
Master the Node.js fs module — async and sync methods for reading, writing, renaming, deleting, watching files, checking metadata with fs.stat, streaming large files, and using fs/promises with async/await.
Node.jsapp.use vs app.all in Express.js: Key Differences Explained
Learn the difference between app.use and app.all in Express.js — middleware vs route handling, partial vs exact path matching, error-handling middleware, router.use, and real auth middleware examples.
Node.jsIs Node.js Single-Threaded or Multi-Threaded? A Complete Guide
Understand how Node.js handles concurrency — single-threaded JavaScript execution, the event loop, libuv thread pool, worker threads for CPU tasks, the cluster module, and when to use each approach.
Written by
Aditya RawasFull-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.