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
idinto an absolute file path using the core/relative/package rules above, trying the exact path, then.js,.json, and.nodeextensions, then (if it’s a directory) anindex.jsinside it. - 2. Check the cache. If that absolute path already exists in Node’s internal module cache, the cached
module.exportsis 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
moduleobject (withmodule.exportsstarting as{}) and runs the wrapper, passing inexports,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.exportsended up as in the cache (keyed by the resolved path) and returns that value to the code that calledrequire().
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
exportsvariable; always assign tomodule.exportswhen replacing the whole export value. - Always prefix your own files with
./or../inrequire()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
readFileSyncon 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.jsinside a folder as its \”barrel\” file sorequire(\"./my-folder\")resolves to a single, intentional entry point.
Practice Exercises
- Create
strings.jsthat exports two functions,capitalize(str)andreverse(str), usingmodule.exports. Require it from a second file and log the result of calling each on a sample string. - Build
counter.jsthat keeps a privatelet count = 0variable and exportsincrement()andgetCount()functions that read and modify it. Requirecounter.jsfrom two different files in the same run, callincrement()from one, and confirm from the other thatgetCount()reflects the change — this demonstrates the module cache creating a shared singleton. - Write two files,
left.jsandright.js, that require each other at the top of the file (a circular dependency). Before running them, predict on paper what eachconsole.logwill print and in what order, then run them to check your prediction.
Summary
- Node’s default module system is CommonJS: every
.jsfile is wrapped in a hidden function receivingexports,require,module,__filename, and__dirname. module.exportsis the valuerequire()returns;exportsis only a convenience alias for adding properties to it, never for wholesale reassignment.require(id)resolvesidas a core module, a relative/absolute file, or anode_modulespackage, 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, reassigningexportsdirectly, and circularrequire()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.
