CommonJS require and module.exports

Published by Programming Line in Node.js. Last updated .

CommonJS is the module system Node.js has used since its very first release: every .js file is treated as its own module with a private scope, and you share code between files with two building blocks — require() to pull a module in, and module.exports to expose what a module offers. Unless a project opts into ECMAScript modules (via "type": "module" in package.json or a .mjs extension), every plain .js file you write is a CommonJS module by default. Understanding exactly how require resolves a path, how module.exports differs from the exports shortcut, and how Node caches modules will save you from some of the most common bugs beginners hit when splitting code across files.

Overview: How CommonJS Modules Work

When Node loads a .js file as a CommonJS module, it does not simply run your source as-is. It first wraps the entire file in a function that looks roughly like this:

(function (exports, require, module, __filename, __dirname) {
  // your module's code runs here
  console.log(__filename);
});

This wrapper is why require, module, exports, __filename, and __dirname appear to be available everywhere in a CommonJS file even though they are not real JavaScript globals — Node injects them as parameters before your code ever executes. It also means every module has its own private scope: top-level variables and functions in one file are local to that file’s wrapper function, not attached to the real global object, so two modules can reuse names like i or config without colliding.

The three pieces you work with directly are:

  • module.exports — the value this file hands back to whoever calls require() on it. It starts out as an empty object ({}); you either add properties to it or replace it entirely with a function, class, or any other value.
  • exports — a variable that, at the start of the file, points to the exact same object as module.exports. It exists purely as a shorthand so you can write exports.foo = ... instead of module.exports.foo = ....
  • require(id) — a synchronous function that locates a module by its identifier, executes it (once) inside the wrapper above, and returns whatever that module’s module.exports ended up being.

require is synchronous and blocking: it reads the file from disk, compiles it, and runs it before returning, all on the main thread. That is a deliberate trade-off — modules are almost always loaded once at startup, before a server starts accepting traffic, so blocking briefly during that phase is acceptable. It is a very different model from asynchronous I/O like fs.readFile, which you should never block on inside a request handler.

Syntax

A module both exports things and requires other modules using this general shape:

// exporting from a module
module.exports = value;          // replace the whole export
module.exports.name = value;     // or: exports.name = value;

// requiring a module elsewhere
const something = require(moduleId);
Piece Meaning
module.exports The object, function, class, or value this file exposes to callers. Assigning to it replaces the export entirely.
exports Shorthand reference to the original module.exports object. Only safe for adding properties (exports.foo = ...); reassigning it breaks the link.
require(moduleId) Loads and returns a module’s module.exports. Synchronous — it does not return until the module has fully executed.
moduleId A core module name ("node:fs"), a relative path ("./math", "../lib/db"), or a package name resolved from node_modules ("express").
__filename / __dirname Absolute path to the current file / its containing directory. Available automatically in CommonJS; these do not exist in ES modules.

Examples

Example 1: Exporting multiple functions from one module

The most common pattern is bundling several related functions into one exported object.

function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

module.exports = { add, subtract };
const { add, subtract } = require("./math");

console.log(add(5, 3));
console.log(subtract(5, 3));

Output:

8
2

The first file, math.js, builds an object with two functions and assigns it wholesale to module.exports. The second file requires it and immediately destructures the two functions out of the returned object — a very idiomatic Node style.

Example 2: The exports shorthand

logger.js never touches module.exports directly; it attaches functions as properties of the exports shorthand instead.

exports.info = function (message) {
  console.log(`[INFO] ${message}`);
};

exports.error = function (message) {
  console.error(`[ERROR] ${message}`);
};
const logger = require("./logger");

logger.info("Server starting");
logger.error("Something went wrong");

Output:

[INFO] Server starting
[ERROR] Something went wrong

Because exports starts out pointing at the same object as module.exports, properties you attach to exports show up on the object require("./logger") returns. This style is popular for modules that accumulate several named exports one at a time, but as you’ll see in Common Mistakes, it only works as long as you never reassign exports itself.

Example 3: A realistic config module used by a server

Real Node programs typically have a small configuration module, read once at startup, that other files depend on.

const config = {
  port: process.env.PORT ?? 3000,
  env: process.env.NODE_ENV ?? "development",
};

module.exports = config;
const http = require("node:http");
const config = require("./config");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ message: "Hello", env: config.env }));
});

server.listen(config.port, () => {
  console.log(`Listening on port ${config.port}`);
});

Output:

Listening on port 3000

config.js exports a plain object built from environment variables with sensible defaults. server.js requires it once and uses config.port and config.env throughout — there is no need to re-read environment variables in every file that needs them.

How It Works Step by Step (Under the Hood)

1. Resolving the module identifier

  1. If the identifier matches a core module name ("node:fs", "node:path", …), Node uses its own built-in implementation immediately — no disk lookup needed.
  2. If it starts with ./, ../, or /, Node treats it as a file path relative to the requiring file (or absolute). It tries the exact path, then appends .js, then .json, then .node (native addons).
  3. If that path is a directory rather than a file, Node looks for a package.json with a "main" field inside it, falling back to index.js.
  4. If the identifier is a bare name with no path prefix ("express"), Node searches the node_modules folder next to the requiring file, then walks upward through each parent directory’s node_modules until it finds a match or reaches the filesystem root.

2. Executing and caching the module

The first time a file is required, Node runs it inside the wrapper function and stores the resulting module.exports in an internal cache keyed by the module’s fully resolved absolute path. Every subsequent require() of that same file — from anywhere in the program — returns the exact same cached object instead of re-running the file. This makes every module a singleton by default.

let count = 0;

function increment() {
  count += 1;
  return count;
}

module.exports = { increment };
const counterA = require("./counter");
const counterB = require("./counter");

console.log(counterA.increment());
console.log(counterB.increment());
console.log(counterA === counterB);

Output:

1
2
true

Even though counter.js is required twice under different local names, both variables reference the identical object, so the internal count keeps incrementing across both call sites. This is a common way to implement a shared, in-process singleton, and also a common source of surprise if you expected a fresh copy each time.

Common Mistakes

Mistake 1: Reassigning exports instead of module.exports

exports = {
  add(a, b) {
    return a + b;
  },
};

This looks reasonable but does nothing useful: require() always returns module.exports, not the local exports variable. Assigning to exports only rebinds that local variable to a new object; module.exports still points at the original empty object, so callers get {} back and add is never visible outside the file.

module.exports = {
  add(a, b) {
    return a + b;
  },
};

Assigning to module.exports directly always works, whether you are replacing the export with an object, a single function, or a class.

Mistake 2: Forgetting the relative path prefix

const helpers = require("helpers");

Without a leading ./ or ../, Node assumes "helpers" is a core module or a package name and searches node_modules for it. Unless you actually have a package named helpers installed, this throws Error: Cannot find module 'helpers'.

const helpers = require("./helpers");

Always prefix local files with ./ (same directory) or ../ (parent directory) so Node knows to resolve it as a file path instead of a package name.

Mistake 3: Mixing exports.name assignments with a later module.exports = {...}

exports.add = function (a, b) {
  return a + b;
};

module.exports = {
  subtract(a, b) {
    return a - b;
  },
};

The second assignment replaces the entire export object, discarding the add property that had already been attached to it. Only subtract survives; requiring this module and calling .add() throws TypeError: (intermediate value).add is not a function.

module.exports = {
  add(a, b) {
    return a + b;
  },
  subtract(a, b) {
    return a - b;
  },
};

Pick one style per file: either build a single object and assign it to module.exports once, or attach properties to exports incrementally — never both.

Best Practices

  • Build one object and assign it to module.exports once per file; reserve the exports.name = ... shorthand for files that only ever add properties, and never mix the two styles.
  • Always prefix local file requires with ./ or ../; bare specifiers are reserved for core modules and installed packages.
  • Prefer require("node:fs") style prefixes for core modules so it is unambiguous at a glance that a module is built into Node rather than a third-party package.
  • Remember modules are cached singletons: don’t rely on requiring the same file twice to get a fresh instance. If you need a fresh instance per call, export a factory function instead of a ready-made object.
  • Keep modules focused on a single responsibility so each file’s exported surface stays small and easy to understand at the require site.
  • Avoid heavy synchronous work at a module’s top level (large file reads, expensive computation) — it runs once, synchronously, the first time anything requires that file, blocking whoever triggered it.
  • Watch for circular require() calls between two modules; if you spot one, extract the shared pieces into a third module that both can depend on instead.

Practice Exercises

Exercise 1: Create a file stringUtils.js that exports capitalize and reverse functions bundled into a single module.exports object. Then create app.js that requires it and prints the result of calling both functions on the string "node".

Exercise 2: Take a module that uses exports.a = ... followed by a later module.exports = {...} that overwrites it (as shown in Mistake 3), and refactor it so both exported functions work correctly. Verify by requiring the fixed module and calling both functions.

Exercise 3: Write a pageViews.js module that tracks an in-memory view count with an increment() function, similar to the caching example above. Require it from two different files in the same program and confirm, by logging the results, that both files share the same counter rather than each getting an independent copy.

Summary

  • CommonJS is Node’s original, default module system for .js files; every file is wrapped in a function that injects require, module, exports, __filename, and __dirname.
  • module.exports is the value require() actually returns; exports is just a shorthand pointing at the same object and breaks if reassigned.
  • require() is synchronous: it resolves the identifier (core module, relative path, or package name), executes the module once, and returns its export.
  • Required modules are cached by their resolved absolute path, so every require() of the same file returns the identical object — modules behave as singletons.
  • Common bugs come from reassigning exports, forgetting the ./ prefix on local paths, and mixing the exports.name and module.exports = {...} styles in the same file.