CommonJS: require() and module.exports
The Module System That Built Node.js
Before ES modules existed, CommonJS was the only game in town for server-side JavaScript. Every require() call you've ever written in Node.js uses CommonJS. npm's entire ecosystem was built on it. And even today, the majority of npm packages ship CommonJS.
Understanding how CommonJS works isn't just history — it's essential for debugging module resolution errors, understanding why some packages don't tree-shake, and navigating the ESM migration that's still ongoing.
Think of require() like a synchronous file reader with a cache. The first time you require a file, Node.js reads it, wraps it in a function, executes it, and stores the result. Every subsequent require of the same file returns the cached result — the file never executes twice. It's like a library where the first person to check out a book gets a fresh copy, but everyone after that gets a photocopy of the first person's copy.
The Module Wrapper Function
Here's something most developers don't know: Node.js never executes your module code directly. Before running your file, Node.js wraps it in a function:
(function(exports, require, module, __filename, __dirname) {
// Your actual module code goes here
const helper = require('./helper');
module.exports = { helper };
});
This wrapper is why __dirname and __filename exist — they're not globals. They're parameters injected into every module. It's also why variables declared with var at the top level of a module don't pollute the global scope — they're local to this wrapper function.
Node.js uses vm.runInThisContext() internally (similar to eval, but without access to the local scope) to compile the wrapped source. The wrapper provides module-level scope isolation without the overhead of a separate V8 context. You can see the actual wrapper strings in Node.js source at lib/internal/modules/cjs/loader.js.
The five parameters of the wrapper give each module its own isolated environment:
// exports → shorthand reference to module.exports
// require → function to load other modules
// module → the module object itself (has .exports, .id, .filename, etc.)
// __filename → absolute path to this file
// __dirname → absolute path to this file's directory
module.exports vs exports — The Trap Everyone Falls Into
This is the single most confusing thing about CommonJS. Both module.exports and exports exist, and they start out pointing to the same object:
// At the start of every module, Node.js essentially does:
// exports = module.exports = {};
// Both point to the same empty object
exports.greet = function() { return 'hello'; };
// Works! You're adding a property to the shared object.
// module.exports is { greet: [Function] }
exports.farewell = function() { return 'bye'; };
// Also works! module.exports is { greet: [Function], farewell: [Function] }
But the moment you reassign exports, you break the link:
// This does NOT work as expected
exports = { greet: function() { return 'hello'; } };
// You just pointed 'exports' to a new object.
// module.exports is still {} — the empty object from the start.
// require() returns module.exports, not exports.
// So the consumer gets an empty object.
The fix is to always assign to module.exports when replacing the entire export:
// This works correctly
module.exports = { greet: function() { return 'hello'; } };
// Or export a single function/class
module.exports = function calculator() { /* ... */ };
exports is just a convenience reference to module.exports. It's a local variable, not some special binding. Reassigning it (exports = something) only changes the local variable — it doesn't affect what require() returns. The rule is simple: use exports.property = value for adding properties, and module.exports = value for replacing the entire export.
// calc.js
exports.add = (a, b) => a + b;
exports = { subtract: (a, b) => a - b };
// main.js
const calc = require('./calc');
console.log(calc);
// { add: [Function: add] }
// subtract is lost — exports was reassigned to a new object,
// but require() returns module.exports, which only has add.
How require() Actually Works
When you call require('./math'), Node.js follows a precise algorithm:
Two critical details:
It's synchronous. require() blocks the event loop until the file is read and executed. This is fine at startup (you're loading modules before handling requests), but calling require() inside a hot request handler would be a performance disaster.
It's cached. After the first require(), the module object is stored in require.cache (an alias for Module._cache). Every subsequent require() of the same file returns the same module.exports object — the file is never re-executed.
// The cache is a plain object keyed by absolute file path
console.log(require.cache);
// {
// '/Users/you/project/app.js': Module { ... },
// '/Users/you/project/math.js': Module { ... },
// ...
// }
// You can even delete from the cache to force re-loading
delete require.cache[require.resolve('./math')];
const freshMath = require('./math'); // Re-reads and re-executes the file
Circular Dependencies — The Part That Surprises Everyone
What happens when module A requires module B, and module B requires module A? In most languages, this would be an error or an infinite loop. CommonJS handles it, but the result might surprise you:
// a.js
console.log('a.js: starting');
exports.done = false;
const b = require('./b'); // Pauses a.js, starts executing b.js
console.log('a.js: b.done =', b.done);
exports.done = true;
console.log('a.js: finished');
// b.js
console.log('b.js: starting');
exports.done = false;
const a = require('./a'); // Gets a PARTIAL copy of a's exports
console.log('b.js: a.done =', a.done); // false! a hasn't finished yet
exports.done = true;
console.log('b.js: finished');
The output:
a.js: starting
b.js: starting
b.js: a.done = false // <-- a.js wasn't done yet!
b.js: finished
a.js: b.done = true
a.js: finished
The key insight: when Node.js detects a circular dependency, it doesn't crash or loop. It returns whatever has been exported so far — a partially completed exports object. This is why circular dependencies are technically "supported" in CommonJS but can lead to subtle bugs where you get undefined for properties that haven't been set yet.
Circular dependencies in CommonJS give you a partially populated exports object, not an error. Your code runs without crashing, but properties that haven't been assigned yet are undefined. This makes the bug silent and hard to track down. The fix is to restructure your code to eliminate the cycle, or at minimum, to not rely on properties that might not be set during the circular load.
require.resolve — Looking Without Loading
Sometimes you need to know where a module lives without actually loading it. That's what require.resolve does:
// Returns the absolute path Node.js would load
require.resolve('./math');
// '/Users/you/project/math.js'
require.resolve('lodash');
// '/Users/you/project/node_modules/lodash/lodash.js'
// Throws MODULE_NOT_FOUND if the module doesn't exist
require.resolve('nonexistent-package');
// Error: Cannot find module 'nonexistent-package'
This is useful for checking if a module exists (wrap in try/catch), finding the actual file path for a package, or building custom module loading logic.
CommonJS in the Wild — Patterns You'll See
Exporting a single function or class
// logger.js — the most common pattern
module.exports = function log(message) {
console.log(`[${new Date().toISOString()}] ${message}`);
};
// usage
const log = require('./logger');
log('Server started');
Exporting a namespace of functions
// math.js
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
exports.multiply = (a, b) => a * b;
// usage
const math = require('./math');
math.add(2, 3);
// or destructure
const { add, subtract } = require('./math');
The singleton pattern (via caching)
// db.js — connection is created once, shared everywhere
const connection = createDatabaseConnection();
module.exports = connection;
// Both of these get the SAME connection object
// app.js
const db = require('./db');
// routes.js
const db = require('./db'); // Same object — from cache
| What developers do | What they should do |
|---|---|
| Using `exports = { ... }` to replace the entire export object `exports` is a local variable — reassigning it breaks the reference to `module.exports`. Only `module.exports` is returned by require(). | Use `module.exports = { ... }` to replace the entire export |
| Assuming `require()` returns a new object each time All consumers share the same object. Mutating it in one file affects every other file that required the same module. | Remember that `require()` returns the cached module.exports singleton |
| Using `require()` inside hot code paths (request handlers, loops) require() is synchronous and blocks the event loop. At the top level this is fine (runs once at startup). Inside a request handler, it blocks every request. | Move all require() calls to the top level of the module |
- 1require() is synchronous — it blocks until the file is loaded and executed
- 2Modules are cached by absolute path — the code runs once, every subsequent require() returns the same exports object
- 3Node.js wraps every module in a function that provides exports, require, module, __filename, and __dirname
- 4exports is just a shorthand for module.exports — reassigning exports breaks the link
- 5Circular dependencies return a partially populated exports object, not an error