open atlas
↑ Back to track
Node.js, zero to senior NODE · 01 · 02

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.

NODE Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

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 under npm install --omit=dev / NODE_ENV=production.
  • peerDependencies — “I work with this, but the host app must provide it.” A React plugin lists react here 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 like fsevents (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.01.0.1), MINOR = backward-compatible new features (1.0.01.1.0), MAJOR = changes that break backward compatibility (1.0.02.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:

SpecAllows up to (for 1.2.3)Lets inUse when
^1.2.3<2.0.0minor + patchdefault; you trust the lib’s semver
~1.2.3<1.3.0patch onlycautious; want fixes, not features
1.2.31.2.3nothingexact pin; max determinism
*any versioneverything, incl. majorsnever 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 installnpm ci
Source of truthre-solves package.json rangesinstalls strictly from the lockfile
Mutates the lockfile?yes — may add/update entriesno — never writes the lock
Needs a lockfile?no (creates one if missing)yes — errors without one
Lock vs manifest mismatchupdates the lock to reconcileexits with an error
node_modulespatches in placedeletes it first, clean install
Install one package?yes (npm i lodash)no — whole project only
Use it forlocal dev, adding/removing depsCI, 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 sync

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

Quiz

CI is failing on a dependency you never changed. package.json still says the same ^ range. What is the most likely cause and fix?

Quiz

require('lodash') works locally but lodash is not in your package.json. What is going on?

Pick the best fit

A team wants reproducible installs in CI and reliable, debuggable upgrades. Which install policy should they adopt?

Recall before you leave
  1. 01
    Explain the difference between npm install and npm ci, and why CI should use npm ci.
  2. 02
    What's a phantom dependency, what causes it, and how do you prevent it?
Recap

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.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.