The Node.js module system controls how code is organised, shared, and loaded. Understanding the differences between CommonJS (require()) and ES Modules (import/export), how the module cache works, module resolution algorithm, circular dependencies, and dynamic imports equips you to make correct architectural decisions for Node.js applications — especially important in a MEAN Stack monorepo that may need to share code between the Angular frontend (ESM) and the Express backend (CJS/ESM).
CommonJS vs ES Modules
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports |
import / export |
| Loading | Synchronous — blocks while loading | Asynchronous — non-blocking |
| Resolution | Runtime — dynamic paths allowed | Static — paths must be literals (mostly) |
| Top-level await | Not supported | Supported (await at module top level) |
| Tree-shaking | Not possible — require is dynamic |
Possible — static analysis of imports |
| File extension | .js (with "type":"commonjs") or .cjs |
.mjs or .js (with "type":"module") |
| __dirname / __filename | Available | Not available — use import.meta.url |
| Default in Node.js | Yes (without "type":"module") |
Opt-in via package.json |
require() — subsequent require() calls for the same path return the cached exports object. This is why singleton patterns work in Node.js: require('./db') returns the same Mongoose connection object everywhere. ESM modules are also cached but the semantics are slightly different — live bindings mean exported values that change in the exporting module are reflected in importing modules.packages/shared directory with ESM ("type":"module") and TypeScript. Angular naturally consumes ESM; Express can consume it with import() or by configuring "type":"module" in the API package.json. Alternatively, compile the shared package to both CJS and ESM outputs with TypeScript and publish dual-format packages — one entry for "main" (CJS) and one for "exports"["."]["import"] (ESM).undefined due to the circular dependency. Restructure to break the cycle — extract shared code into a third module.Complete Module System Examples
// ── CJS module patterns ───────────────────────────────────────────────────
// db.js — CJS singleton (cached after first require)
const mongoose = require('mongoose');
let connection = null;
async function connect(uri) {
if (!connection) {
connection = await mongoose.connect(uri);
}
return connection;
}
// module.exports — everything exported at once
module.exports = { connect, mongoose };
// OR: named exports pattern
module.exports.connect = connect;
module.exports.disconnect = () => mongoose.disconnect();
// ── ESM — modern Node.js ──────────────────────────────────────────────────
// package.json: { "type": "module" }
// db.mjs (or .js with "type":"module")
import mongoose from 'mongoose';
let connection = null;
export async function connect(uri) {
connection ??= await mongoose.connect(uri);
return connection;
}
export const disconnect = () => mongoose.disconnect();
// Default + named exports
export default mongoose;
// ── __dirname equivalent in ESM ───────────────────────────────────────────
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, '../config/app.json');
// ── Dynamic import — lazy loading ─────────────────────────────────────────
// Load a heavy module only when needed
async function generatePDFReport(data) {
// PDFKit is large — only load when PDF generation is needed
const { default: PDFDocument } = await import('pdfkit');
const doc = new PDFDocument();
// ... generate PDF
return doc;
}
// ── Conditional import — load based on environment ────────────────────────
async function getEmailTransport() {
if (process.env.NODE_ENV === 'production') {
const { createSESTransport } = await import('./transports/ses.js');
return createSESTransport();
} else {
const { createDevTransport } = await import('./transports/dev.js');
return createDevTransport();
}
}
// ── Module mocking for testing (ESM) ─────────────────────────────────────
// Jest requires special ESM mock setup (esmock or jest.unstable_mockModule)
// For CJS, jest.mock() works directly:
// jest.mock('../services/email.service');
// ── Dual CJS/ESM package (library pattern) ────────────────────────────────
// package.json for a shared package:
// {
// "name": "@taskmanager/shared",
// "main": "./dist/cjs/index.js", ← CJS for older require() consumers
// "module": "./dist/esm/index.js", ← ESM for bundlers (Angular)
// "exports": {
// ".": {
// "import": "./dist/esm/index.js", ← import (ESM)
// "require": "./dist/cjs/index.js" ← require (CJS)
// }
// },
// "types": "./dist/types/index.d.ts"
// }
// ── Inspecting the module cache ───────────────────────────────────────────
// See all loaded modules:
console.log(Object.keys(require.cache).length, 'modules loaded');
// Force-reload a module (e.g. after file change in development):
function requireFresh(modulePath) {
const resolved = require.resolve(modulePath);
delete require.cache[resolved];
return require(resolved);
}
// ── Circular dependency detection ────────────────────────────────────────
// Detect at startup with madge:
// npx madge --circular src/
// Output: Circular dependencies found: src/a.js -> src/b.js -> src/a.js
How It Works
Step 1 — CJS require() Is Synchronous and Cached
When Node.js executes require('./db'), it synchronously reads, parses, and executes db.js, then caches the result in require.cache keyed by the resolved file path. The next require('./db') returns the cached exports object immediately. This synchronous behaviour is why top-level await is not possible in CJS — it would block the process during module load.
Step 2 — ESM Static Analysis Enables Tree-Shaking
ESM import statements are statically analysable — a build tool can determine exactly which exports are used without executing any code. This enables tree-shaking: unused exports and their transitive dependencies are excluded from the bundle. CJS require() can be called anywhere in code with computed paths, making static analysis impossible. This is why Angular (a bundled application) requires ESM-compatible libraries for effective tree-shaking.
Step 3 — Top-Level await in ESM Enables Sequential Initialisation
ESM modules can use await at the top level of a module file. This means a module can establish a database connection before exporting it: const db = await connectToDatabase(); export { db }. Importing modules that use top-level await must themselves be ESM (or use dynamic import()). This eliminates the need for “connect then start” patterns where you must ensure connection before using the exported object.
Step 4 — Dynamic import() Is Always Asynchronous
import(specifier) is a function call that returns a Promise resolving to the module namespace object. It works in both CJS and ESM files, making it the bridge between the two systems. Use it to lazy-load heavy modules (PDF generators, image processors) that are only needed for specific operations — keeping startup time fast by deferring their loading until first use.
Step 5 — Circular Dependencies Return Incomplete Exports
When Node.js starts loading module A and encounters require('./B'), it starts loading B. When B tries to require('./A'), Node.js returns A’s exports object as it currently exists — incomplete, since A has not finished loading. Any exports that A sets up after the point of the circular require are missing. This produces undefined at the call site, not an error, making circular dependency bugs very hard to diagnose without static analysis tooling.
Quick Reference
| Task | CJS | ESM |
|---|---|---|
| Export value | module.exports = value |
export default value |
| Named export | module.exports.name = fn |
export function name() {} |
| Import default | const x = require('./x') |
import x from './x.js' |
| Named import | const { fn } = require('./x') |
import { fn } from './x.js' |
| Lazy load | const mod = require('./heavy') inside fn |
const mod = await import('./heavy.js') |
| __dirname in ESM | Available | dirname(fileURLToPath(import.meta.url)) |
| Force reload | delete require.cache[require.resolve(path)] |
Not supported (ESM cache immutable) |
| Detect circulars | npx madge --circular src/ |
|