CommonJS vs ES Modules, Module Caching
As a Node.js app grows, you split code into modules: separate files that share code with each other. Node.js has two module systems, CommonJS and ES Modules, and both cache a module after it loads the first time.
Definition
- CommonJS (CJS): Node's original module system. It uses
require()to import andmodule.exportsto export. - ES Modules (ESM): the official JavaScript standard, used in browsers and modern Node.js. It uses
importandexport. - Module caching: after a module is loaded once, Node.js stores it. Every later import returns the same stored copy, without running the file again.
Analogy: think of borrowing a book from a library. The first time, the library has to fetch it from storage (load and run the file). After that, it stays on the front shelf, and everyone who asks gets that same copy (the cache).
Syntax Side by Side
// math.jsfunction add(a, b) {return a + b;}module.exports = { add };// app.jsconst { add } = require("./math");console.log(add(2, 3)); // 5
Key Differences
| CommonJS | ES Modules | |
|---|---|---|
| Import / export | require() / module.exports | import / export |
| Loading | Synchronous, at runtime | Asynchronous, analyzed before the code runs |
| Where imports can go | Anywhere, even inside if or functions | Top level only (use import() for dynamic loading) |
| Exported values | A copy of the value at export time | A live binding to the original variable |
Top-level await | Not supported | Supported |
| Strict mode | Off by default | Always on |
this at top level | module.exports | undefined |
__dirname, __filename | Available | Not available (use import.meta.dirname and import.meta.filename) |
| File extension in imports | Optional (./math) | Required (./math.mjs) |
| Tree shaking | Hard, because imports are dynamic | Easy, because imports are static |
| Used in browsers | No | Yes |
How Node.js Decides Which System to Use
| File | Treated as |
|---|---|
.cjs | Always CommonJS |
.mjs | Always ES Module |
.js with "type": "module" in package.json | ES Module |
.js with no "type", or "type": "commonjs" | CommonJS |
{"name": "my-app","type": "module"}
Copy vs Live Binding
This difference is a common interview question.
// counter.jslet count = 0;function increment() {count++;}module.exports = { count, increment };// app.jsconst counter = require("./counter");console.log(counter.count); // 0counter.increment();console.log(counter.count); // 0 (still the old copy)
- In CommonJS,
countwas copied into the exports object when the module ran, so later changes don't show up. - In ES Modules,
countis a live link to the variable insidecounter.mjs, so you always see its current value.
Using One System From the Other
| From | To | How |
|---|---|---|
| ESM | CJS | import pkg from "./file.cjs" works. module.exports becomes the default export |
| CJS | ESM | Use dynamic import(), which returns a promise. Newer Node.js versions (22.12+ and 20.19+) also allow require() of an ES module, as long as it doesn't use top-level await |
// Inside a CommonJS fileasync function main() {const { add } = await import("./math.mjs");console.log(add(2, 3));}main();
Module Caching
The first time a module is imported, Node.js runs the file and stores the result. Every later import gets the same object from the cache. The file does not run again.
// config.jsconsole.log("config loaded");module.exports = { count: 0 };// app.jsconst a = require("./config"); // prints "config loaded"const b = require("./config"); // nothing printed: comes from the cachea.count++;console.log(b.count); // 1console.log(a === b); // true (the same object)
Step by step:
- The first
requireresolves the full path, runsconfig.js, and storesmodule.exportsin the cache. - The second
requirefinds the path already in the cache and returns the same object. - Changing
a.countalso changesb.count, becauseaandbare the same object.
ES Modules work the same way: each module is evaluated only once, and every import shares it.
Why Caching Is Useful
- Faster: each file is read and run only once.
- Shared state: a module works like a singleton. A database connection or config object is created once and shared across the app.
- Safer circular imports: if two modules import each other, the cache stops them from loading forever.
The Cache Key Is the Resolved File Path
The cache is keyed by the full resolved path, not by the text you write in require.
| Import | Same cache entry? |
|---|---|
require("./config") and require("./config.js") | Yes, both resolve to the same file |
The same package installed in two different node_modules folders | No, two different paths, so two separate copies |
Clearing the Cache
// Remove the module from the cache, so the next require runs it againdelete require.cache[require.resolve("./config")];const fresh = require("./config"); // prints "config loaded" again
You rarely need this outside of tests and hot-reloading tools.
Which Should You Use?
- For new projects, use ES Modules: it is the standard, works in browsers, supports top-level
await, and enables tree shaking. - Use CommonJS when working on older codebases or with packages that only support it.
Summary
- CommonJS uses
require/module.exports, loads synchronously, and allows imports anywhere. - ES Modules use
import/export, are analyzed before running, and support top-levelawait. - CommonJS exports a copy. ES Modules export a live binding.
- Node.js picks the system by file extension (
.cjs,.mjs) or the"type"field inpackage.json. - ESM can import CJS directly. CJS loads ESM with
import(), or withrequire()in newer Node.js versions. - Module caching: a module runs once, and every later import returns the same object.
- The cache is keyed by the resolved file path. It can be cleared in CJS with
require.cache.
Interview Answer: What Is the Difference Between CommonJS and ES Modules?
CommonJS is Node's original module system. It uses require() and module.exports, loads modules synchronously at runtime, and allows require anywhere in the code. ES Modules are the JavaScript standard. They use import and export, are analyzed before the code runs, support top-level await, run in strict mode, and work in browsers. Because ESM imports are static, bundlers can tree-shake unused code.
Another key difference is that CommonJS exports a copy of a value, while ES Modules export a live binding, so an importer always sees the variable's current value.
Both systems cache modules. The first import runs the file and stores the result, keyed by its resolved path. Every later import returns the same object without running the file again. That makes modules behave like singletons, which is useful for sharing a database connection or config. In CommonJS, I can clear a cached module by deleting it from require.cache.