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
You can also export a single value:
javascript
Or add to exports incrementally:
javascript
Module Caching: require() Only Runs a File Once
Here is the single biggest gap in most beginners' mental model: require() (and import) caches modules by their resolved file path. The first time you require('./db'), Node.js runs db.js top to bottom and caches whatever it exported. Every subsequent require('./db') — from any other file, anywhere in your project — returns that same cached object. It does not re-run db.js.
javascript
javascript
'Connecting to the database...' prints only once, no matter how many files require('./db'). This is not an optimisation detail you can ignore — it's the entire mechanism behind patterns you'll rely on constantly later in this course: a singleton database client that every file shares, a single shared EventEmitter acting as an event bus, a config object loaded once and reused everywhere. If module caching didn't work this way, each require('./db') would open a brand-new, independent database connection.
Importing with require()
javascript
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
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.
Detecting Direct Execution: require.main === module
A common need: a file should behave differently depending on whether it was run directly (node script.js) or loaded by another file via require(). The standard CommonJS idiom for this is require.main === module:
javascript
require.main is set to the module object of whichever file was originally executed by the node command. Comparing it to the current file's own module object tells you whether you're the entry point or just an import. You'll see this idiom constantly in CLI tools and scripts that are also meant to be reusable as libraries.
ES Modules: The Modern Standard
ES Modules (ESM) were standardised in ES2015. Node.js support arrived gradually — experimental support (behind a flag, .mjs extension) landed in Node 8.5 back in 2017, and unflagged, stable support arrived incrementally across Node 12–13. 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
Importing
javascript
You can also import everything under a namespace:
javascript
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 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
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 "exports" Field
Package authors have a second way to control how their package is resolved: the "exports" field in the package's own package.json. Where "type" decides how your.js files are interpreted, "exports" decides what other people importing your published package are allowed to reach, and whether they get the CommonJS or ESM version:
json
This tells Node.js: if the consumer used require('some-package'), hand them index.cjs; if they used import ... from 'some-package', hand them index.mjs. It's also a whitelist — a package with "exports" can only be imported at the paths it explicitly lists, blocking the older pattern of reaching into a package's internals (require('some-package/lib/internal/helper')). You won't often write this field yourself in Foundation-level work, but you will see Cannot find module 'some-package/lib/whatever' errors caused by it, and now you know why.
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)
javascript
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.
require.resolve(): Finding a Path Without Loading It
Sometimes you want to know where Node.js would load a module from, without actually running that module's code. require.resolve() runs the exact algorithm above and gives you back the resolved absolute path:
javascript
This is useful for tooling — checking whether a package is installed before conditionally requiring it, or resolving a path to hand to another process — without paying the cost (or side effects) of executing the module.
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
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.
Analogy: Calling require() twice on the same module is like inserting the same code into a vending machine twice — you get the identical snack from inventory both times, not a freshly baked one. The module caching from earlier in this module is exactly why circular requires don't infinitely re-run each other — Node.js hands back whatever is currently in the machine, even if that snack isn't fully "baked" yet.
javascript
Running node main.js produces:
text
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
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 → .json → index.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:
text
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
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
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.
From production: A blockchain indexer kept track of the last block height it had processed in a CommonJS singleton — one shared module-level variable, updated after every successfully committed batch, read by every part of the app before writing new events. During a partial migration to ESM, one code path ended up pulling in the CJS build of that state module while another pulled in the ESM build — the dual package hazard described above. Node.js treated them as two separate modules with two separate copies of "the last processed block," each maintaining its own answer. The ESM side's copy fell behind, and on restart the indexer trusted the stale value and replayed roughly 40,000 already-committed events straight into Postgres — all because "the singleton" was quietly two singletons.
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 @arethetypeswrong/cli <package-name> (the attw CLI) checks for ESM/CJS type mismatches in your installed packages
Identify ESM-only blockers:grep "ERR_REQUIRE_ESM" pnpm-debug.log after 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 .mjs or add "type": "module" to package.json when ready
Update TypeScript config — change moduleResolution after migration
Knowledge Check
In a CommonJS module, why does the code exports = { foo: 'bar' } fail to properly export the object?
What is a key difference in how CommonJS and ES Modules handle module resolution and loading?
What happens when two CommonJS modules have a circular dependency?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// math.jsfunctionadd(a, b){return a + b;}functionmultiply(a, b){return a * b;}// Export an object containing both functionsmodule.exports={ add, multiply };
// db.jsconsole.log('Connecting to the database...');const connection ={id:Math.random()};module.exports= connection;
// a.jsconst db1 =require('./db');// b.jsconst db2 =require('./db');console.log(db1 === db2);// true — same object, same connection
// app.jsconst math =require('./math');// loads math.jsconst greet =require('./greet');// loads greet.jsconst{ formatDate }=require('./utils');// destructure what you needconsole.log(math.add(2,3));// 5console.log(greet('Jatin'));// Hello, Jatin!console.log(formatDate(newDate()));// 2026-05-21
// This works — adding properties to the shared objectexports.foo='bar';// This also works — replacing module.exports entirelymodule.exports={foo:'bar'};// This BREAKS — you reassign exports but module.exports still points to {}exports ={foo:'bar'};// ❌ This is a local variable reassignment
// math.jsfunctionadd(a, b){return a + b;}module.exports={ add };// If this file was run directly (node math.js), run a quick self-test.// If it was require()'d from elsewhere, skip this and just export.if(require.main=== module){console.log('2 + 3 =',add(2,3));}
// math.mjs (or math.js with "type": "module" in package.json)exportfunctionadd(a, b){return a + b;}exportfunctionmultiply(a, b){return a * b;}// A default export — one per fileexportdefaultfunctiondivide(a, b){return a / b;}
// a.jsconst b =require('./b');console.log('a.js: b.value =', b.value);exports.value='A';// b.jsconst a =require('./a');console.log('b.js: a.value =', a.value);exports.value='B';// main.jsrequire('./a');
b.js: a.value = undefined
a.js: b.value = B
// 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.jsonimportfsfrom'node:fs';import{ add }from'./math.js';// extension required in ESMexportfunctionmyFunction(){}exportdefault myFunction;// Dynamic import — works in both systems, returns a Promiseconst module =awaitimport('./heavy-module.js');
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.
// CJS file — can use dynamic import() for ESM-only packagesconst{default: chalk }=awaitimport('chalk')console.log(chalk.blue('Hello'))