Node.js Interview Prep
Module System

CommonJS

require, module.exports, and the Wrapper Function

LinkedIn Hook

"Why does exports = something silently break your Node.js module, while module.exports = something works perfectly?"

This is one of the most common Node.js gotchas — and it trips up even senior developers in interviews. The answer lies in how Node wraps every file in a hidden function before executing it, and how exports is just a reference to module.exports, not the real thing.

Before ES Modules arrived, CommonJS was the only way to share code in Node.js. Every require() call runs through a five-step pipeline: resolve -> load -> wrap -> evaluate -> cache. Understanding this pipeline explains circular dependencies, the exports trap, the module cache, and why Node's module loader is synchronous.

In Lesson 2.1, I break down the CommonJS internals — the wrapper function, require.cache, circular dependency handling, and why the JavaScript world is slowly migrating to ESM.

Read the full lesson -> [link]

#NodeJS #CommonJS #JavaScript #BackendDevelopment #InterviewPrep


CommonJS thumbnail


What You'll Learn

  • How require() works step by step: resolve, load, wrap, evaluate, cache
  • The hidden module wrapper function Node injects around every file
  • The difference between module.exports and exports — and why reassigning exports silently breaks things
  • How the module cache (require.cache) works and when to clear it
  • How CommonJS handles circular dependencies (and why it returns partial exports)
  • Why CommonJS module loading is synchronous — and why ESM is replacing it

The Library Checkout Analogy

Imagine a small town library with one copy of each book. The first time you ask for "The Node Handbook," the librarian walks to the back shelf, finds the book, stamps a checkout card, and hands it to you. The next visitor who asks for the same book doesn't wait for another shelf walk — the librarian simply points to the copy already in circulation. Everyone ends up reading the same physical book, not a fresh copy.

That is exactly how require() works in Node.js. The first time you require a module, Node resolves the path, loads the file from disk, wraps it in a function, evaluates the code, and stores the resulting module.exports object in a cache. Every subsequent require() call for that same path skips the shelf walk entirely — it returns the cached object directly. This is why modifying an exported object in one file affects every other file that required it: they all hold a reference to the same object.

Now here is the twist. If two books reference each other — Book A says "see Book B, page 10" and Book B says "see Book A, page 5" — and a reader tries to follow both references simultaneously, what happens? In CommonJS, the librarian hands out a half-finished copy of whichever book was started first. That's how circular dependencies work: you get whatever module.exports happened to contain at the moment the cycle was detected.

+---------------------------------------------------------------+
|           THE require() PIPELINE (5 STEPS)                    |
+---------------------------------------------------------------+
|                                                                |
|   require('./math')                                            |
|          |                                                     |
|          v                                                     |
|   +-------------+                                              |
|   | 1. RESOLVE  |  Turn './math' into an absolute path         |
|   +-------------+  (./math.js, ./math/index.js, node_modules)  |
|          |                                                     |
|          v                                                     |
|   +-------------+                                              |
|   | 2. CACHE?   |  Check require.cache[absolutePath]           |
|   +-------------+  If hit -> return cached module.exports      |
|          |                                                     |
|          v (miss)                                              |
|   +-------------+                                              |
|   | 3. LOAD     |  Read file contents from disk (sync)         |
|   +-------------+  fs.readFileSync under the hood              |
|          |                                                     |
|          v                                                     |
|   +-------------+                                              |
|   | 4. WRAP     |  Wrap code in (function(exports, require,    |
|   +-------------+   module, __filename, __dirname) { ... })    |
|          |                                                     |
|          v                                                     |
|   +-------------+                                              |
|   | 5. EVALUATE |  Run the wrapped function. Whatever ends up  |
|   +-------------+  in module.exports is the result.            |
|          |                                                     |
|          v                                                     |
|   +-------------+                                              |
|   | 6. CACHE IT |  require.cache[absolutePath] = module        |
|   +-------------+  Return module.exports to the caller         |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A vertical flowchart showing five stages: RESOLVE, LOAD, WRAP, EVALUATE, CACHE. Each stage is a Node-green (#68a063) rounded box with white monospace text. Amber (#ffb020) arrows connect them. A side panel shows the wrapper function signature in amber on a dark background. A small library icon on the right with a cached book labeled 'require.cache' in green."


The Module Wrapper Function — Node's Hidden Magic

When you write a file like this:

// math.js
const PI = 3.14159;
function area(r) {
  return PI * r * r;
}
module.exports = { area };

You might wonder: where do module, exports, require, __filename, and __dirname come from? You never imported them. They seem to appear out of nowhere. The answer is that Node wraps your entire file in a function before executing it. The real code Node runs looks like this:

// What Node actually executes
(function (exports, require, module, __filename, __dirname) {
  // -- your file contents start here --
  const PI = 3.14159;
  function area(r) {
    return PI * r * r;
  }
  module.exports = { area };
  // -- your file contents end here --
});

This is called the module wrapper function. It is a regular JavaScript function that takes five parameters and executes your code in its own scope. Because of this wrapper:

  1. Top-level variables are local, not global. const PI in math.js is scoped to the wrapper function. It does not leak into other files.
  2. Each module gets its own module, exports, and require. These are not globals — they are function parameters unique to each file.
  3. __filename and __dirname are injected automatically. They are not part of JavaScript; Node supplies them as wrapper arguments.
  4. this at the top level equals module.exports, because Node calls the wrapper with module.exports as the this context.

You can actually see the wrapper by running:

// inspect-wrapper.js
// Node exposes the wrapper template on the Module object
const Module = require('module');
console.log(Module.wrapper);
// Output:
// [
//   '(function (exports, require, module, __filename, __dirname) { ',
//   '\n});'
// ]

The wrapper is a literal string prepended and appended to your source code before vm.runInThisContext evaluates it. This is why syntax errors in your module sometimes show line numbers offset by one — the wrapper adds a line at the top.


module.exports vs exports — The Reassignment Trap

This is the single most common CommonJS mistake. Consider these two files:

// good.js -- This works
module.exports = function greet(name) {
  return 'Hello, ' + name;
};
// bad.js -- This silently does nothing
exports = function greet(name) {
  return 'Hello, ' + name;
};

If you require('./good.js') you get the function. If you require('./bad.js') you get an empty object. Why?

Inside the wrapper function, Node sets up this relationship:

// What Node does before your code runs
function wrapper(exports, require, module, __filename, __dirname) {
  // module is the real module object
  // exports is just a shortcut: it starts as a reference to module.exports
  // exports === module.exports   // true at the very start

  // -- your code here --
}

At the start, exports and module.exports point to the same object. So this works:

// Mutating the shared object -- both references see the change
exports.greet = function (name) {
  return 'Hello, ' + name;
};
// module.exports is still the same object, now with a greet property.
// require() returns module.exports -- { greet: [Function] }

But this does NOT work:

// Reassigning the local parameter -- breaks the connection
exports = function greet(name) {
  return 'Hello, ' + name;
};
// We just pointed the local `exports` variable at a new function.
// But `module.exports` still points to the ORIGINAL empty object.
// require() returns module.exports -- {} (empty object!)

Reassigning exports only rebinds the local parameter inside the wrapper function. It has zero effect on module.exports, which is what require() actually returns.

The Safe Rules

// RULE 1: Exporting multiple named things -- use either style
exports.add = (a, b) => a + b;
exports.sub = (a, b) => a - b;
// Equivalent to:
module.exports.add = (a, b) => a + b;
module.exports.sub = (a, b) => a - b;

// RULE 2: Exporting a single thing (function, class, value)
// ALWAYS use module.exports, NEVER exports =
module.exports = class User { /* ... */ };

// RULE 3: If you assign module.exports, don't mix with exports.foo
// after that -- the exports alias is now stale
module.exports = { a: 1 };
exports.b = 2;  // BUG: this attaches b to the OLD object, not the new one

A good mental model: exports is a convenience alias that becomes useless the moment you reassign module.exports. When in doubt, just use module.exports everywhere.


The Module Cache — require.cache

Every module Node loads is stored in require.cache, keyed by the resolved absolute file path. This cache is shared across the entire process.

// counter.js -- A module with mutable state
let count = 0;
module.exports = {
  increment() {
    count += 1;
    return count;
  },
  current() {
    return count;
  }
};
// app.js -- Observing the cache
const counterA = require('./counter');
const counterB = require('./counter');

counterA.increment();  // 1
counterA.increment();  // 2
counterB.increment();  // 3

console.log(counterA === counterB);  // true -- same object!
console.log(counterA.current());     // 3

// Inspect the cache directly
console.log(Object.keys(require.cache));
// [
//   '/absolute/path/to/app.js',
//   '/absolute/path/to/counter.js'
// ]

// The cache stores full Module objects
const cachedEntry = require.cache[require.resolve('./counter')];
console.log(cachedEntry.id);        // absolute path
console.log(cachedEntry.loaded);    // true
console.log(cachedEntry.exports);   // { increment: [Fn], current: [Fn] }
console.log(cachedEntry.children);  // modules this one required
console.log(cachedEntry.parent);    // who required it first

Two different calls to require('./counter') from the same or different files return the exact same object. This is a feature — it gives you singleton-like behavior for free. It is also a trap — if a module holds mutable state, that state is shared globally.

Clearing the Cache

Sometimes — in test runners, hot-reload tools, or REPL experiments — you want to force Node to re-evaluate a module. You do this by deleting its entry from require.cache.

// hot-reload.js -- Manual cache clearing
const path = require('path');

function reload(relativePath) {
  // Resolve to absolute path -- cache keys are always absolute
  const absolutePath = require.resolve(relativePath);

  // Delete the cache entry so the next require() re-evaluates the file
  delete require.cache[absolutePath];

  // Now this call will read the file from disk again
  return require(relativePath);
}

// First load
let config = require('./config');
console.log(config.version);  // 1

// Edit config.js on disk, then:
config = reload('./config');
console.log(config.version);  // 2 (fresh read)

Caveats of cache clearing:

  1. Any module that already holds a reference to the old exports still has the old version. Only new require() calls get the new one.
  2. Clearing a parent does not clear its children. You may need to walk cachedEntry.children recursively.
  3. Native addons (.node files) cannot be safely reloaded — they keep handles to native memory.

In production code, you should almost never touch require.cache directly. It exists mainly for tooling.


Circular Dependencies — Partial Exports

What happens when module A requires module B, and module B requires module A? CommonJS handles this gracefully but unexpectedly: the second require returns whatever was in module.exports up to that point — a partial, possibly incomplete object.

// a.js
console.log('a: starting');

// Assign something BEFORE requiring b
module.exports.greeting = 'Hello from A';

// Now require b -- this triggers b.js evaluation
const b = require('./b');

// After b finishes, continue setting up a
module.exports.finalValue = 42;
module.exports.bSays = b.message;

console.log('a: done, b.message =', b.message);
// b.js
console.log('b: starting');

// Require a WHILE a is still being evaluated (circular!)
const a = require('./a');

// At this point, a.js has only run up to the `require('./b')` line.
// So a.greeting exists, but a.finalValue does NOT.
console.log('b: a.greeting =', a.greeting);    // 'Hello from A'
console.log('b: a.finalValue =', a.finalValue); // undefined (!)

module.exports.message = 'Hello from B';

console.log('b: done');
// main.js
const a = require('./a');
console.log('main: a.finalValue =', a.finalValue);

Running node main.js produces:

a: starting
b: starting
b: a.greeting = Hello from A
b: a.finalValue = undefined
b: done
a: done, b.message = Hello from B
main: a.finalValue = 42

What happened step by step:

  1. main.js requires a.js. Node creates module.exports = {} for a, caches it immediately (important!), and starts evaluating a.js.
  2. a.js assigns module.exports.greeting = 'Hello from A'.
  3. a.js hits require('./b'). Node starts evaluating b.js.
  4. b.js calls require('./a'). Node sees a is already in the cache (step 1) even though it isn't finished evaluating yet. It returns the partial exports: { greeting: 'Hello from A' }.
  5. b.js finishes. Its exports are stored.
  6. Control returns to a.js, which adds finalValue and bSays.
  7. main.js sees the fully populated a.

The key insight: Node caches the module object before running its code. This is what prevents infinite recursion in cycles — the second require hits the cache and returns what exists so far, rather than re-entering the file.

How to Avoid Circular Dependency Bugs

// BAD -- destructuring at top forces partial capture
const { helper } = require('./a');  // undefined if a hasn't finished yet

// BETTER -- require the module object, access properties lazily
const a = require('./a');
function doWork() {
  return a.helper();  // a is fully loaded by the time doWork runs
}

Or better: refactor to remove the cycle. Extract the shared functionality into a third module that both A and B depend on.


Why CommonJS is Synchronous — and Why ESM is Replacing It

require() is synchronous. When you call it, Node blocks the thread until the entire module — including all its transitive dependencies — is loaded, parsed, wrapped, evaluated, and cached. Only then does require() return.

This design made sense in 2009 when Node was built for server-side code loaded once at startup. Disk reads were acceptable because they happened during boot, not during request handling. But it makes CommonJS fundamentally incompatible with the browser, where loading is inherently network-bound and must be asynchronous.

+---------------------------------------------------------------+
|           SYNC (CommonJS) vs ASYNC (ESM)                      |
+---------------------------------------------------------------+
|                                                                |
|  CommonJS (sync):                                              |
|    require('./a')                                              |
|      -> fs.readFileSync('./a.js')  <-- blocks the thread       |
|      -> parse + wrap + evaluate                                |
|      -> returns exports                                       |
|    Next line runs only after all of the above.                 |
|                                                                |
|  ESM (async):                                                  |
|    import './a.js'                                             |
|      -> fetch/read file (async)                                |
|      -> parse (build dependency graph)                         |
|      -> link (resolve all imports statically)                  |
|      -> evaluate (in topological order)                        |
|    Top-level await is allowed. Browser-compatible.             |
|                                                                |
+---------------------------------------------------------------+

Why the JavaScript world is moving to ESM:

  1. Static analysis. import statements are parsed before any code runs. This enables tree-shaking, dead-code elimination, and better tooling.
  2. Browser compatibility. ESM is the official ECMAScript standard. The same syntax works in Node, Deno, Bun, and every modern browser.
  3. Top-level await. ESM allows await at the top of a module. CommonJS cannot because require() is synchronous.
  4. No wrapper function magic. ESM modules are not wrapped. this is undefined, there is no exports vs module.exports trap.
  5. Live bindings. ESM imports are live references to the exported values, not snapshots. This makes circular dependencies more predictable.

CommonJS is not going away — millions of npm packages still use it, and Node will support it indefinitely. But new code should default to ESM unless you have a specific reason otherwise.


Common Mistakes

1. Reassigning exports instead of module.exports. Writing exports = myFunction silently produces an empty module. It rebinds the local parameter without touching the real module.exports. Always use module.exports = myFunction when exporting a single value. When in doubt, never use bare exports =.

2. Treating require() as if it were asynchronous. require() blocks the event loop. Calling it inside a hot path (like an HTTP handler) forces a synchronous disk read every time the cache is cold. Always require() at the top of the file, not lazily inside request handlers — unless you specifically need lazy loading and understand the cost.

3. Relying on require.cache for state management. Some developers stash singletons or configuration in modules assuming the cache guarantees a single instance. This breaks when multiple copies of a package get installed (e.g., via nested node_modules), when symlinks resolve to different absolute paths, or when worker threads create separate caches. Use explicit dependency injection instead.

4. Creating circular dependencies by accident. A imports from B, B imports from A, and suddenly one side sees undefined for a function it expected. The fix is almost never "work around the cycle" — it's to extract the shared code into a third module, or to require lazily inside the function body rather than at the top of the file.

5. Mixing module.exports = { ... } with exports.foo = ... afterwards. Once you reassign module.exports, the exports alias points to the old, now-orphaned object. Any subsequent exports.foo = assignments go into the void. Pick one style per file and stick with it.


Interview Questions

1. "Walk me through exactly what happens when I call require('./math')."

Node runs a five-step pipeline. First, resolution: Node turns ./math into an absolute path by trying ./math.js, ./math.json, ./math/index.js, and walking node_modules if needed. Second, cache check: if that absolute path is already in require.cache, Node returns the cached module.exports immediately and stops. Third, load: Node reads the file contents synchronously via fs.readFileSync. Fourth, wrap: Node wraps the source code in a function with the signature (exports, require, module, __filename, __dirname). Fifth, evaluate: Node compiles and runs the wrapped function. Whatever the code assigns to module.exports becomes the module's public API. Finally, Node stores the module object in require.cache keyed by the absolute path and returns module.exports to the caller. The key detail for follow-up questions is that the cache entry is created before evaluation begins, which is what allows circular dependencies to terminate.

2. "Why does exports = function() {} not work, but module.exports = function() {} does?"

Node wraps every file in a function where exports and module are parameters. At the start of execution, exports is just a local variable that happens to point to the same object as module.exports. When you write exports.foo = bar, you mutate the shared object and both references see the change. But when you write exports = somethingElse, you rebind the local parameter to a new value — you do not touch module.exports at all. Since require() returns module.exports (not exports), the caller still gets the original empty object. The rule: use exports.name = value to add properties, and module.exports = value to replace the entire export. Never write exports = value.

3. "How does CommonJS handle circular dependencies?"

Node caches a module's module.exports object before it starts evaluating the module's code. This is crucial. When module A requires module B, and B requires A while A is still evaluating, the second require('./a') inside B does not re-execute A — it finds A in the cache and returns whatever is currently on module.exports, which is a partial object containing only the properties assigned before the require('./b') line ran. Once B finishes, control returns to A, which continues adding properties. After everything settles, both modules see the full exports of each other — but only through live object references, not through values captured at require time. This is why destructuring a circular import often gives you undefined: you captured the partial snapshot instead of accessing a property later when the module was fully loaded.

4. "Why is require() synchronous, and what are the consequences?"

require() uses fs.readFileSync under the hood and blocks the event loop until the module and all its transitive dependencies finish loading. This was an intentional design choice in 2009: Node modules were meant to load at process startup, when blocking was acceptable. The consequences are significant. First, module loading cannot happen over the network — this is why CommonJS never worked in browsers and why ESM was created. Second, top-level await is impossible in CommonJS, because require() would need to pause in a way sync functions can't. Third, lazy require() calls inside hot code paths cause unexpected blocking on first call. Fourth, the synchronous model forced Node to load all dependencies eagerly, making startup time proportional to dependency tree size. ESM solves all of these with an asynchronous multi-phase loader (parse -> link -> evaluate).

5. "What is require.cache and when would you clear it?"

require.cache is a plain object, keyed by absolute file paths, that stores every module Node has loaded in the current process. Each value is a Module instance with properties like id, exports, loaded, parent, and children. The cache is what makes require('./foo') from different files return the same object — Node serves cached entries instead of re-evaluating the file. You would manually clear the cache (delete require.cache[require.resolve('./foo')]) in scenarios like hot module reloading during development, test isolation in a test runner that wants fresh module state between tests, or REPL experimentation. In production code you almost never touch it, because clearing the cache does not update references that other modules already hold, and it can leak memory or cause subtle bugs with native addons. If you find yourself reaching for cache clearing in production, it usually means you should restructure your code to avoid module-level mutable state.


Quick Reference — CommonJS Cheat Sheet

+---------------------------------------------------------------+
|           COMMONJS CHEAT SHEET                                |
+---------------------------------------------------------------+
|                                                                |
|  WRAPPER FUNCTION:                                             |
|  (function (exports, require, module,                         |
|             __filename, __dirname) {                          |
|     // your code here                                         |
|  })                                                            |
|                                                                |
|  EXPORTING:                                                    |
|  module.exports = value           // replace entire export    |
|  module.exports.name = value      // add named export         |
|  exports.name = value             // shortcut (safe)          |
|  exports = value                  // BROKEN -- do not use     |
|                                                                |
|  IMPORTING:                                                    |
|  const m = require('./math')      // relative path            |
|  const fs = require('fs')         // built-in module          |
|  const x = require('lodash')      // node_modules package     |
|  const p = require.resolve('./m') // path only, no load       |
|                                                                |
|  CACHE:                                                        |
|  require.cache                    // { absPath: Module }      |
|  delete require.cache[absPath]    // force re-evaluation      |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           KEY RULES                                            |
+---------------------------------------------------------------+
|                                                                |
|  1. Never reassign `exports` -- use `module.exports` instead  |
|  2. require() is SYNCHRONOUS -- call at top of file           |
|  3. Modules are cached by absolute path, shared across files  |
|  4. Circular deps return PARTIAL exports (what exists so far) |
|  5. Cache key = resolved absolute path, not the string you    |
|     passed to require()                                      |
|  6. `this` at top level === module.exports                    |
|  7. Prefer ESM for new code -- CJS is legacy but not going    |
|     away                                                       |
|                                                                |
+---------------------------------------------------------------+
FeatureCommonJS (CJS)ES Modules (ESM)
Syntaxrequire / module.exportsimport / export
LoadingSynchronousAsynchronous
Top-level awaitNoYes
Static analysisNo (dynamic)Yes (static)
Tree-shakingNoYes
File extension.js (default) or .cjs.mjs or .js with "type": "module"
this at top levelmodule.exportsundefined
Wrapper functionYesNo
Browser supportNoYes
Circular depsPartial exports snapshotLive bindings

Prev: Lesson 1.5 -- Blocking vs Non-Blocking I/O Next: Lesson 2.2 -- ES Modules in Node.js


This is Lesson 2.1 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.

On this page