CommonJS vs ES Modules — Module System Internals and Interop

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
Note: CJS modules are cached after the first 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.
Tip: For a MEAN Stack monorepo sharing types between Angular and Express, use a dedicated 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).
Warning: Circular dependencies in CommonJS are silently tolerated but produce empty objects. If module A requires module B, and module B requires module A, Node.js returns an incomplete exports object for whichever module is still loading at the time of the circular require. The bug typically manifests as “X is not a function” when calling an imported function, because the import arrived as 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/

🧠 Test Yourself

Module A require()s module B, and module B require()s module A at the top level. What does B receive when it requires A, and what is the symptom in production?