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.
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 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.
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.
// db.js console.log('Connecting to the database...'); const connection = { id: Math.random() }; module.exports = connection;
// a.js const db1 = require('./db');
// b.js const db2 = require('./db'); console.log(db1 === db2); // true — same object, same connection
'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.
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).
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:
// 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.
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:
// math.js function add(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)); }
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 (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.
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
// 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.
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:
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.
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:
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.
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):
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.
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.
// 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');
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 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.
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.
// 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).
// Pattern: lazy import with caching let _chalk = null async function getChalk() { if (!_chalk) _chalk = (await import('chalk')).default return _chalk }
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'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.