CommonJS require Node.js Module System 2026

JavaScript CommonJS require: Node.js Module System Deep Dive

CommonJS require is the original module system that made Node.js possible. Before CommonJS existed, JavaScript had no built-in way to split code across files, share functionality between modules, or manage dependencies. Understanding how require() works is essential for every JavaScript developer working with Node.js, npm packages, or legacy codebases. In this deep tutorial, you will learn exactly how CommonJS modules load, cache, resolve paths, and handle edge cases that trip up even experienced developers.

What Is CommonJS?

CommonJS is a module specification created in 2009 to give JavaScript a standard module system outside the browser. Node.js adopted CommonJS as its default module format, and it remained the only option until ES Modules arrived in Node.js 12+. Every file in a CommonJS environment is treated as a separate module with its own scope — variables declared inside a file do not leak into the global scope.

The two core pieces of CommonJS are:

  • require() — a synchronous function that loads and returns a module
  • module.exports — the object that a module exposes to consumers
// math.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
// app.js
const math = require('./math');

console.log(math.add(2, 3));       // 5
console.log(math.multiply(4, 5));  // 20

Every Node.js file you have ever written uses CommonJS unless you explicitly opt into ES Modules via .mjs extensions or "type": "module" in package.json.

How require() Works Under the Hood

When Node.js encounters require('./math'), it performs several steps internally. Understanding this process helps you debug module resolution errors and optimize load times.

Step 1: Resolve the File Path

Node.js resolves the argument to an absolute file path. For relative paths like './math', it resolves relative to the directory of the calling file. For bare specifiers like 'express', it walks up node_modules directories.

Step 2: Check the Module Cache

Before loading anything, Node.js checks require.cache. If the resolved path already exists in the cache, it returns the cached module.exports immediately without re-executing the file.

Step 3: Load and Wrap the File

Node.js reads the file contents and wraps them in a function:

// Node.js internally wraps your code like this:
(function(exports, require, module, __filename, __dirname) {
  // --- your module code runs here ---
  function add(a, b) { return a + b; }
  module.exports = { add };
  // --- end of your code ---
});

This wrapper is why each module has its own scope and why __filename, __dirname, exports, require, and module are available in every file without importing them.

Step 4: Execute and Cache

Node.js executes the wrapped function, passing in the module objects. Whatever you assign to module.exports becomes the return value of require(). The result is cached so subsequent calls are instant.

// Proving the cache works
const a = require('./math');
const b = require('./math');
console.log(a === b); // true — same object reference

Exporting with module.exports and exports

There are two ways to export from a CommonJS module, and confusing them is one of the most common bugs in Node.js development.

Using module.exports

module.exports is the actual object that require() returns. You can assign anything to it — an object, a function, a class, or a primitive.

// Export a single function
module.exports = function greet(name) {
  return `Hello, ${name}!`;
};

// Export a class
module.exports = class Logger {
  log(msg) { console.log(`[LOG] ${msg}`); }
};

// Export an object with multiple methods
module.exports = {
  parse: (str) => JSON.parse(str),
  stringify: (obj) => JSON.stringify(obj, null, 2),
};

Using the exports Shorthand

The exports variable is a reference to module.exports. You can add properties to it:

// This works fine
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;

But you cannot reassign exports directly:

// THIS DOES NOT WORK!
exports = { add: (a, b) => a + b };
// require() still returns the original empty object

Reassigning exports breaks the reference to module.exports. Always use module.exports when you need to replace the entire export.

The require() Resolution Algorithm

Understanding path resolution is critical for debugging “Cannot find module” errors. Node.js follows a specific algorithm based on the argument type.

Core Modules

Built-in modules like fs, path, http always take priority:

const fs = require('fs');       // Built-in, always resolves first
const path = require('path');   // Even if a local 'path.js' exists

Relative Paths

Paths starting with ./, ../, or / are resolved relative to the calling file. Node.js tries these extensions in order: .js, .json, .node.

// If file is /app/src/main.js
require('./utils');
// Tries: /app/src/utils.js, /app/src/utils.json, /app/src/utils.node
// Then:  /app/src/utils/index.js, /app/src/utils/index.json

node_modules Lookup

Bare specifiers like require('lodash') trigger a walk up the directory tree:

// If called from /app/src/lib/helper.js, Node.js checks:
// /app/src/lib/node_modules/lodash
// /app/src/node_modules/lodash
// /app/node_modules/lodash
// /node_modules/lodash

For each directory, Node.js checks the package’s main field in package.json, then falls back to index.js.

Module Caching and Singletons

CommonJS modules are cached after first load. This means the module code runs exactly once, and every require() call returns the same object reference. This behavior makes CommonJS modules natural singletons.

// counter.js
let count = 0;

module.exports = {
  increment() { count++; },
  getCount() { return count; },
};
// a.js
const counter = require('./counter');
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2

// b.js (in the same process)
const counter = require('./counter');
console.log(counter.getCount()); // 2 — same instance!

You can inspect and even manipulate the cache directly:

// View all cached modules
console.log(Object.keys(require.cache));

// Force re-evaluation by clearing cache
delete require.cache[require.resolve('./counter')];
const freshCounter = require('./counter'); // Re-executes the file

Circular Dependencies in CommonJS

Circular dependencies happen when module A requires module B, and module B requires module A. CommonJS handles this by returning a partially loaded module:

// a.js
console.log('a.js starting');
exports.done = false;
const b = require('./b');
console.log('in a.js, b.done =', b.done);
exports.done = true;
console.log('a.js done');

// b.js
console.log('b.js starting');
exports.done = false;
const a = require('./a');
console.log('in b.js, a.done =', a.done); // false! (partial)
exports.done = true;
console.log('b.js done');
// Running: node a.js
// Output:
// a.js starting
// b.js starting
// in b.js, a.done = false    <-- partial export!
// b.js done
// in a.js, b.done = true
// a.js done

When b.js requires a.js, it gets whatever a.js has exported so far — which is { done: false } because a.js hasn’t finished executing. This is a common source of bugs. Avoid circular dependencies when possible, or restructure using a third “shared” module.

Dynamic require() and Conditional Loading

Unlike ES Modules, CommonJS require() is a regular function call. You can use it anywhere, including inside conditionals, loops, and try/catch blocks:

// Conditional loading
let db;
if (process.env.DB_TYPE === 'postgres') {
  db = require('pg');
} else {
  db = require('mysql2');
}

// Loading in a try/catch for optional dependencies
let chalk;
try {
  chalk = require('chalk');
} catch (e) {
  chalk = { red: (s) => s, green: (s) => s };
}

// Dynamic path construction
const locale = process.env.LANG || 'en';
const messages = require(`./locales/${locale}.json`);

This flexibility is powerful but makes static analysis impossible. Bundlers like Webpack cannot tree-shake CommonJS modules because they cannot determine which exports are used at build time.

Common CommonJS Patterns

The Factory Pattern

// logger.js
module.exports = function createLogger(prefix) {
  return {
    log: (msg) => console.log(`[${prefix}] ${msg}`),
    error: (msg) => console.error(`[${prefix}] ERROR: ${msg}`),
  };
};

// usage
const logger = require('./logger')('AuthService');
logger.log('User logged in');
// [AuthService] User logged in

The Revealing Module Pattern

// database.js
const connections = new Map();

function connect(uri) {
  if (connections.has(uri)) return connections.get(uri);
  const conn = { uri, connected: true, query: (sql) => `Running: ${sql}` };
  connections.set(uri, conn);
  return conn;
}

function disconnect(uri) {
  connections.delete(uri);
}

// Only expose public API
module.exports = { connect, disconnect };

Re-exporting Multiple Modules

// index.js (barrel file)
module.exports = {
  ...require('./auth'),
  ...require('./database'),
  ...require('./cache'),
};

CommonJS vs ES Modules

Understanding the differences between CommonJS and ES Modules is crucial for modern JavaScript development:

Feature CommonJS ES Modules
Loading Synchronous Asynchronous
Syntax require() / module.exports import / export
Tree-shaking Not possible Supported
Dynamic imports require(expr) import(expr)
Top-level await Not available Supported (Node 14.8+)
Browser support Requires bundler Native
this at top level module.exports undefined

The JavaScript ecosystem is moving toward ES Modules, but CommonJS remains dominant in the npm registry. Most packages now ship both formats. Learn more about how this behaves differently in each module system.

Common Mistakes and Pitfalls

1. Reassigning exports Instead of module.exports

// WRONG
exports = { greet: () => 'hello' };

// CORRECT
module.exports = { greet: () => 'hello' };

2. Forgetting That require() Is Synchronous

If a module performs heavy I/O at the top level, it blocks the entire event loop during loading. Move heavy operations into exported functions instead.

3. Assuming Fresh State on Every require()

Because modules are cached, mutable state persists across all consumers. This causes bugs when you expect isolated state.

4. Relative Path Confusion

// From /app/src/routes/user.js
require('./utils');     // Looks for /app/src/routes/utils.js
require('../utils');    // Looks for /app/src/utils.js
require('../../utils'); // Looks for /app/utils.js

5. JSON Require Without Validation

// This works but returns a mutable cached object
const config = require('./config.json');
config.port = 9999; // Mutates the cached version for everyone!

// Safer approach
const config = JSON.parse(JSON.stringify(require('./config.json')));

Summary and Key Takeaways

  • CommonJS is Node.js’s original module system using require() and module.exports
  • Modules are loaded synchronously and cached after first execution
  • Node.js wraps every file in a function that provides exports, require, module, __filename, and __dirname
  • Use module.exports (not exports =) when replacing the entire export
  • The resolution algorithm checks core modules, then relative paths, then walks up node_modules
  • Circular dependencies return partially-loaded modules — avoid them
  • Dynamic require() is flexible but prevents tree-shaking
  • The ecosystem is shifting to ES Modules, but CommonJS knowledge remains essential for maintaining Node.js applications and understanding the npm ecosystem

Now that you understand CommonJS, continue to the next lesson on ES Modules (import/export) to learn the modern standard that is replacing require() across the JavaScript ecosystem.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *