Backend
Node.js
Node.js Module System Deep Dive

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 to module.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, and exports objects 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

  1. Resolve Path: Find the absolute file path based on the Module Resolution Algorithm.
  2. Check Cache: Look up the resolved path in require.cache. If found, immediately return the cached module.exports.
  3. Load Module: If not cached, create a new module object and read the file content from the disk.
  4. Wrap Module: Wrap the file content inside the IIFE module wrapper.
  5. Execute Module: Run the wrapped function, passing the require, module, and exports objects.
  6. Cache Module: Store the resulting module object in require.cache for future calls.
  7. Return Exports: Return module.exports to 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: 1

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

  1. 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');
  2. Local Modules Modules created by you within your application. Always required using relative (./, ../) or absolute paths. Examples: ./utils/math.js, ../config/database.js.

  3. Third-Party Modules Modules downloaded from the npm registry and stored in the node_modules directory. 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:

  1. The package.json approach (Recommended) Add "type": "module" to your package.json. This tells Node.js to treat all .js files as ESM.

    {
      "name": "my-app",
      "type": "module"
    }
  2. The .mjs Extension Rename your file from app.js to app.mjs. Node.js will explicitly treat .mjs files as ESM, regardless of the package.json.

  3. The .cjs Extension Conversely, if "type": "module" is enabled but you need a specific file to run as CommonJS, rename it to .cjs.


8. ESM vs CommonJS

FeatureCommonJS (CJS)ES Modules (ESM)
Syntaxrequire() / module.exportsimport / export
Loading BehaviorSynchronousAsynchronous
Browser SupportNo (Requires bundler)Yes (Native browser support)
Tree ShakingDifficult / LimitedExcellent (due to static structure)
File Extension.js, .cjs.js (with "type":"module"), .mjs
Dynamic ImportsYes (require())Yes (import())
Top-Level AwaitNoYes
__dirname / __filenameAvailable globallyUnavailable
Strict ModeOptionalAlways 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 a require() statement inside an if statement or a loop. The path can be a dynamic variable.
  • import is static and runs at compile-time. Standard import statements 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 path

2. Bare Specifiers (node_modules lookup) If it's a bare string (e.g., require('express')), Node.js does the following:

  1. Checks if it's a Core Module (like fs).
  2. If not, it looks for a node_modules folder in the current directory.
  3. If not found, it moves to the parent directory and looks for node_modules.
  4. 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 users module should not reach directly into the orders database 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-level import statements 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 dynamic import().
  • Memory Caching: Cached modules consume RAM. Be cautious of giant JSON files loaded via require('./data.json')—they will live in memory forever. Use fs.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_modules execute with the same system privileges as your server. An imported package can silently read .env files 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

  1. Prefer ESM for New Projects: The ecosystem has shifted. Use "type": "module" for new Node.js APIs to ensure future compatibility.
  2. Use Named Exports Over Default Exports: Named exports enforce strict naming conventions and offer better auto-completion and refactoring support in IDEs.
  3. Avoid Giant Utility Files: Instead of a massive utils.js file with 50 functions, split it into smaller, domain-specific modules (mathUtils.js, dateUtils.js).
  4. Use Barrel Files Carefully: index.js files 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.