Node.js Module System: A Deep Dive
A complete, production-level technical guide from first principles covering CJS, ESM, internals, and real-world backend usage.
1. Introduction to Modules
What is Modular Programming?
Modular programming is a software design technique that separates a program's functionality into independent, interchangeable modules. Each module contains everything necessary to execute one specific aspect of the desired functionality.
Why Large Applications Need Modules
Without a module system, building complex backend systems would result in a massive, unmaintainable monolith. Problems without modules include:
- Global Namespace Pollution: Variables and functions clash, leading to unpredictable runtime errors.
- Tight Coupling: Code becomes intertwined, making testing and refactoring nearly impossible.
- No Dependency Tracking: It's unclear which part of the code relies on another part.
Modules solve these problems by enforcing separation of concerns and promoting code reusability and maintainability.
File == Module in Node.js
In Node.js, the fundamental rule is that every file is treated as a separate module. Variables, functions, and classes defined in a file are private to that file unless explicitly exported.
For example, in a standard backend API, you might have:
auth.js— Handles authentication logic.db.js— Manages the database connection pool.server.js— The entry point that glues everything together.
By keeping these separate, you can safely modify auth.js without worrying about breaking db.js.
2. CommonJS Module System
History and Context
When Node.js was created in 2009, JavaScript did not have a built-in module system (ESM didn't exist until ES2015). Node.js adopted CommonJS (CJS), a standard created to structure JavaScript applications outside the browser.
CommonJS was designed for server-side environments where module files are available locally on the disk. Thus, it loads modules synchronously. In a server, reading a local file is fast, so synchronous loading during startup is perfectly acceptable.
CommonJS Globals
When you write code in a CommonJS file, Node.js provides a set of global-like objects:
require: A function used to import other modules.module: An object representing the current module.module.exports: The object that gets exported.exports: A shorthand reference tomodule.exports.__dirname: The absolute path to the directory containing the current file.__filename: The absolute path to the current file itself.
How It Works Internally: The Module Wrapper
You might wonder how these "globals" are available. Node.js achieves this by wrapping your code before executing it.
When you run a file, Node.js implicitly wraps it in an Immediately Invoked Function Expression (IIFE):
(function(exports, require, module, __filename, __dirname) {
// --- YOUR MODULE CODE STARTS HERE ---
const fs = require('fs');
const path = require('path');
function calculate() { /* ... */ }
module.exports = { calculate };
// --- YOUR MODULE CODE ENDS HERE ---
});Key Takeaways:
- Every file gets wrapped.
- The
require,module, andexportsobjects are injected as arguments. - Variables declared inside the file remain private to that function scope. They do not leak into the global scope.
3. module.exports vs exports
One of the most confusing concepts for Node.js beginners is the relationship between exports and module.exports.
The Relationship
Internally, exports is just a pointer to module.exports. At the start of a module's execution, they both point to the same empty object.
exports = module.exports = {};Reference Behavior & Reassignment Pitfalls
Because exports is just a reference, modifying its properties works perfectly fine:
// âś… Valid: Mutating the shared object
exports.sayHello = () => console.log("Hello");However, if you reassign the exports variable, it loses its reference to module.exports, and Node.js will export an empty object (since module.exports is what actually gets returned by require()).
// ❌ Invalid: Reassigning 'exports' breaks the reference
exports = () => console.log("Hello"); // This will NOT be exported!
// âś… Valid: Reassigning 'module.exports' works perfectly
module.exports = () => console.log("Hello");Export Patterns
You can export anything in CommonJS:
1. Object Exports (Multiple Items)
module.exports = {
hashPassword: (pwd) => { /* ... */ },
comparePassword: (pwd, hash) => { /* ... */ }
};2. Function Exports
module.exports = function createServer() { /* ... */ };3. Class Exports
class Database { /* ... */ }
module.exports = Database;4. require() Internals
When you call require('module'), Node.js doesn't just read the file. It executes a complex, well-defined sequence of steps.
Step-by-Step Execution
- Resolve Path: Find the absolute file path based on the Module Resolution Algorithm.
- Check Cache: Look up the resolved path in
require.cache. If found, immediately return the cachedmodule.exports. - Load Module: If not cached, create a new
moduleobject and read the file content from the disk. - Wrap Module: Wrap the file content inside the IIFE module wrapper.
- Execute Module: Run the wrapped function, passing the
require,module, andexportsobjects. - Cache Module: Store the resulting
moduleobject inrequire.cachefor future calls. - Return Exports: Return
module.exportsto the caller.
Module Caching & Singleton Behavior
Because Node.js caches modules after the first time they are loaded, modules act as Singletons.
// counter.js
let count = 0;
module.exports = {
increment: () => count++,
getCount: () => count
};// app.js
const counter1 = require('./counter');
const counter2 = require('./counter');
counter1.increment();
console.log(counter2.getCount()); // Output: 1Even though we required the file twice, both variables point to the exact same object in memory because of require.cache. This state persistence is critical for shared resources like database connection pools.
5. Types of Modules in Node.js
Node.js modules fall into three categories:
-
Core Modules (Built-in) Provided by the Node.js binary itself. You don't need to install them. Examples:
fs,path,http,crypto,os.const fs = require('fs'); -
Local Modules Modules created by you within your application. Always required using relative (
./,../) or absolute paths. Examples:./utils/math.js,../config/database.js. -
Third-Party Modules Modules downloaded from the npm registry and stored in the
node_modulesdirectory. Examples:express,mongoose,lodash.const express = require('express');
6. ES Modules (ESM)
Why ESM was Introduced
As JavaScript evolved, the language needed an official, standard module system that worked natively in both browsers and servers. ECMAScript Modules (ESM) were introduced in ES6 (ES2015). ESM brings static analysis, better tree-shaking, and asynchronous loading. Today, the modern JavaScript ecosystem (React, Vue, Next.js, frontend tooling) has heavily adopted ESM.
ESM Syntax
ESM uses import and export keywords instead of require and module.exports.
1. Default Exports
// math.js
export default function add(a, b) { return a + b; }
// app.js
import add from './math.js';2. Named Exports
// utils.js
export const pi = 3.14159;
export function multiply(a, b) { return a * b; }
// app.js
import { pi, multiply } from './utils.js';3. Mixed Exports
export const name = 'Node.js';
export default function init() { /* ... */ }
import init, { name } from './module.js';7. Enabling ESM in Node.js
Historically, Node.js only supported CommonJS. To use ESM, you must explicitly opt-in. There are three ways to do this:
-
The
package.jsonapproach (Recommended) Add"type": "module"to yourpackage.json. This tells Node.js to treat all.jsfiles as ESM.{ "name": "my-app", "type": "module" } -
The
.mjsExtension Rename your file fromapp.jstoapp.mjs. Node.js will explicitly treat.mjsfiles as ESM, regardless of thepackage.json. -
The
.cjsExtension Conversely, if"type": "module"is enabled but you need a specific file to run as CommonJS, rename it to.cjs.
8. ESM vs CommonJS
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading Behavior | Synchronous | Asynchronous |
| Browser Support | No (Requires bundler) | Yes (Native browser support) |
| Tree Shaking | Difficult / Limited | Excellent (due to static structure) |
| File Extension | .js, .cjs | .js (with "type":"module"), .mjs |
| Dynamic Imports | Yes (require()) | Yes (import()) |
| Top-Level Await | No | Yes |
__dirname / __filename | Available globally | Unavailable |
| Strict Mode | Optional | Always enabled implicitly |
The __dirname Workaround in ESM
Because ESM is not wrapped in the traditional Node.js IIFE, __dirname and __filename are not injected into the file scope. You must construct them using the import.meta.url object:
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log(__dirname);9. import vs require
Static Imports vs Runtime Requires
require()is dynamic and runs at runtime. You can place arequire()statement inside anifstatement or a loop. The path can be a dynamic variable.importis static and runs at compile-time. Standardimportstatements must be at the top level of the file. They cannot be conditionally executed. This static nature allows JS engines to perform tree shaking (eliminating dead code) before execution.
Dynamic Imports in ESM
If you need conditional or lazy loading in ESM, you must use the dynamic import() function, which returns a Promise:
// Lazy loading a heavy module
async function loadHeavyTask() {
if (condition) {
const { processTask } = await import('./heavy-task.js');
processTask();
}
}10. Module Resolution Algorithm
When you import a module, Node.js must figure out exactly which file on the disk to load.
1. Relative and Absolute Paths
If the path starts with ./, ../, or /, Node.js resolves it directly against the filesystem.
import './math.js'; // Look in the same directory
import '/usr/local/lib/math.js'; // Look at absolute path2. Bare Specifiers (node_modules lookup)
If it's a bare string (e.g., require('express')), Node.js does the following:
- Checks if it's a Core Module (like
fs). - If not, it looks for a
node_modulesfolder in the current directory. - If not found, it moves to the parent directory and looks for
node_modules. - It repeats this up to the root directory
/.
3. Package Resolution
Once inside node_modules/express, Node.js checks the package.json of that module:
- It looks for the
"exports"field (modern Node.js). - If absent, it looks for the
"main"field. - If absent, it defaults to looking for
index.js.
11. Circular Dependencies
What is a Circular Dependency?
A circular dependency occurs when Module A imports Module B, and Module B imports Module A.
How Node.js Handles It (CommonJS)
Node.js does not crash or throw an error. Instead, it handles it gracefully but leaves a potential trap: partial exports.
// a.js
exports.a = false;
const b = require('./b.js');
exports.a = true;
console.log('in a, b.b =', b.b);
// b.js
exports.b = false;
const a = require('./a.js'); // CAUTION!
exports.b = true;
console.log('in b, a.a =', a.a);When a.js loads b.js, b.js attempts to load a.js. Because a.js is already in the middle of executing, Node.js returns the unfinished copy of the a.js exports object to b.js. This can lead to variables being undefined when you expect them to have values.
Best Practice
Avoid circular dependencies by refactoring shared logic into a separate, third module (e.g., Module C) that both A and B can import without relying on each other.
12. Interoperability Between CJS and ESM
Mixing CJS and ESM in the same project is historically painful but fully supported in modern Node.js.
1. Importing CommonJS into ESM
This works seamlessly. You can use standard import syntax to bring in a CJS module.
// ESM File (app.mjs)
import express from 'express'; // express is a CJS module
import lodash from 'lodash';2. Importing ESM into CommonJS
You cannot use require() to load an ESM module. It will throw an ERR_REQUIRE_ESM error because require is synchronous, but ESM loading is asynchronous by nature.
To load ESM into CJS, you MUST use dynamic import():
// CJS File (app.cjs)
async function loadEsm() {
const esmModule = await import('./module.mjs');
esmModule.doSomething();
}13. Advanced Node.js Module Concepts
The Dependency Graph
When your application starts, Node.js builds a directed graph of all modules. Starting from the entry point (e.g., index.js), it reads the imports, loads those files, reads their imports, and so on. This entire graph is resolved before the application actually begins running your business logic.
require.main vs import.meta.url
Sometimes a file acts as both an exportable library and a standalone executable script. You can detect if a file is being executed directly via the CLI:
In CommonJS:
if (require.main === module) {
console.log("This script was run directly from the terminal.");
}In ESM:
import { fileURLToPath } from 'url';
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
if (isMain) {
console.log("This script was run directly from the terminal.");
}14. Backend Engineering Connection
Understanding module systems is critical for designing scalable enterprise backend architectures:
- Monolith Architecture: A well-structured module system ensures boundaries. A
usersmodule should not reach directly into theordersdatabase logic. Modular code enables easier migration to microservices later. - Dependency Injection: Modern backends (like NestJS) rely heavily on module resolution to inject dependencies (like DB services) into controllers.
- Layered Architecture: Modules allow you to separate your app into Controller, Service, and Repository layers, ensuring strict unidirectional data flow.
15. Performance Considerations
- Startup Cost: Node.js resolves and evaluates all synchronous
require()and top-levelimportstatements at startup. A bloated dependency graph (requiring hundreds of files) will result in a measurable cold-start penalty, which is highly detrimental in Serverless environments like AWS Lambda. - Lazy Loading: For serverless or massive monoliths, defer loading non-critical modules until they are needed by moving
require()into the specific route handler, or using dynamicimport(). - Memory Caching: Cached modules consume RAM. Be cautious of giant JSON files loaded via
require('./data.json')—they will live in memory forever. Usefs.readFile()for large datasets.
16. Security Considerations
Modules pose a significant supply-chain security risk in backend engineering.
- Arbitrary Require Risks: Never construct
require()paths directly from user input.// ❌ VERY DANGEROUS: Directory Traversal Vulnerability const plugin = require(`./plugins/${req.query.pluginName}`); - Malicious Packages: Third-party modules in
node_modulesexecute with the same system privileges as your server. An imported package can silently read.envfiles or execute shell commands. - Best Practices: Always use lock files (
package-lock.json,pnpm-lock.yaml), audit dependencies regularly (npm audit), and limit module privileges using modern Node.js experimental permission flags.
17. Best Practices
- Prefer ESM for New Projects: The ecosystem has shifted. Use
"type": "module"for new Node.js APIs to ensure future compatibility. - Use Named Exports Over Default Exports: Named exports enforce strict naming conventions and offer better auto-completion and refactoring support in IDEs.
- Avoid Giant Utility Files: Instead of a massive
utils.jsfile with 50 functions, split it into smaller, domain-specific modules (mathUtils.js,dateUtils.js). - Use Barrel Files Carefully:
index.jsfiles that aggregate and re-export modules (barrel exports) are great for neat imports, but can accidentally cause circular dependencies and slow down startup times if they load massive module trees.
18. Common Interview Questions
Q: What is the difference between require() and import?
A: require is dynamic, synchronous, and specific to CommonJS. import is static, asynchronous, native to JavaScript (ESM), and supports tree shaking.
Q: Why is __dirname unavailable in ESM?
A: Because ESM modules are not wrapped in the traditional Node.js CommonJS IIFE wrapper, which is what injects __dirname and __filename into the local scope.
Q: What happens if you require() the same file twice?
A: The file is read and executed only once. The first time, its module.exports is stored in require.cache. Subsequent calls immediately pull the result from the cache.
Q: Can you explain a Circular Dependency?
A: It happens when Module A imports Module B, and B imports A. Node.js handles this by returning a partially constructed exports object for the module that hasn't finished executing, which can lead to undefined values.
19. Final Summary
The Node.js Module System is the backbone of scalable backend architecture.
- The CommonJS Mental Model: Everything is wrapped in a function, resolved dynamically, loaded synchronously, and cached as a singleton.
- The ESM Mental Model: Code is parsed statically before execution, allowing for optimizations, loaded asynchronously, and standardized across browsers and servers.
When to use which? If you are maintaining a legacy application, you will work heavily with CommonJS. If you are starting a fresh project today—especially using modern frameworks or TypeScript—ES Modules are the clear way forward. Node.js is actively investing its future entirely into ESM compatibility and performance.