Chapter 2: Node.js Modules: CommonJS, ES Modules, and What Happens When You Import a File
Chapter 2: Node.js Modules: CommonJS, ES Modules, and What Happens When You Import a File
JavaScript was originally designed to run inside the browser, where scripts could interact with a shared page environment.
Node.js changed the environment completely.
Instead of writing a few scripts that manipulate a webpage, you can build a backend containing hundreds or thousands of files:
controllers/
services/
models/
routes/
utils/
middleware/
config/
Those files need to communicate with one another, but allowing every file to freely access every other file would quickly become unmanageable.
So Node.js needs a module system.
A module system answers three fundamental questions:
- How does one file expose something to another file?
- How does another file import it?
- What actually happens inside Node.js when that import occurs?
To understand modern Node.js modules, we will follow that problem from the beginning.
The Problem: Two JavaScript Files
Imagine you are building your first Node.js application.
You create two files:
my-app/
├── calculate.js
└── app.js
Inside calculate.js:
function calculateMultiply(a, b) {
return a * b;
}
And inside app.js:
console.log("App is booting...");
const result = calculateMultiply(5, 5);
console.log("Result:", result);
You run:
node app.js
You might expect:
App is booting...
Result: 25
But Node.js gives you:
ReferenceError: calculateMultiply is not defined
Why?
The files are sitting next to each other.
Why can't app.js simply see the function from calculate.js?
The answer takes us to one of the most important ideas in Node.js:
A Node.js module has its own scope. Code inside one module is not automatically available inside another module.
The files are separate modules.
If one module wants to expose something, it has to explicitly export it.
If another module wants to use it, it has to explicitly import or require it.
That separation is the foundation of Node.js applications.
Module Isolation
Before looking at require() and import, it is useful to understand why module isolation exists.
Imagine a large backend containing these files:
user.js
payment.js
analytics.js
order.js
notification.js
Suppose every file shared one giant global namespace.
user.js:
const formatData = () => {
// user formatting
};
payment.js:
const formatData = () => {
// payment formatting
};
Now the application has to deal with global names colliding with one another.
The larger the application becomes, the harder it becomes to understand where a variable or function came from.
Modules solve this by creating boundaries.
Conceptually:
┌──────────────────┐
│ user.js │
│ │
│ private code │
│ │
│ ── exports ──► │
└──────────────────┘
┌──────────────────┐
│ payment.js │
│ │
│ private code │
│ │
│ ── exports ──► │
└──────────────────┘
Each module owns its internal variables.
Only the values it deliberately exposes become part of its public interface.
This gives us a simple mental model:
A module has private implementation details and a public interface.
That idea becomes extremely important when applications grow.
CommonJS: Node.js's Original Built-In Module System
When Node.js was created, JavaScript did not yet have the standardized import and export syntax that we use today.
Node.js therefore adopted CommonJS as its original built-in module system.
The two concepts you need to remember are:
module.exports → expose something
require() → load something
Let's fix our original example.
In calculate.js:
function calculateMultiply(a, b) {
return a * b;
}
module.exports = calculateMultiply;
Now the function is explicitly exposed.
In app.js:
const calculateMultiply = require('./calculate');
const result = calculateMultiply(5, 5);
console.log("Result:", result);
Output:
Result: 25
The important thing is not the syntax.
The important concept is:
calculate.js
│
│ module.exports
▼
exported value
│
│ require()
▼
app.js
calculate.js decides what it exposes.
app.js decides what it consumes.
Exporting Multiple Values
A module does not have to expose only one function.
Suppose we have:
const PI = 3.14159;
function calculateSum(a, b) {
return a + b;
}
function calculateMultiply(a, b) {
return a * b;
}
We can expose all three:
module.exports = {
PI,
calculateSum,
calculateMultiply,
};
Now another module can consume them.
Destructuring
const { calculateSum, PI } = require('./math');
console.log(calculateSum(10, 20));
console.log(PI);
Or we can keep the module as a namespace:
const math = require('./math');
console.log(math.calculateSum(10, 20));
console.log(math.calculateMultiply(4, 5));
Both approaches are simply different ways of accessing the same exported object.
The Important Boundary: Private vs Public Code
This module boundary also gives us encapsulation.
Consider:
const SECRET = "some-private-value";
function processData(value) {
return `${value}-${SECRET}`;
}
module.exports = {
processData,
};
Another file can do:
const { processData } = require('./processor');
console.log(processData("hello"));
But it cannot directly access the module's local SECRET variable:
console.log(SECRET);
That variable belongs to the module's own scope.
This is one of the reasons modular code is easier to maintain:
Implementation details
│
│ hidden
▼
┌──────────────────┐
│ Module │
│ │
│ private code │
│ │
│ public API │
└────────┬─────────┘
│
▼
Other modules
The consumer only needs to know the module's public interface.
Organizing Modules into Folders
As an application grows, individual modules naturally get grouped by responsibility.
For example:
my-app/
├── calculate/
│ ├── sum.js
│ ├── multiply.js
│ └── index.js
└── app.js
sum.js:
function calculateSum(a, b) {
return a + b;
}
module.exports = {
calculateSum,
};
multiply.js:
function calculateMultiply(a, b) {
return a * b;
}
module.exports = {
calculateMultiply,
};
Now index.js can act as an aggregation module:
const { calculateSum } = require('./sum');
const { calculateMultiply } = require('./multiply');
module.exports = {
calculateSum,
calculateMultiply,
};
Then the application can consume the folder's public interface:
const {
calculateSum,
calculateMultiply,
} = require('./calculate');
This pattern is commonly called a barrel or aggregator module.
The idea is simple:
sum.js ───────┐
│
multiply.js ──┼──► index.js ──► app.js
│
other.js ─────┘
Instead of making the consumer know about every internal file, the folder exposes one public interface.
But Where Did require() Come From?
At this point, something interesting should bother you.
You wrote:
const math = require('./math');
But where did require come from?
You never declared:
const require = ...
The same question applies to:
module
exports
__filename
__dirname
These names are not ordinary variables that you declared in your source file.
For CommonJS modules, Node.js provides them through the CommonJS module mechanism.
Conceptually, Node.js wraps CommonJS module code in a function similar to:
(function (exports, require, module, __filename, __dirname) {
// Your module code
});
This is the CommonJS module wrapper.
The exact internal implementation has evolved over Node.js versions, so think of this as a conceptual model of how CommonJS gets its module-local variables.
That wrapper explains several things at once.
For example:
console.log(__filename);
console.log(__dirname);
These values are available because Node provides them to the CommonJS module environment.
Likewise:
module.exports = something;
works because module is provided by the CommonJS system.
And:
require('./math');
works because require is provided to the module.
exports vs module.exports
This is one of the most common CommonJS interview questions.
At the beginning of a CommonJS module, conceptually:
exports = module.exports = {};
Both variables initially refer to the same object.
Therefore this works:
exports.add = (a, b) => a + b;
because you are modifying the object that module.exports points to.
You can think of it like this:
exports ───────────┐
│
▼
┌───────────┐
│ {} │
└───────────┘
▲
│
module.exports
But this is different:
exports = function add(a, b) {
return a + b;
};
Now exports points somewhere else:
exports
│
▼
function add()
module.exports
│
▼
{}
The connection between them has been broken.
CommonJS consumers receive the value assigned to:
module.exports
not whatever you later assigned to the local exports variable.
Therefore, if you want to replace the entire export:
module.exports = function add(a, b) {
return a + b;
};
is the correct form.
A good rule to remember:
Use
exports.foo = ...when adding properties. Usemodule.exports = ...when replacing the entire exported value.
What Actually Happens When require() Runs?
Now we have enough background to look underneath the syntax.
Suppose we write:
const db = require('./database');
Conceptually, CommonJS module loading goes through several stages:
require('./database')
│
▼
1. Resolve (specifier → absolute path or core module)
│
▼
2. Load (reads content by file type: .js, .json, .node)
│
▼
3. Wrap (applies to CommonJS JavaScript files only)
│
▼
4. Evaluate (executes module code)
│
▼
5. Cache (keyed by resolved path per process)
│
▼
return module.exports
Let's walk through the process in detail.
Step 1: Resolve
Node first has to determine what:
'./database'
actually refers to.
The module specifier is resolved according to Node's CommonJS resolution algorithm (Module._resolveFilename).
Rather than simply finding a file, resolution evaluates whether the specifier is:
- A built-in core module (such as
node:fsorpath), which resolves internally without filesystem path lookup. - A relative or absolute filesystem path (
./database,../utils,/app/db), which checks file extensions (.js,.json,.node) and directory entry points (./database/index.jsorpackage.json"main"). - A third-party package specifier (such as
'express'), which traverses parentnode_modulesdirectories searching for package boundaries and exports.
The result is ultimately a specific target: an absolute file path on disk or an internal core module identifier.
For example:
./database
↓
/app/database.js
The important idea is:
Before Node can execute a module, it must determine exactly which target you are requesting.
Step 2: Load
Once Node knows which module is being requested, it loads the module according to its resolved file extension (Module._extensions):
- JavaScript files (
.js): Node reads the UTF-8 source code text from disk into memory, preparing it for compilation and execution. - JSON files (
.json): Node reads the text file and parses it directly into a JavaScript object viaJSON.parse(). JSON files are never wrapped in a function or executed as code. - Native C/C++ addons (
.node): Node loads the binary shared library directly into process memory usingprocess.dlopen().
Step 3: Wrap
For CommonJS JavaScript files (.js), Node provides the module wrapper environment:
(function (exports, require, module, __filename, __dirname) {
// module code
});
Now the source code has access to the CommonJS-specific variables.
Important Detail: Wrapping is not a universal step for all modules. It applies specifically to CommonJS JavaScript files (
.js). JSON modules and native.nodeaddons bypass the wrapper entirely.
Step 4: Evaluate
The module code executes.
For example:
const connection = createDatabaseConnection();
module.exports = connection;
The code runs and determines what the module exports.
Step 5: Cache
Node caches loaded CommonJS modules.
This is extremely important.
Suppose:
app.js
├── require('./database')
├── require('./users')
│ └── require('./database')
└── require('./orders')
└── require('./database')
The database module does not normally get independently evaluated from scratch for every require() call.
Once a module has been loaded, subsequent require() calls for the same resolved module return the cached module export from require.cache (or Module._cache).
This is why a CommonJS module can effectively behave like a shared singleton, with two critical qualifications:
- Cached per resolved absolute file path: Caching is strictly keyed by the module's resolved file path. If two different specifiers resolve to different physical paths on disk (for instance, symlinks, case-sensitivity differences on some filesystems, or duplicate package versions installed in nested
node_modules), Node will evaluate and cache separate, independent module instances. - Cached per process: The cache resides in process memory. Separate OS processes or independent
worker_threadseach maintain their own completely isolated module caches and module instances.
For example:
// database.js
console.log("Database module evaluated");
module.exports = {
connected: true,
};
If several modules require it, you don't normally see:
Database module evaluated
Database module evaluated
Database module evaluated
for every require().
The module is cached after loading.
Why Module Caching Matters
Caching is not just a performance detail.
It affects application behavior.
Suppose a module exports an object:
// config.js
module.exports = {
environment: "production",
};
Two modules can require it:
const configA = require('./config');
const configB = require('./config');
They can receive the same cached module export.
Therefore, mutating exported objects can create shared state:
configA.environment = "development";
console.log(configB.environment);
You may now see:
development
That is why mutable module-level state should be designed carefully.
The Modern Standard: ES Modules
CommonJS solved Node.js's module problem, but JavaScript eventually received an official module system.
In 2015, ECMAScript standardized ES Modules, commonly called ESM.
The syntax is:
export
import
Instead of:
module.exports
require()
we can write:
// math.js
export const PI = 3.14159;
export function calculateSum(a, b) {
return a + b;
}
And consume it with:
import { calculateSum, PI } from './math.js';
console.log(calculateSum(10, 20));
console.log(PI);
This is the JavaScript module system standardized by ECMAScript and supported by modern Node.js, browsers, and other JavaScript runtimes.
Named Exports and Default Exports
ES Modules support named exports:
export function calculateSum(a, b) {
return a + b;
}
and default exports:
export default function calculateMultiply(a, b) {
return a * b;
}
They can be consumed together:
import calculateMultiply, {
calculateSum,
} from './math.js';
The important difference is:
import { calculateSum } from './math.js';
means you are importing a named export.
Whereas:
import calculateMultiply from './math.js';
means you are importing the module's default export.
How Does Node Know Whether a File Is CJS or ESM?
This is where many developers learn an outdated rule:
“
.jsmeans CommonJS and.mjsmeans ESM.”
That is incomplete.
Node.js supports both module systems, and the module format depends on how the file is configured.
The clearest ways to explicitly tell Node what you mean are:
CommonJS
.cjs
or:
{
"type": "commonjs"
}
ES Modules
.mjs
or:
{
"type": "module"
}
For example:
{
"name": "my-app",
"type": "module"
}
Now .js files in that package scope are treated as ES Modules.
This is why you will frequently see modern Node.js projects using:
{
"type": "module"
}
and then writing:
import express from 'express';
instead of:
const express = require('express');
Node also has additional rules for ambiguous .js files, so the safest practice is to make your package's module type explicit.
The Biggest Conceptual Difference: CommonJS vs ESM
Now that we understand both systems, we can compare what makes them fundamentally different.
The first difference is how dependencies are expressed and linked.
CommonJS uses:
const math = require('./math');
require() is a runtime function.
That means it can be used dynamically:
if (useAdvancedMath) {
const math = require('./advanced-math');
}
ESM uses static import declarations:
import { calculateSum } from './math.js';
Static imports are part of the module structure.
This allows the runtime and tooling to understand the dependency graph before normal module evaluation takes place.
That is one reason ESM works well with static analysis and tools such as bundlers.
ESM Has Live Bindings
One of the most important differences is how imported bindings behave.
Consider CommonJS:
// counter.cjs
let count = 0;
function increment() {
count++;
}
module.exports = {
count,
increment,
};
Then:
// app.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // 0
Why is it still 0?
Because when we created the exported object, the count property contained the current primitive value.
The exported object effectively looked like:
{
count: 0,
increment: function...
}
Destructuring then gave app.cjs its own local count value.
Now compare that with ESM.
// counter.js
export let count = 0;
export function increment() {
count++;
}
And:
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1
ES Modules use live bindings.
The imported binding reflects changes made by the exporting module.
This does not mean the importer can freely reassign the imported variable.
The exporter controls the binding.
That distinction is fundamental to understanding ESM.
Why ESM Is Easier to Analyze
Because static imports describe dependencies explicitly:
import { calculateSum } from './math.js';
import { User } from './user.js';
tools can analyze the module dependency graph without executing arbitrary require() calls.
This enables powerful tooling capabilities such as:
Dependency analysis
↓
Unused export detection
↓
Dead-code elimination
↓
Tree-shaking
↓
Smaller production bundles
However, tree-shaking is primarily a tooling/bundler capability, not something that Node.js automatically performs simply because you use ESM.
So the accurate statement is:
ESM's statically analyzable module structure makes tree-shaking easier for bundlers and build tools.
What Happened to __dirname in ESM?
If you move from CommonJS to ESM, you may notice that these CommonJS variables are not available in the same way:
__dirname
__filename
require
module
For example:
console.log(__dirname);
doesn't work as a normal CommonJS variable inside an ES module.
Modern Node.js provides ESM-specific mechanisms for getting module file information.
For example:
console.log(import.meta.url);
Starting in Node.js v20.11.0 and v21.2.0, Node.js introduced two convenience properties on the import.meta object:
console.log(import.meta.filename); // Absolute path to current module file
console.log(import.meta.dirname); // Absolute path to current module directory
Important Qualifications:
- These properties are Node.js-specific runtime extensions, not part of the ECMAScript (TC39) standard.
- They are not available in older Node.js versions (< 20.11.0), in standard web browsers, or in other JavaScript runtimes without compatibility flags.
- For universal, portable ES Modules across all runtimes and older Node versions, deriving them from the standardized
import.meta.urlremains the most robust pattern:
For example:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
The important concept is not memorizing a replacement.
It is understanding why the old variables disappeared:
CommonJS
↓
module wrapper
↓
__filename / __dirname
ESM
↓
no CommonJS wrapper
↓
import.meta
ESM Evaluation and the Dependency Graph
There is another fundamental architectural difference between CommonJS and ES Modules.
While CommonJS resolves and executes synchronously line-by-line as it encounters each require() call, ES Modules follow a formal three-phase lifecycle defined by the ECMAScript specification:
- Construction (Resolution & Parsing): The runtime resolves module specifiers into URLs, fetches the source files, parses them to validate syntax, and discovers all
importandexportdeclarations without executing any user code. This builds the static directed dependency graph. - Instantiation (Linking): The engine traverses the dependency graph and allocates memory spaces for all exported values across all modules. It connects the
importstatements directly to these memory spaces, creating live bindings. No JavaScript execution has taken place yet. - Evaluation: Finally, the runtime executes the top-level code of each module in post-order depth-first traversal (deepest dependencies evaluate first). Exported variables in memory are populated with actual values.
Imagine:
app.js
│
├── user.js
│ └── database.js
│
└── order.js
└── database.js
Because the entire dependency graph is constructed and linked before evaluation, the engine guarantees deterministic execution ordering. Furthermore, dependency evaluation supports asynchronous operations cleanly:
ESM also supports features such as:
await connectToDatabase();
at the top level of a module.
This is called top-level await.
For example:
// database.js
const connection = await connectToDatabase();
export default connection;
The ability to use top-level await is part of the ESM module model.
Dynamic Imports Still Exist
Static imports are not the only way to load ESM.
JavaScript also supports dynamic import:
const module = await import('./advanced-math.js');
Unlike static:
import { calculateSum } from './math.js';
dynamic import() happens at runtime and returns a Promise.
This is useful when code should only be loaded when it is needed.
For example:
if (useAdvancedFeature) {
const module = await import('./advanced-feature.js');
module.run();
}
So the distinction becomes:
Static import
↓
Known dependency
↓
Analyzable module graph
Dynamic import()
↓
Runtime decision
↓
Load module when needed
CommonJS vs ESM
Now the entire journey can be summarized clearly.
| Feature | CommonJS | ES Modules |
|---|---|---|
| Specification / Origin | Node.js's original built-in module system (adopted from CommonJS 1.0) | Official ECMAScript standard (TC39) |
| Import | require() |
import |
| Export | module.exports / exports |
export |
| Module linking | Runtime-oriented (synchronous during execution) | Static 3-phase structure (Construction → Instantiation → Evaluation) |
| Imported bindings | CommonJS values/objects (copied reference or value) | Live bindings (direct connection to exported memory location) |
| Dynamic / Conditional loading | require() is a synchronous call that can be conditionally invoked |
import() is an asynchronous, Promise-returning expression |
Top-level await |
Not part of CJS module syntax | Supported |
__dirname |
Available via CJS wrapper | Node.js: import.meta.dirname (v20.11+); Portable: import.meta.url |
module.exports |
Available via CJS wrapper | Not available as a native ESM construct |
require |
Available in CJS | Not normally available (use createRequire if needed) |
| Tree-shaking | More difficult for tooling | Enabled by static analysis of import/export graph |
| Strict mode | Not automatic | Automatically strict |
What Should You Use Today?
At this point, the question is no longer:
“Which module system exists?”
You now understand both.
The practical question is:
Which one should I use for a new Node.js application?
Engineering Recommendation: For new applications, adopting ES Modules is widely considered best practice because ESM is the official ECMAScript standard, provides native browser and tooling compatibility, enables static tree-shaking, and is first-class in modern Node.js.
However, this is an architectural recommendation rather than an absolute rule. Teams maintaining extensive CommonJS library ecosystems, specialized legacy build tools, or specific deployment configurations may intentionally continue using CommonJS.
A project might therefore use:
{
"type": "module"
}
and write:
import express from 'express';
import { connectDB } from './database.js';
const app = express();
But that does not mean CommonJS is obsolete.
You will encounter CommonJS frequently in:
- older Node.js applications
- existing production codebases
- older packages
- configuration files
- libraries using CommonJS interoperability
Therefore, as a Node.js developer, you should be comfortable reading both.
The Mental Model to Remember
If you remember only one thing from this chapter, remember this progression:
JavaScript needs multiple files
↓
Multiple files need boundaries
↓
Modules provide those boundaries
↓
Node originally used CommonJS
↓
module.exports exposes values
↓
require() consumes them
↓
Node provides the CommonJS wrapper
↓
Node resolves → loads → evaluates → caches modules
↓
JavaScript standardized ES Modules
↓
import / export provide the modern module system
↓
ESM uses static module structure + live bindings
↓
Modern Node.js supports both
And the most important practical distinction is:
CommonJS
────────────────────────
require()
module.exports
exports
__dirname
__filename
CommonJS module cache
ES Modules
────────────────────────
import
export
import()
import.meta
live bindings
top-level await
Once this mental model is clear, things that initially look mysterious—require(), module.exports, exports, __dirname, ESM imports, live bindings, module caching, and package.json's "type" field—stop being isolated facts.
They become different pieces of the same module system.