CodeOath
← All posts
Node.js36 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 4 of 14 · ~4 min

Modules & npm

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

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

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.

How Node decides which system a file uses

SignalResult
File extension .mjsAlways treated as an ES Module
File extension .cjsAlways 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 allTreated 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.

package.json essentials

{
  "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. devDependencies

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

Semver ranges: ^ 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:

RangeMeaningExample: ^4.19.2 allows~4.19.2 allows
^ (caret)Allows changes that don't modify the leftmost non-zero digit4.19.3 through anything < 5.0.0
~ (tilde)Allows patch-level changes only4.19.3 through anything < 4.20.0
exact (4.19.2)No automatic changes at allonly 4.19.2only 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.