Skip to content

CommonJS: require() and module.exports

intermediate14 min read

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.

Mental Model

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
Quiz
Why can you use __dirname in a CommonJS module but not in an ES module?

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() { /* ... */ };
Common Trap

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.

Quiz
What does require('./calc') return when calc.js contains: exports.add = (a, b) => a + b; then exports = { subtract: (a, b) => a - b };?
// 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
Quiz
What happens when you require() the same module from two different files?

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');
Execution Trace
Start a.js
Execute a.js from the top
Node creates a module object for a.js and adds it to cache
a.js: exports.done = false
a.js exports = { done: false }
Partially populated exports
a.js: require('./b')
Execution pauses in a.js, starts b.js
a.js is in cache but not finished
b.js: exports.done = false
b.js exports = { done: false }
b.js starts executing
b.js: require('./a')
Returns a.js partial exports: { done: false }
a.js is in cache, so Node returns what it has so far
b.js: a.done = false
b.js sees a.done as false
a.js hasn't finished, so done is still false
b.js finishes
b.js exports = { done: true }
b.js completes, returns to a.js
a.js resumes
a.js gets b: { done: true }
b.js fully completed, so a.js gets the complete exports
a.js finishes
a.js exports = { done: true }
Both modules complete

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.

Common Trap

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
Quiz
A Node.js module calls require('./config') at the top level, and later a request handler also calls require('./config'). What happens during the request?
What developers doWhat 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
Key Rules
  1. 1require() is synchronous — it blocks until the file is loaded and executed
  2. 2Modules are cached by absolute path — the code runs once, every subsequent require() returns the same exports object
  3. 3Node.js wraps every module in a function that provides exports, require, module, __filename, and __dirname
  4. 4exports is just a shorthand for module.exports — reassigning exports breaks the link
  5. 5Circular dependencies return a partially populated exports object, not an error
1/8