Node.js October 23, 2024 Aditya Rawas

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()

  1. Resolve the module path — convert the identifier to an absolute filesystem path.
  2. Check the module cache — return the cached module if it’s been loaded before.
  3. Create a new module object — initialize a fresh module if not cached.
  4. Load and execute the module — read the file, wrap it in a closure, and run it.
  5. 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:

VariableWhat it is
exportsShorthand reference to module.exports
requireThe require function scoped to this module
moduleThe current module object
__filenameAbsolute path of the current file
__dirnameDirectory 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:

  1. Core modules (require('fs'), require('http')) — loaded from the Node.js binary immediately.
  2. Relative/absolute paths (require('./utils'), require('/abs/path')) — resolved to the filesystem.
  3. node_modules lookup — 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:

  1. ./math.js
  2. ./math.json
  3. ./math.node (compiled native addon)
  4. ./math/index.js
  5. ./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.

FeatureCommonJS (require)ES Modules (import)
Syntaxrequire() / module.exportsimport / export
LoadingSynchronousAsynchronous
EvaluationDynamic (run-time)Static (parse-time)
Tree-shakingNoYes
__dirname / __filenameAvailableNot available (use import.meta.url)
Top-level awaitNoYes
File extension.js or .cjs.mjs or .js with "type": "module"
Cacherequire.cacheSeparate 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 import in 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, and module.
  • 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-level await.
  • 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.
Aditya Rawas

Written by

Aditya Rawas

Full-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.