Back/Module F-2 The Module System: CommonJS and ESM
Module F-2·20 min read

require() vs import/export, how Node resolves modules, built-in core modules, and the circular dependency trap every beginner falls into.

Module F-2 — The Module System: CommonJS and ESM

What this module covers: Every file you write in Node.js is a module. Understanding how modules work — how they import and export code, how Node.js finds the files you reference, and why two different systems (require and import) exist side by side — is foundational to everything else. This module explains CommonJS, ES Modules, the Node.js module resolution algorithm, the built-in core modules, and the circular dependency problem that catches every developer at least once.


Why Modules Exist

Imagine writing an entire application in a single file. At a hundred lines it's manageable. At a thousand it's painful. At ten thousand it's unmaintainable. You cannot test pieces of it in isolation. You cannot reuse logic across projects. Every variable is global and any part of the code can accidentally overwrite anything else.

Modules solve this by giving each file its own scope. Variables defined in utils.js don't exist in server.js unless utils.js explicitly exports them and server.js explicitly imports them. This is the entire point: controlled, explicit sharing of code between files.

Node.js has had two module systems across its history: CommonJS (the original, still widely used) and ES Modules (the modern standard, aligned with the browser). You will encounter both in the wild. You need to understand both.


CommonJS: The Original System

CommonJS was Node.js's module system from day one in 2009. It uses require() to load modules and module.exports (or exports) to expose code from a file.

Exporting from a file

javascript
// math.js function add(a, b) { return a + b; } function multiply(a, b) { return a * b; } // Export an object containing both functions module.exports = { add, multiply };

You can also export a single value:

javascript
// greet.js module.exports = function greet(name) { return `Hello, ${name}!`; };

Or add to exports incrementally:

javascript
// utils.js exports.formatDate = function(date) { return date.toISOString().split('T')[0]; }; exports.capitalize = function(str) { return str.charAt(0).toUpperCase() + str.slice(1); };

Importing with require()

javascript
// app.js const math = require('./math'); // loads math.js const greet = require('./greet'); // loads greet.js const { formatDate } = require('./utils'); // destructure what you need console.log(math.add(2, 3)); // 5 console.log(greet('Jatin')); // Hello, Jatin! console.log(formatDate(new Date())); // 2026-05-21

A few things to notice:

  • The path './math' is relative to the current file. The ./ means "same directory". Without it, Node.js looks in node_modules (covered shortly).
  • The .js extension is optional — require('./math') and require('./math.js') are equivalent.
  • require() is synchronous — it blocks until the file is fully loaded and executed. This is fine at startup but you should never call require() inside a hot code path (inside a function that runs on every request).

The module.exports vs exports distinction

This trips up beginners frequently. Both module.exports and exports start out pointing to the same object. But if you reassign module.exports, you replace the export entirely:

javascript
// This works — adding properties to the shared object exports.foo = 'bar'; // This also works — replacing module.exports entirely module.exports = { foo: 'bar' }; // This BREAKS — you reassign exports but module.exports still points to {} exports = { foo: 'bar' }; // ❌ This is a local variable reassignment

Rule of thumb: Use module.exports = ... when you want to export a single thing (a function, a class, an object). Use exports.name = ... when you want to add properties incrementally. Never reassign exports directly.


ES Modules: The Modern Standard

ES Modules (ESM) were standardised in ES2015 and added to Node.js in v12. They use import and export syntax and are the module system used in browsers, Deno, and all modern JavaScript tooling.

Exporting from a file

javascript
// math.mjs (or math.js with "type": "module" in package.json) export function add(a, b) { return a + b; } export function multiply(a, b) { return a * b; } // A default export — one per file export default function divide(a, b) { return a / b; }

Importing

javascript
// app.mjs import { add, multiply } from './math.mjs'; import divide from './math.mjs'; // imports the default export console.log(add(2, 3)); // 5 console.log(divide(10, 2)); // 5

You can also import everything under a namespace:

javascript
import * as math from './math.mjs'; console.log(math.add(2, 3)); // 5

Key differences from CommonJS

FeatureCommonJSES Modules
Syntaxrequire() / module.exportsimport / export
ExecutionSynchronousAsynchronous (allows top-level await)
When resolvedRuntimeParse time (static analysis possible)
Default exportmodule.exports = valueexport default value
Tree-shaking❌ Not possible✅ Bundlers can eliminate unused exports
__dirname✅ Available❌ Use import.meta.url instead
Top-level await

How Node.js Knows Which System to Use

This is where confusion often comes from. Node.js decides whether a file is CommonJS or ESM based on two things:

1. File extension:

  • .cjs → always CommonJS
  • .mjs → always ES Modules
  • .js → depends on the package.json

2. The "type" field in package.json:

  • "type": "module" → all .js files in this package are ESM
  • "type": "commonjs" (or no "type" field) → all .js files are CommonJS
json
// package.json — makes all .js files in this project use ESM { "name": "my-project", "type": "module", "version": "1.0.0" }

Practical guidance for new projects in 2026:

Most modern projects use "type": "module" and write ESM (import/export). But a huge portion of the npm ecosystem — especially older packages — is CommonJS. You will regularly need to understand both. When you npm install a package and it uses require() internally, that's fine even in an ESM project; Node.js handles the interop.

If you are joining an existing project, look at the package.json first to understand which system it uses. If there is no "type" field, it's CommonJS.


The Module Resolution Algorithm

When you write require('./utils') or import { foo } from './utils', how does Node.js find the actual file? The algorithm is straightforward:

For relative paths (./, ../)

  1. Try the exact path: ./utils
  2. Try with .js: ./utils.js
  3. Try with .json: ./utils.json
  4. Try with .node (native addon): ./utils.node
  5. Try as a directory: look for ./utils/index.js, ./utils/index.json

For bare specifiers (no ./ prefix)

javascript
require('express') // bare specifier — looks in node_modules
  1. Look in node_modules/express/ in the current directory
  2. Look in node_modules/express/ in the parent directory
  3. Keep going up the directory tree until the filesystem root
  4. Throw Cannot find module 'express' if not found anywhere

This means if you accidentally name a file express.js in your project root, it will shadow the express package from node_modules. Don't do that.

Why this matters

Understanding resolution saves you from mysterious errors:

Error: Cannot find module './utils'

This means Node.js looked in the right places and found nothing. Check:

  • Is the path relative (./) or did you forget the ./?
  • Is the filename spelled correctly (case matters on Linux)?
  • Does the file actually exist?
  • Are you in the right directory when running node?

Built-in Core Modules

Node.js ships with a standard library of built-in modules. You load them just like any other module — but since they are built into the Node.js binary, there is no file to find and no npm install needed.

You can prefix them with node: to make it explicit that it's a built-in (recommended in modern code):

javascript
const fs = require('node:fs'); const path = require('node:path'); const http = require('node:http'); const crypto = require('node:crypto'); const os = require('node:os');

The most commonly used built-in modules:

ModulePurpose
fsFile system — read, write, watch files and directories
pathFile path manipulation — join, resolve, parse
http / httpsCreate HTTP/HTTPS servers and make requests
cryptoHashing, encryption, random bytes
osOS info — CPUs, memory, hostname, home directory
processProcess info, env vars, stdin/stdout, exit
eventsThe EventEmitter class (covered in F-9)
streamReadable, Writable, Transform streams
bufferBinary data handling
utilUtility functions: promisify, inspect, format
urlURL parsing and construction
child_processSpawn and communicate with child processes

You do not need to memorise all of these. You will learn them as you need them. The key ones for early development are fs, path, and http.


Circular Dependencies

A circular dependency happens when module A imports module B, and module B imports module A. Node.js handles this without crashing — but the result may not be what you expect.

javascript
// a.js const b = require('./b'); console.log('a.js: b.value =', b.value); exports.value = 'A'; // b.js const a = require('./a'); console.log('b.js: a.value =', a.value); exports.value = 'B'; // main.js require('./a');

Running node main.js produces:

b.js: a.value = undefined
a.js: b.value = B

Why undefined? When a.js starts executing and hits require('./b'), Node.js starts loading b.js. b.js then does require('./a') — but a.js is already being loaded. To prevent infinite recursion, Node.js returns whatever a.js has exported so far. Since a.js hasn't finished executing yet, exports.value hasn't been set. b.js gets an empty object.

The fix: restructure your code to remove the cycle. Usually this means:

  1. Extract shared code into a third module that both A and B import — but which imports neither.
  2. Delay the require: put require('./b') inside a function that runs after initialisation, not at the top level.
  3. Rethink the architecture: circular dependencies often indicate that two modules are too tightly coupled and should be combined or restructured.

ESM has stricter behaviour with circular dependencies — you will get a ReferenceError at runtime for the values that haven't been initialised yet, making the problem harder to ignore but easier to diagnose.

Practical rule: if you see undefined where you expected an exported value, and both files import each other, you have a circular dependency. Move the shared code out.


Quick Reference

javascript
// CommonJS — most compatible, default for packages without "type": "module" const fs = require('node:fs'); const { add } = require('./math'); module.exports = { myFunction }; // ES Modules — modern, use with "type": "module" in package.json import fs from 'node:fs'; import { add } from './math.js'; // extension required in ESM export function myFunction() { } export default myFunction; // Dynamic import — works in both systems, returns a Promise const module = await import('./heavy-module.js');

Summary

  • CommonJS uses require() and module.exports. It is synchronous, runs at runtime, and is still the default for packages without a "type" field.
  • ES Modules use import/export. They are static (resolved at parse time), support top-level await, and enable tree-shaking. They are the modern standard.
  • Which to use: set "type": "module" in package.json for new projects and write ESM. You will still encounter CommonJS in dependencies — that's fine.
  • Resolution order: exact path → .js.jsonindex.js for relative paths. Walk up node_modules/ directories for bare specifiers.
  • Core modules are built into Node.js. Prefix with node: for clarity. No installation required.
  • Circular dependencies produce confusing undefined values. The fix is to extract shared code into a third module, not to work around the symptom.

Next: working with the file system, environment variables, and paths — the three things you reach for in almost every Node.js script.


The ESM/CJS Interop Reality — The Dependency Upgrade Trap

The Node.js ecosystem has been migrating from CommonJS to ESM since 2020. In 2025, a significant portion of popular packages are ESM-only. If your project is CommonJS, you will hit this wall when upgrading dependencies.

Which Packages Went ESM-Only

  • chalk >= v5 — terminal colors (most projects use this)
  • node-fetch >= v3 — HTTP client (superseded by native fetch, but legacy code uses it)
  • got >= v12 — HTTP client
  • nanoid >= v4 — ID generation
  • ora >= v6 — terminal spinners
  • execa >= v6 — child processes
  • p-limit >= v4 — concurrency limiting

The error when you require() an ESM-only package:

Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/node_modules/chalk/source/index.js 
from /path/to/your/file.js not supported.
Instead change the require of /path/to/node_modules/chalk/source/index.js to a dynamic 
import() which is available in all CommonJS modules.

The "type": "module" Field

Adding "type": "module" to package.json makes ALL .js files in the package treated as ESM. Without it, .js files are CJS.

The file extension overrides:

  • .mjs — always ESM, regardless of "type" field
  • .cjs — always CJS, regardless of "type" field
  • .js — follows "type" field (default: CJS)

Dynamic import() — The CJS Escape Hatch

CJS can import ESM using dynamic import():

javascript
// CJS file — can use dynamic import() for ESM-only packages const { default: chalk } = await import('chalk') console.log(chalk.blue('Hello'))

The limitation: import() is async. You cannot use it at the top of a CJS module synchronously. Wrap it in an async function or move it to a place where async is acceptable (inside request handlers, not at module initialization).

javascript
// Pattern: lazy import with caching let _chalk = null async function getChalk() { if (!_chalk) _chalk = (await import('chalk')).default return _chalk } app.get('/test', async (req, res) => { const chalk = await getChalk() console.log(chalk.green('Request received')) res.json({ ok: true }) })

The Dual Package Hazard

Some packages publish both CJS and ESM versions. When CJS and ESM code both import such a package, you can end up with two separate instances of the package in memory — one per module system. This breaks singletons (the same "instance" behaves differently from two code paths).

Detection: if a package's state (config, connection pool, event emitter) set from CJS code is invisible to ESM code, you have the dual package hazard.

Resolution: commit fully to one module system. For new projects: ESM. For legacy CJS projects: pin packages to their last CJS-compatible version until you're ready to migrate.

TypeScript moduleResolution

TypeScript's moduleResolution setting controls how it resolves imports:

SettingNode.js versionPackage.json exportsUse when
"node"AnyIgnoredLegacy projects
"node16"Node 16+RespectedCJS with occasional ESM
"nodenext"Node 16+RespectedFull ESM
"bundler"BundlerRespectedVite, webpack, Next.js

The wrong moduleResolution causes TypeScript to resolve imports correctly at compile time but Node.js to fail at runtime with ERR_REQUIRE_ESM. Use "moduleResolution": "node16" or "bundler" for modern Node.js projects.

The Migration Path for Existing CJS Projects

  1. Audit dependencies: npx are-the-types-wrong checks for ESM/CJS type mismatches in your installed packages
  2. Identify ESM-only blockers: grep -r "ERR_REQUIRE_ESM" node_modules/.pnpm-debug.log after a test failure
  3. Pin or downgrade ESM-only packages to their last CJS version temporarily (chalk@4, node-fetch@2)
  4. Migrate file by file to .mjs or add "type": "module" to package.json when ready
  5. Update TypeScript config — change moduleResolution after migration

Discussion