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

You can also export a single value:

javascript

Or add to exports incrementally:

javascript

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.


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

Importing

javascript

You can also import everything under a namespace:

javascript

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

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
  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

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

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:

  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

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:

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.

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

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.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.