Node.js Modules

A Node.js module is simply a JavaScript file (or folder) whose code is private by default and must explicitly export whatever it wants other files to use. Instead of every script sharing one giant global scope like a browser <script> tag, Node wraps each file in its own private scope and gives you require() and module.exports to share code between files on your own terms. This is what lets a real application be split into dozens of small, focused files — routes, database helpers, utilities — without them stepping on each other’s variables. This lesson covers Node’s default module system, CommonJS, in depth: how it works under the hood, how modules are found and cached, and the mistakes that trip up almost everyone the first time.

Overview: How Node.js Modules Work

Node.js ships with two module systems: CommonJS (CJS), the original system built around require() and module.exports, and ECMAScript Modules (ESM), built around import/export. This lesson focuses on CommonJS, which is still the default for any file ending in .js unless the project’s package.json sets \"type\": \"module\". A later lesson in this course covers ES Modules in Node.js in detail.

Every file is secretly wrapped in a function

When Node loads a .js file as a CommonJS module, it does not execute your code as-is. It first wraps the entire file’s text inside a function, roughly equivalent to this:

(function (exports, require, module, __filename, __dirname) {
  // your module's code lives here
  const path = require(\"node:path\");
  module.exports = { hello: () => \"hi\" };
});

This hidden \”module wrapper\” is why require, module, exports, __filename, and __dirname are available inside every CommonJS file without you ever importing them — they are simply parameters passed into this wrapper function by Node before your code runs. It also explains why variables declared at the top level of one file (with const, let, or a bare function declaration) never leak into another file: they are local to that file’s wrapper function, not truly global.

require(), module.exports, and exports

module is an object Node creates for every file, and module.exports is the value that file hands back to whoever calls require() on it. By default, module.exports starts out as an empty object {}. The bare exports variable is just a shorthand that initially points to that very same object, so you can write exports.foo = ... instead of the longer module.exports.foo = .... The instant you assign a brand-new object to module.exports, though, exports keeps pointing to the old, now-discarded object — a mismatch that is the single most common bug in this system (see Common Mistakes below).

Three kinds of modules

When you call require(id), Node classifies id into one of three categories:

Kind Example How it’s resolved
Core module require(\"node:fs\") Built into the Node binary; resolved instantly, no disk lookup
File/local module require(\"./math.js\") Resolved relative to the requiring file’s folder; the id must start with ./, ../, or be an absolute path
Package module require(\"express\") Searched for in the nearest node_modules folder, then its parent, walking up toward the filesystem root

The key rule: if the string passed to require() does not start with ./, ../, or /, and it isn’t a recognized core module name, Node assumes it names a package and only searches node_modules directories. This is why forgetting the leading ./ on your own file is such a classic mistake — it isn’t a syntax error, it’s a confusing \”Cannot find module\” error at runtime.

Caching

The first time a given file is require()d, Node executes it top to bottom, stores the resulting module.exports value in an internal cache keyed by the file’s fully resolved absolute path, and returns that value. Every subsequent require() of the same file — from anywhere in the program — returns the exact same cached object instead of re-running the file. This is what lets a module behave like a singleton: if it holds state on itself (a database connection, a counter, a parsed config object), every part of the app that requires it shares that one instance, not a fresh copy.

Syntax

// exporting from a-file.js
module.exports = value;              // replace the whole export
exports.name = value;                // add one named export

// importing in another file
const thing = require(\"./a-file.js\");      // local file (needs ./ or ../)
const fs = require(\"node:fs/promises\");    // core module (node: prefix recommended)
const express = require(\"express\");        // installed package
  • module.exports — the value returned by require(); assign an object, function, class, or any value to it.
  • exports — shorthand alias for module.exports; only safe for adding properties (exports.x = ...), never for reassigning it wholesale.
  • require(id) — a synchronous function that resolves, loads, caches, and returns a module’s module.exports.
  • id — a core module name (ideally with the node: prefix), a relative/absolute path to a file, or a package name.

Examples

Example 1: a basic utility module

Split a couple of pure functions into their own file, then use them from another file.

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

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

module.exports = { add, subtract };
const path = require(\"node:path\");
const { add, subtract } = require(\"./math.js\");

console.log(`2 + 3 = ${add(2, 3)}`);
console.log(`5 - 1 = ${subtract(5, 1)}`);
console.log(`Loaded from: ${path.basename(__filename)}`);

Output:

2 + 3 = 5
5 - 1 = 4
Loaded from: app.js

math.js exports a plain object holding two functions. app.js requires it with a relative path, destructures the two functions out of the returned object, and also requires the core node:path module to show a core module and a local module being used side by side.

Example 2: exports vs module.exports

This example shows the shorthand form of exporting, adding properties directly onto exports.

exports.greet = function greet(name) {
  return `Hello, ${name}!`;
};

exports.MAX_USERS = 100;
const user = require(\"./user.js\");

console.log(user.greet(\"Ava\"));
console.log(`Max users allowed: ${user.MAX_USERS}`);

Output:

Hello, Ava!
Max users allowed: 100

Because exports and module.exports start out pointing to the same object, adding properties directly to exports works fine here. This only breaks once you try to reassign exports itself, which is covered in Common Mistakes.

Example 3: a realistic logger module

A more real-world module combines a core module with your own logic and gets reused from an entry file.

const os = require(\"node:os\");

function log(message) {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] (${os.hostname()}) ${message}`);
}

module.exports = { log };
const { log } = require(\"./logger.js\");

log(\"Server starting up\");
log(\"Listening on port 3000\");

Output:

[2026-07-31T12:00:00.000Z] (myhost) Server starting up
[2026-07-31T12:00:00.000Z] (myhost) Listening on port 3000

The exact timestamp and hostname depend on when and where you run it, but the shape is fixed. Notice that start.js never touches node:os directly — that dependency is encapsulated entirely inside logger.js, which is the whole point of splitting code into modules: each file owns one responsibility.

How require() Works, Step by Step

Every call to require(id) follows the same sequence:

  • 1. Resolve. Node turns id into an absolute file path using the core/relative/package rules above, trying the exact path, then .js, .json, and .node extensions, then (if it’s a directory) an index.js inside it.
  • 2. Check the cache. If that absolute path already exists in Node’s internal module cache, the cached module.exports is returned immediately — the file is not re-read or re-run.
  • 3. Read and wrap. If it’s not cached, Node reads the file’s source text and wraps it inside the hidden module wrapper function shown earlier.
  • 4. Execute synchronously. Node creates a fresh module object (with module.exports starting as {}) and runs the wrapper, passing in exports, require, module, __filename, and __dirname. The file’s code runs top to bottom, synchronously, right there.
  • 5. Cache and return. Once the file finishes running, Node stores whatever module.exports ended up as in the cache (keyed by the resolved path) and returns that value to the code that called require().

Because this whole process is synchronous, a slow or blocking top-level module (for example, one that calls fs.readFileSync on a huge file) will stall your entire program while it loads — there is no way to await a require() call.

Common Mistakes

Mistake 1: reassigning exports instead of module.exports

Because exports is just a variable pointing at the same object as module.exports, reassigning exports directly breaks the link — the file’s caller still gets the original, empty module.exports.

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

Anyone who requires this file gets back {}, not the object with add, because module.exports was never touched. Fix it by assigning to module.exports itself:

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

Mistake 2: forgetting the ./ prefix on a local file

Without a leading ./, ../, or /, Node treats the id as a package name and only looks in node_modules — it will never find a sibling file this way, even if it’s sitting right next to the requiring file.

const math = require(\"math.js\");
console.log(math.add(2, 3));

This throws Error: Cannot find module 'math.js' at runtime, because Node searches node_modules directories, not the current folder. The fix is to make it explicit:

const math = require(\"./math.js\");
console.log(math.add(2, 3));

Mistake 3: circular requires returning incomplete exports

If a.js requires b.js which in turn requires a.js, Node does not loop forever — but it also does not wait for a.js to finish. Instead, the inner require(\"./a.js\") gets whatever partial module.exports a.js had built up so far.

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

Output:

a.js starting
b.js starting
in b.js, a.done = false
b.js finished
in a.js, b.done = true
a.js finished
main done true true

Notice b.js sees a.done as false, even though a.js eventually sets it to true — because at the moment b.js required a.js, a.js hadn’t finished running yet. Circular dependencies are legal but fragile; restructure the code to remove the cycle, or move the require() call inside a function so it happens later, after both modules have fully loaded.

Best Practices

  • Import core modules with the node: prefix (require(\"node:fs\")) to make it unambiguous that a name refers to a built-in, not a package.
  • Give each module a single responsibility — one file per concern (one router, one database helper, one utility group) rather than one huge file exporting everything.
  • Never reassign the bare exports variable; always assign to module.exports when replacing the whole export value.
  • Always prefix your own files with ./ or ../ in require() calls so Node doesn’t mistake them for packages.
  • Avoid circular require() chains; if two modules genuinely need each other, extract the shared piece into a third module they both depend on.
  • Keep expensive, blocking work (like readFileSync on a large file) out of module top-level code unless it’s a one-time startup step in a script, not a server request path.
  • Use an index.js inside a folder as its \”barrel\” file so require(\"./my-folder\") resolves to a single, intentional entry point.

Practice Exercises

  • Create strings.js that exports two functions, capitalize(str) and reverse(str), using module.exports. Require it from a second file and log the result of calling each on a sample string.
  • Build counter.js that keeps a private let count = 0 variable and exports increment() and getCount() functions that read and modify it. Require counter.js from two different files in the same run, call increment() from one, and confirm from the other that getCount() reflects the change — this demonstrates the module cache creating a shared singleton.
  • Write two files, left.js and right.js, that require each other at the top of the file (a circular dependency). Before running them, predict on paper what each console.log will print and in what order, then run them to check your prediction.

Summary

  • Node’s default module system is CommonJS: every .js file is wrapped in a hidden function receiving exports, require, module, __filename, and __dirname.
  • module.exports is the value require() returns; exports is only a convenience alias for adding properties to it, never for wholesale reassignment.
  • require(id) resolves id as a core module, a relative/absolute file, or a node_modules package, in that order of rules, then caches the result by absolute path.
  • Because modules are cached, requiring the same file twice returns the same object instance, making modules act like singletons.
  • Missing the ./ prefix on a local file, reassigning exports directly, and circular require() chains are the three classic CommonJS pitfalls.
  • ES Modules (import/export) are Node’s other module system and are covered in a dedicated lesson later in this course.