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 innode_modules(covered shortly). - The
.jsextension is optional —require('./math')andrequire('./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 callrequire()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:
javascriptimport * as math from './math.mjs'; console.log(math.add(2, 3)); // 5
Key differences from CommonJS
| Feature | CommonJS | ES Modules |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Execution | Synchronous | Asynchronous (allows top-level await) |
| When resolved | Runtime | Parse time (static analysis possible) |
| Default export | module.exports = value | export 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 thepackage.json
2. The "type" field in package.json:
"type": "module"→ all.jsfiles in this package are ESM"type": "commonjs"(or no"type"field) → all.jsfiles 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 (./, ../)
- Try the exact path:
./utils - Try with
.js:./utils.js - Try with
.json:./utils.json - Try with
.node(native addon):./utils.node - Try as a directory: look for
./utils/index.js,./utils/index.json
For bare specifiers (no ./ prefix)
javascriptrequire('express') // bare specifier — looks in node_modules
- Look in
node_modules/express/in the current directory - Look in
node_modules/express/in the parent directory - Keep going up the directory tree until the filesystem root
- 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):
javascriptconst 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:
| Module | Purpose |
|---|---|
fs | File system — read, write, watch files and directories |
path | File path manipulation — join, resolve, parse |
http / https | Create HTTP/HTTPS servers and make requests |
crypto | Hashing, encryption, random bytes |
os | OS info — CPUs, memory, hostname, home directory |
process | Process info, env vars, stdin/stdout, exit |
events | The EventEmitter class (covered in F-9) |
stream | Readable, Writable, Transform streams |
buffer | Binary data handling |
util | Utility functions: promisify, inspect, format |
url | URL parsing and construction |
child_process | Spawn 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:
- Extract shared code into a third module that both A and B import — but which imports neither.
- Delay the require: put
require('./b')inside a function that runs after initialisation, not at the top level. - 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()andmodule.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-levelawait, and enable tree-shaking. They are the modern standard. - Which to use: set
"type": "module"inpackage.jsonfor new projects and write ESM. You will still encounter CommonJS in dependencies — that's fine. - Resolution order: exact path →
.js→.json→index.jsfor relative paths. Walk upnode_modules/directories for bare specifiers. - Core modules are built into Node.js. Prefix with
node:for clarity. No installation required. - Circular dependencies produce confusing
undefinedvalues. 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 nativefetch, but legacy code uses it)got>= v12 — HTTP clientnanoid>= v4 — ID generationora>= v6 — terminal spinnersexeca>= v6 — child processesp-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:
| Setting | Node.js version | Package.json exports | Use when |
|---|---|---|---|
"node" | Any | Ignored | Legacy projects |
"node16" | Node 16+ | Respected | CJS with occasional ESM |
"nodenext" | Node 16+ | Respected | Full ESM |
"bundler" | Bundler | Respected | Vite, 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
- Audit dependencies:
npx are-the-types-wrongchecks for ESM/CJS type mismatches in your installed packages - Identify ESM-only blockers:
grep -r "ERR_REQUIRE_ESM" node_modules/.pnpm-debug.logafter a test failure - Pin or downgrade ESM-only packages to their last CJS version temporarily (chalk@4, node-fetch@2)
- Migrate file by file to
.mjsor add"type": "module"topackage.jsonwhen ready - Update TypeScript config — change
moduleResolutionafter migration