A module in Node is just a file - but a file loaded under rules that give it its own private scope, its own way of declaring what it shares, and a defined path for how other files can pull it in. That sounds simple, and it mostly is, except Node actually supports two different systems for those rules - CommonJS and ES Modules - plus several distinct categories of module depending on where the code comes from.
This page is the map before the territory: Modules Basics walks through working code for both systems, and CommonJS (require) / ES Modules (import) go deep on each one's syntax and behavior. Here, the goal is the mental model - what actually distinguishes a "type" of module, and why it matters which one you're looking at.
Node modules split along two independent axes - the system that loads them (CommonJS or ES Modules) and their origin (core, local, third-party, JSON, or native/WASM) - and both axes change how a given module actually behaves.
Insight: Most "why doesn't this import work" bugs are really "these are two different module systems, not two spellings of the same thing" - and knowing which system and which origin you're dealing with tells you what's actually possible.
When to Use This Model: Starting a new project and deciding a default, debugging require is not defined or Cannot use import statement outside a module, maintaining code that mixes old and new modules, or publishing a package that has to serve both kinds of consumers.
Limitations/Trade-offs: Two systems means real interop friction - synchronous vs. asynchronous loading, static vs. dynamic exports, and a genuine risk of a dependency loading twice as "two different modules" if it's pulled in both ways.
Related Topics: CommonJS require, ES Modules import, package.json"type" & exports, CJS/ESM interop, the module resolution algorithm.
Before any module system existed, every JavaScript file in a program shared one global scope - two files could silently clobber each other's variables just by both declaring x. A module system's first job is solving that: wrapping each file in its own private scope, so nothing leaks in or out except what the file explicitly shares.
Node needed this from day one (2009), years before JavaScript itself had an official answer - so it adopted CommonJS, a synchronous, require()-based system built for server-side, file-system-backed code. When JavaScript later standardized its own module system, ES Modules (import/export), Node had to add support for a second, different system alongside the one it already had - which is why Node today runs on two systems rather than one.
A simple way to picture the difference: CommonJS treats require() like handing a note to an assistant while you're already working - "go get me that file" - and waiting right there until it comes back, so you can call it anywhere, even conditionally. ES Modules instead treats import like submitting a shipping manifest before work starts - every dependency is declared upfront, in a fixed spot, before a single line of the module's own code runs.
// CJS: pulled in dynamically, wherever you call itconst math = require('./math.js');// ESM: declared statically, always at the top, before this file executesimport * as math from './math.js';
By system, CommonJS and ES Modules differ in more than punctuation:
CommonJS (CJS)
ES Modules (ESM)
Load timing
Synchronous - blocks until the file is read and run
Asynchronous - the whole dependency graph resolves before execution
Import style
Dynamic - require() is a real function, callable conditionally
Static - import is a declaration, parsed before any code runs
Exports
A mutable module.exports object, copied at require-time
Live bindings - importers see updates to exported values
Scope helpers
__dirname, __filename, module, exports available automatically
None of those - use import.meta.url instead
Caching
require.cache, keyed by resolved file path
The module graph itself; re-importing the same specifier reuses the instance
By origin, a module can come from five different places, and that origin - independent of which system loads it - determines how (or whether) it's installed at all:
Core / built-in modules - compiled into the Node binary itself (fs, http, path, …). Nothing to install; the node: prefix (node:fs) makes the source unambiguous in either system.
Local / file modules - your own project files, resolved by relative or absolute path.
Third-party (npm) modules - installed into node_modules, located by Node's resolution algorithm walking up the directory tree from the importing file.
JSON modules - plain data, not code - require() reads JSON implicitly in CJS; ESM requires an explicit with { type: 'json' } import attribute.
Native addons / WebAssembly modules - compiled machine code (C++/Rust via N-API, or a .wasm binary) loaded through a different mechanism entirely, exposing a JS-shaped interface without being JavaScript itself.
Which system applies to a given local .js file isn't guesswork - Node decides it from package.json's "type" field ("module" vs. the CommonJS default) and from the file's own extension (.mjs and .cjs always override "type", forcing ESM or CJS respectively regardless of the package setting). package.json "type" & exports covers that decision in full; Module Resolution Algorithm covers how a specifier like 'lodash' or './utils.js' actually turns into a file on disk.
Because CJS and ESM are genuinely different loading systems, not just different syntax, mixing them has real failure modes rather than cosmetic ones:
Concern
CommonJS
ES Modules
Tree-shaking (bundlers)
Poor - dynamic require() defeats static analysis
Strong - static imports are analyzable before execution
Conditional loading
Natural - require() is just a function call
Requires dynamic import(), which returns a Promise
Top-level await
Not possible - require is synchronous
Supported natively
Interop risk
Loading the same package via both require and import can instantiate it twice - a real "dual package hazard" that breaks instanceof checks and shared singleton state
Same risk, from the other direction
That dual-package hazard is the sharpest edge in this whole area: a dependency loaded once through require() and once through import isn't guaranteed to be the same module instance, which quietly breaks anything relying on reference equality or shared module-level state. CJS ↔ ESM Interop covers createRequire, explicit .mjs/.cjs extensions, and how to avoid it during a migration.
Native addon and WebAssembly modules sit outside this whole CJS/ESM comparison - they're loaded through their own bindings rather than parsed as JavaScript source at all, but they still show up to your code as an ordinary imported object once loaded, which is why they're worth naming as a category even though this page doesn't go deeper into building one.
For new code, current guidance is unambiguous: default to ES Modules via "type": "module" in package.json. CommonJS isn't deprecated and Node has no plan to remove it - the ecosystem is far too large for that - but ESM's static structure is what modern tooling (bundlers, type checkers, node --experimental-strip-types) is increasingly built to assume.
"CommonJS and ES Modules are just two syntaxes for the same thing." They differ in loading semantics, not spelling - synchronous vs. asynchronous graph resolution, dynamic vs. static imports, copied values vs. live bindings.
"A Node module always comes from npm." Plenty never touch node_modules at all - core modules ship inside the Node binary, and local files are modules the moment another file imports them.
"ESM is the modern one, so CommonJS is going away." CommonJS remains fully supported with no removal planned; ESM is the recommended default for new code, not a replacement rolling out underneath existing packages.
"You can freely mix require and import in the same file." A single file's system is fixed by its extension and its package's "type" field - you can bridge the two systems (createRequire, dynamic import()), but you can't declare both require and top-level import statements in one file.
"A .json file imported into a module is basically JavaScript." It's data, not code - CJS reads it implicitly via require(), while ESM requires an explicit type: 'json' import attribute, and neither system executes it.
Any file loaded under a module system's rules - given its own scope, with an explicit way to export values and a defined path for other files to import it. Plain top-level scripts run outside a module system don't get that isolation.
Why does Node have two module systems instead of one?
CommonJS existed years before JavaScript standardized its own module syntax, so Node built on it first. Once ES Modules became the language's official system, Node added support for it too - rather than break the enormous existing CommonJS ecosystem by switching outright.
Are core modules like `fs` and `http` a different "type" from my own files?
Yes, by origin - core modules are compiled into the Node binary and need no installation, unlike local files or npm packages. They're still loaded through whichever system (CJS or ESM) your importing file uses.
How does Node decide if a `.js` file is CommonJS or ESM?
It checks the file's own extension first - .mjs always means ESM, .cjs always means CommonJS, regardless of anything else. For plain .js files, it looks at the nearest package.json's "type" field, defaulting to CommonJS if that field is absent.
Can a single file use both `require` and `import`?
No - a file's module system is fixed by its extension/"type", and each system only recognizes its own syntax. You can bridge between them (createRequire to get require inside an ESM file, or dynamic import() inside a CJS one), but you can't mix the static import/export and require/module.exports forms in the same file.
What's a JSON module, and why does it need special syntax in ESM?
It's a plain data file (.json) treated as an importable module rather than executable code. CommonJS reads it implicitly through require(); ESM requires an explicit import attribute (with { type: 'json' }) because, unlike CJS, ESM needs to know a specifier's content type before it can decide how to parse it.
Are native addons and WebAssembly files really "modules"?
They behave like one from your code's perspective - you import/require them and get back a JS-shaped object - but they're loaded through their own binding mechanism rather than parsed as JavaScript source, which is why they're categorized separately from CJS/ESM.
What's the "dual package hazard" I should watch out for?
It's when the same package gets loaded once via require() and once via import, producing two separate module instances instead of one shared one - which silently breaks instanceof checks and any state the module expected to be a singleton. CJS ↔ ESM Interop covers how to avoid it.
Should a new project use CommonJS or ES Modules?
ES Modules, via "type": "module" in package.json. It's the language's standard system, has stronger tooling support (tree-shaking, static analysis, top-level await), and is the direction the ecosystem is actively moving - CommonJS stays fully supported for existing and legacy code.
Why does tree-shaking work better with ES Modules?
Bundlers can only safely remove unused code when they can prove, statically, what a module imports and exports - which ESM's fixed, top-of-file import/export declarations guarantee. CommonJS's require() is a runtime function call, so a bundler can't always tell what it loads without actually running the code.
Does `import()` (the function form) belong to ESM or CommonJS?
Both can use it - dynamic import() is a function that returns a Promise, available even inside CommonJS files, specifically so either system can load a module conditionally or asynchronously without needing a static import declaration.