Node has shipped with two competing module systems for years, and knowing which one a given file is using — and why — comes up constantly.
CommonJS (require/module.exports) was Node's original module system and is still the default for .js files in most existing projects:
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// app.js
const { add } = require("./math");
console.log(add(2, 3)); // 5
require() is synchronous — it reads, compiles, and executes the target module before returning, blocking the caller until it's done — and Node caches the result by file path, so requiring the same module twice returns the exact same object rather than re-running the file.
ES Modules (import/export) are the standardized JavaScript module system — the same syntax browsers use natively — and Node has fully supported them for years now:
// math.mjs
export function add(a, b) { return a + b; }
// app.mjs
import { add } from "./math.mjs";
console.log(add(2, 3)); // 5
The practical differences that actually matter: ESM imports are resolved and loaded asynchronously (letting bundlers and Node itself do static analysis — tree-shaking, for instance — that CommonJS's fully dynamic require() calls can't support), import statements are hoisted and must appear at the top level (no conditionally requiring a module inside an if, though dynamic import() exists as an escape hatch and returns a Promise), and ESM runs in strict mode automatically.
| Signal | Result |
|---|---|
File extension .mjs | Always treated as an ES Module |
File extension .cjs | Always treated as CommonJS |
File extension .js, and the nearest package.json has "type": "module" | Treated as an ES Module |
File extension .js, and the nearest package.json has "type": "commonjs" or no "type" field at all | Treated as CommonJS (the default) |
That last row is the one that surprises people: a plain .js file's module system isn't determined by anything inside the file itself — it's determined by the type field in the closest package.json above it in the directory tree. Mixing the two in one project without being deliberate about .mjs/.cjs extensions or the type field is a common source of require() of ES Module ... not supported errors.
{
"name": "screening-service",
"version": "2.4.1",
"type": "commonjs",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"test": "jest"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"nodemon": "^3.1.0",
"jest": "^29.7.0"
},
"engines": { "node": ">=20" }
}
scripts defines named commands runnable via npm run <name> (start and test get short forms — npm start, npm test — the rest need the full npm run). main points at the entry file another package gets when it require()s yours.
dependencies vs. devDependenciesdependencies are packages your code needs at runtime — a web framework, a database driver, anything required or imported by code that actually runs in production. devDependencies are packages only needed while developing — test runners, linters, a dev-mode auto-restart tool. The distinction matters in deployment: npm install --omit=dev (or the older --production flag) skips devDependencies entirely, which keeps deployed node_modules smaller and avoids shipping tooling your running app never touches.
^ and ~npm dependency versions follow semantic versioning (MAJOR.MINOR.PATCH), and the prefix in front of a version string controls how much a npm install is allowed to upgrade it automatically:
| Range | Meaning | Example: ^4.19.2 allows | ~4.19.2 allows |
|---|---|---|---|
^ (caret) | Allows changes that don't modify the leftmost non-zero digit | 4.19.3 through anything < 5.0.0 | — |
~ (tilde) | Allows patch-level changes only | — | 4.19.3 through anything < 4.20.0 |
exact (4.19.2) | No automatic changes at all | only 4.19.2 | only 4.19.2 |
^ is the npm default when you npm install <package>, and it's built on the semver convention that a major version bump signals breaking changes, while minor and patch bumps are supposed to stay backward-compatible — so ^4.19.2 is npm betting that anything up to (but not including) 5.0.0 is safe to pull in automatically. ~ is more conservative, allowing only bug-fix-level patch releases. Neither range is a guarantee — plenty of packages ship accidental breaking changes in a "minor" release — which is exactly why a package-lock.json (recording the exact resolved versions actually installed) gets committed to source control: it's what makes npm ci reproduce the identical dependency tree on every machine and in CI, regardless of what a bare semver range would currently resolve to.