package.json, npm, semver and lockfiles
package.json declares intent with semver ranges; the lockfile pins the exact resolved tree. npm install may rewrite the lock, npm ci installs strictly from it — so commit the lockfile and use npm ci in CI.
The build is green on your laptop and red in CI, and nobody touched the code. The diff is one transitive dependency: a logging library you have never imported shipped a patch overnight, your ^ range silently swallowed it, and it broke on the CI image. Your package.json says "pino": "^9.0.0" — the same string it said yesterday — so the manifest “didn’t change.” What changed is what ^ resolves to today. The fix is one word in the pipeline: npm ci, which installs the exact tree someone already locked, instead of re-solving the ranges from scratch.
package.json: the manifest of intent
package.json is the contract for a Node project — identity, scripts, and what it depends on. The fields that earn their keep at the middle+ level:
{
"name": "billing-service",
"version": "2.4.1",
"type": "module",
"engines": { "node": ">=20" },
"exports": { ".": "./dist/index.js" },
"scripts": { "build": "tsc", "test": "vitest run" },
"dependencies": { "pino": "^9.0.0" },
"devDependencies": { "vitest": "~2.1.0", "typescript": "5.6.3" },
"peerDependencies": { "react": ">=18" },
"optionalDependencies": { "fsevents": "^2.3.0" }
}"type": "module" decides how Node interprets .js files (ESM vs CommonJS — the prior lesson). "exports" is the modern entry map: it defines exactly which paths consumers may import and hides everything else, so deep imports into your internals stop working — it supersedes the older single "main" field. "engines" declares the Node range you support; with a strict installer config it can hard-fail an install on the wrong runtime instead of failing mysteriously at runtime.
The four dependency buckets are not interchangeable:
dependencies— needed at runtime by anyone who installs your package. Shipped.devDependencies— needed only to build and test (compilers, test runners, linters). Installed locally, skipped undernpm install --omit=dev/NODE_ENV=production.peerDependencies— “I work with this, but the host app must provide it.” A React plugin listsreacthere so it shares the host’s single copy instead of bundling a second, duplicate React.optionalDependencies— install is attempted but a failure is non-fatal; your code must guard for absence. Used for platform-specific native addons likefsevents(macOS only).
Semver: a range is a promise, not a pin
Versions are MAJOR.MINOR.PATCH, and per the npm spec each digit is a promise to consumers: PATCH = backward-compatible bug fixes (1.0.0 → 1.0.1), MINOR = backward-compatible new features (1.0.0 → 1.1.0), MAJOR = changes that break backward compatibility (1.0.0 → 2.0.0). A version may carry a prerelease tag like 1.0.0-beta.2, which sorts before 1.0.0 and is excluded from normal ranges unless you ask for it.
What you write in package.json is usually a range, and the operator decides how much drift you accept on the next install:
| Spec | Allows up to (for 1.2.3) | Lets in | Use when |
|---|---|---|---|
^1.2.3 | <2.0.0 | minor + patch | default; you trust the lib’s semver |
~1.2.3 | <1.3.0 | patch only | cautious; want fixes, not features |
1.2.3 | 1.2.3 | nothing | exact pin; max determinism |
* | any version | everything, incl. majors | never in production |
^ is what npm install <pkg> writes by default because it picks up bug fixes and new features automatically while promising to never cross a major. The risk is right there in the hook: that promise depends on the maintainer versioning honestly. A library that ships a breaking change as a minor — or just an accidental regression in a patch — flows straight into your tree on the next un-pinned install. ^ decides the ceiling of what may enter. Something else decides what actually entered last time.
▸Why this works
^0.x is a trap. Below 1.0.0 the caret special-cases to patch-only: ^0.2.3 means >=0.2.3 <0.3.0, not <1.0.0. The reasoning is that pre-1.0 packages are declaring themselves unstable, so every 0.MINOR bump is treated as potentially breaking. Many real dependencies sit on 0.x for years, so this rule bites more often than people expect.
The lockfile: what actually got installed
package.json answers “what am I willing to accept?” package-lock.json answers “what did I get?” When npm resolves your ranges, it walks the whole dependency graph — your deps, their deps, all the way down — picks a concrete version for every node, and writes the entire flattened tree to the lockfile: exact versions, the resolved registry URL, and an integrity hash (a Subresource-Integrity SHA for the tarball). On the next install npm can rebuild byte-identical node_modules and verify each download against its hash.
This is the line between the two install commands, and it is the senior-level point of the lesson:
npm install | npm ci | |
|---|---|---|
| Source of truth | re-solves package.json ranges | installs strictly from the lockfile |
| Mutates the lockfile? | yes — may add/update entries | no — never writes the lock |
| Needs a lockfile? | no (creates one if missing) | yes — errors without one |
| Lock vs manifest mismatch | updates the lock to reconcile | exits with an error |
| node_modules | patches in place | deletes it first, clean install |
| Install one package? | yes (npm i lodash) | no — whole project only |
| Use it for | local dev, adding/removing deps | CI, deploys, any reproducible build |
The senior rule falls out of the table. Commit the lockfile, and in CI run npm ci, not npm install. npm ci is deterministic (it ignores ranges and installs exactly what’s locked), clean (it wipes node_modules first so there is no leftover state), fast (it skips the resolution step), and — crucially — it fails loudly if package-lock.json and package.json have drifted apart, instead of silently rewriting the lock the way npm install would. That failure is a feature: it catches a hand-edited manifest that nobody re-locked, before it ships.
# Local: change deps, which updates package.json AND package-lock.json
npm install lodash # adds "^4.x" to deps, writes the resolved tree to the lock
git add package.json package-lock.json # commit BOTH, together
# CI / Dockerfile: reproduce the locked tree exactly, fail if it drifted
npm ci # clean, deterministic, errors if lock is out of syncnode_modules: flattening, hoisting, and phantoms
npm installs into node_modules as a flat tree where it can: instead of nesting every dependency inside its parent, it hoists shared, compatible versions up to the top level so many packages share one copy. This keeps the tree shallow and small — but it leaks. A package your app never declared can end up hoisted to the top of node_modules, and require("that-package") will work on your machine. That’s a phantom dependency: you’re using something that isn’t in your package.json. The day a dependency upgrade stops hoisting it — or a fresh install resolves a different tree — your import vanishes and the build breaks, with nothing in your manifest to explain why.
This is one reason pnpm exists: it builds a strict, symlinked node_modules where a package can only see what it explicitly declared, making phantom dependencies a hard error rather than a silent crutch. Yarn is the other major alternative, with its own lockfile (yarn.lock) and, in modern versions, a Plug’n’Play mode that drops node_modules entirely. All three solve the same problem npm does; they differ in lockfile format and how aggressively they isolate the tree. The portable lesson is independent of the tool: a declared dependency plus a committed lockfile is the only combination that reproduces.
▸Why this works
The lockfile is also your first supply-chain control. Each entry’s integrity hash means a tampered tarball on the registry fails verification on install, and pinning via the lock means an attacker can’t slip a malicious patch in through your ^ range between builds. Run npm audit to surface known CVEs in the locked tree, and treat the lockfile as a reviewed, committed artifact — a surprise change to it in a PR deserves the same scrutiny as a code change, because it can swap out exactly what executes.
CI is failing on a dependency you never changed. package.json still says the same ^ range. What is the most likely cause and fix?
require('lodash') works locally but lodash is not in your package.json. What is going on?
A team wants reproducible installs in CI and reliable, debuggable upgrades. Which install policy should they adopt?
- 01Explain the difference between npm install and npm ci, and why CI should use npm ci.
- 02What's a phantom dependency, what causes it, and how do you prevent it?
package.json is the manifest of intent: identity (name, version), how Node loads it (type, exports/main, engines), scripts, and four distinct dependency buckets — dependencies (shipped at runtime), devDependencies (build/test only), peerDependencies (provided by the host app), and optionalDependencies (non-fatal if missing). What you write for a dependency is usually a semver range, and the operator sets the ceiling: ^ allows minor and patch (the default, and the source of silent drift), ~ allows patch only, an exact version pins nothing in, and * is never safe in production; remember ^0.x quietly degrades to patch-only. The range is only the ceiling — the lockfile pins reality: package-lock.json records the exact flattened tree with versions and integrity hashes so installs are reproducible and verifiable. That makes the two install commands different in kind: npm install re-solves ranges and may rewrite the lock (right for local dependency changes), while npm ci installs strictly from the lock, wipes node_modules for a clean install, never mutates the lock, and errors if it has drifted from package.json — so the senior policy is to commit the lockfile and use npm ci in CI and deploys. Finally, npm’s flat, hoisted node_modules can produce phantom dependencies — packages you use but never declared, which break on the next reshuffle — so declare what you import, treat the lockfile as a reviewed supply-chain artifact (integrity hashes, npm audit), and reach for pnpm or yarn when you want stricter isolation than npm’s flattening gives you.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.