Explorer
Node.js

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:

TEXT
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:

  1. How does one file expose something to another file?
  2. How does another file import it?
  3. 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:

TEXT
my-app/
├── calculate.js
└── app.js

Inside calculate.js:

JAVASCRIPT
function calculateMultiply(a, b) {
  return a * b;
}

And inside app.js:

JAVASCRIPT
console.log("App is booting...");

const result = calculateMultiply(5, 5);

console.log("Result:", result);

You run:

BASH
node app.js

You might expect:

TEXT
App is booting...
Result: 25

But Node.js gives you:

TEXT
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:

TEXT
user.js
payment.js
analytics.js
order.js
notification.js

Suppose every file shared one giant global namespace.

user.js:

JAVASCRIPT
const formatData = () => {
  // user formatting
};

payment.js:

JAVASCRIPT
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:

TEXT
┌──────────────────┐
│    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:

TEXT
module.exports  → expose something

require()       → load something

Let's fix our original example.

In calculate.js:

JAVASCRIPT
function calculateMultiply(a, b) {
  return a * b;
}

module.exports = calculateMultiply;

Now the function is explicitly exposed.

In app.js:

JAVASCRIPT
const calculateMultiply = require('./calculate');

const result = calculateMultiply(5, 5);

console.log("Result:", result);

Output:

TEXT
Result: 25

The important thing is not the syntax.

The important concept is:

TEXT
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:

JAVASCRIPT
const PI = 3.14159;

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

function calculateMultiply(a, b) {
  return a * b;
}

We can expose all three:

JAVASCRIPT
module.exports = {
  PI,
  calculateSum,
  calculateMultiply,
};

Now another module can consume them.

Destructuring

JAVASCRIPT
const { calculateSum, PI } = require('./math');

console.log(calculateSum(10, 20));
console.log(PI);

Or we can keep the module as a namespace:

JAVASCRIPT
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:

JAVASCRIPT
const SECRET = "some-private-value";

function processData(value) {
  return `${value}-${SECRET}`;
}

module.exports = {
  processData,
};

Another file can do:

JAVASCRIPT
const { processData } = require('./processor');

console.log(processData("hello"));

But it cannot directly access the module's local SECRET variable:

JAVASCRIPT
console.log(SECRET);

That variable belongs to the module's own scope.

This is one of the reasons modular code is easier to maintain:

TEXT
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:

TEXT
my-app/
├── calculate/
│   ├── sum.js
│   ├── multiply.js
│   └── index.js
└── app.js

sum.js:

JAVASCRIPT
function calculateSum(a, b) {
  return a + b;
}

module.exports = {
  calculateSum,
};

multiply.js:

JAVASCRIPT
function calculateMultiply(a, b) {
  return a * b;
}

module.exports = {
  calculateMultiply,
};

Now index.js can act as an aggregation module:

JAVASCRIPT
const { calculateSum } = require('./sum');
const { calculateMultiply } = require('./multiply');

module.exports = {
  calculateSum,
  calculateMultiply,
};

Then the application can consume the folder's public interface:

JAVASCRIPT
const {
  calculateSum,
  calculateMultiply,
} = require('./calculate');

This pattern is commonly called a barrel or aggregator module.

The idea is simple:

TEXT
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:

JAVASCRIPT
const math = require('./math');

But where did require come from?

You never declared:

JAVASCRIPT
const require = ...

The same question applies to:

JAVASCRIPT
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:

JAVASCRIPT
(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:

JAVASCRIPT
console.log(__filename);
console.log(__dirname);

These values are available because Node provides them to the CommonJS module environment.

Likewise:

JAVASCRIPT
module.exports = something;

works because module is provided by the CommonJS system.

And:

JAVASCRIPT
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:

JAVASCRIPT
exports = module.exports = {};

Both variables initially refer to the same object.

Therefore this works:

JAVASCRIPT
exports.add = (a, b) => a + b;

because you are modifying the object that module.exports points to.

You can think of it like this:

TEXT
exports ───────────┐
                   │
                   ▼
             ┌───────────┐
             │    {}     │
             └───────────┘
                   ▲
                   │
             module.exports

But this is different:

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

Now exports points somewhere else:

TEXT
exports
   │
   ▼
 function add()

module.exports
   │
   ▼
  {}

The connection between them has been broken.

CommonJS consumers receive the value assigned to:

JAVASCRIPT
module.exports

not whatever you later assigned to the local exports variable.

Therefore, if you want to replace the entire export:

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

is the correct form.

A good rule to remember:

Use exports.foo = ... when adding properties. Use module.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:

JAVASCRIPT
const db = require('./database');

Conceptually, CommonJS module loading goes through several stages:

TEXT
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:

JAVASCRIPT
'./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:

  1. A built-in core module (such as node:fs or path), which resolves internally without filesystem path lookup.
  2. A relative or absolute filesystem path (./database, ../utils, /app/db), which checks file extensions (.js, .json, .node) and directory entry points (./database/index.js or package.json "main").
  3. A third-party package specifier (such as 'express'), which traverses parent node_modules directories 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:

TEXT
./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 via JSON.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 using process.dlopen().

Step 3: Wrap

For CommonJS JavaScript files (.js), Node provides the module wrapper environment:

JAVASCRIPT
(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 .node addons bypass the wrapper entirely.


Step 4: Evaluate

The module code executes.

For example:

JAVASCRIPT
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:

TEXT
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:

  1. 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.
  2. Cached per process: The cache resides in process memory. Separate OS processes or independent worker_threads each maintain their own completely isolated module caches and module instances.

For example:

JAVASCRIPT
// database.js

console.log("Database module evaluated");

module.exports = {
  connected: true,
};

If several modules require it, you don't normally see:

TEXT
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:

JAVASCRIPT
// config.js

module.exports = {
  environment: "production",
};

Two modules can require it:

JAVASCRIPT
const configA = require('./config');
const configB = require('./config');

They can receive the same cached module export.

Therefore, mutating exported objects can create shared state:

JAVASCRIPT
configA.environment = "development";

console.log(configB.environment);

You may now see:

TEXT
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:

JAVASCRIPT
export
import

Instead of:

JAVASCRIPT
module.exports
require()

we can write:

JAVASCRIPT
// math.js

export const PI = 3.14159;

export function calculateSum(a, b) {
  return a + b;
}

And consume it with:

JAVASCRIPT
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:

JAVASCRIPT
export function calculateSum(a, b) {
  return a + b;
}

and default exports:

JAVASCRIPT
export default function calculateMultiply(a, b) {
  return a * b;
}

They can be consumed together:

JAVASCRIPT
import calculateMultiply, {
  calculateSum,
} from './math.js';

The important difference is:

JAVASCRIPT
import { calculateSum } from './math.js';

means you are importing a named export.

Whereas:

JAVASCRIPT
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:

“.js means CommonJS and .mjs means 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

TEXT
.cjs

or:

JSON
{
  "type": "commonjs"
}

ES Modules

TEXT
.mjs

or:

JSON
{
  "type": "module"
}

For example:

JSON
{
  "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:

JSON
{
  "type": "module"
}

and then writing:

JAVASCRIPT
import express from 'express';

instead of:

JAVASCRIPT
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:

JAVASCRIPT
const math = require('./math');

require() is a runtime function.

That means it can be used dynamically:

JAVASCRIPT
if (useAdvancedMath) {
  const math = require('./advanced-math');
}

ESM uses static import declarations:

JAVASCRIPT
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:

JAVASCRIPT
// counter.cjs

let count = 0;

function increment() {
  count++;
}

module.exports = {
  count,
  increment,
};

Then:

JAVASCRIPT
// 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:

JAVASCRIPT
{
  count: 0,
  increment: function...
}

Destructuring then gave app.cjs its own local count value.

Now compare that with ESM.

JAVASCRIPT
// counter.js

export let count = 0;

export function increment() {
  count++;
}

And:

JAVASCRIPT
// 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:

JAVASCRIPT
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:

TEXT
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:

JAVASCRIPT
__dirname
__filename
require
module

For example:

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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.url remains the most robust pattern:

For example:

JAVASCRIPT
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:

TEXT
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:

  1. Construction (Resolution & Parsing): The runtime resolves module specifiers into URLs, fetches the source files, parses them to validate syntax, and discovers all import and export declarations without executing any user code. This builds the static directed dependency graph.
  2. Instantiation (Linking): The engine traverses the dependency graph and allocates memory spaces for all exported values across all modules. It connects the import statements directly to these memory spaces, creating live bindings. No JavaScript execution has taken place yet.
  3. 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:

TEXT
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:

JAVASCRIPT
await connectToDatabase();

at the top level of a module.

This is called top-level await.

For example:

JAVASCRIPT
// 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:

JAVASCRIPT
const module = await import('./advanced-math.js');

Unlike static:

JAVASCRIPT
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:

JAVASCRIPT
if (useAdvancedFeature) {
  const module = await import('./advanced-feature.js');

  module.run();
}

So the distinction becomes:

TEXT
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:

JSON
{
  "type": "module"
}

and write:

JAVASCRIPT
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:

TEXT
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:

TEXT
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.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.