Managing Dependencies
npm separates concerns by dependency type. Rather than dumping every package into a single bucket, package.json distinguishes between packages your application needs at runtime, tools you only need during development, and packages that the consumer of your library must supply themselves. Getting these categories right keeps production installs lean and avoids dependency conflicts downstream.
dependencies — runtime packages
Section titled “dependencies — runtime packages”dependencies lists packages that must be present for your application to run. When someone installs your package, npm installs these alongside it.
npm install expressThis adds express to the "dependencies" section of package.json:
{ "dependencies": { "express": "^4.18.2" }}devDependencies — development-only tools
Section titled “devDependencies — development-only tools”devDependencies contains packages used only while building, testing, or linting — they are never needed in production. npm skips them when NODE_ENV=production or when --omit=dev is passed.
npm install --save-dev eslint vitest typescript{ "devDependencies": { "eslint": "^8.57.0", "typescript": "^5.4.5", "vitest": "^1.6.0" }}peerDependencies — consumer-provided packages
Section titled “peerDependencies — consumer-provided packages”peerDependencies declares packages that the consuming project must install. This is the correct pattern for plugins, framework integrations, and shared libraries that should not be bundled separately in every package that uses them.
{ "name": "eslint-plugin-my-rules", "version": "1.0.0", "peerDependencies": { "eslint": ">=8.0.0" }}Since npm 7, npm install automatically installs a missing peer dependency when it can resolve a version that satisfies the range — no separate step or flag needed. npm only prints a peer dependency warning when it cannot resolve one automatically (for example, an already-installed version conflicts with the required range). The plugin itself never includes its own copy of eslint — it uses the one in the consumer’s project.
Lockfiles
Section titled “Lockfiles”package-lock.json records the exact resolved version and integrity hash of every package (and its transitive dependencies) that was installed. This guarantees that every developer and every CI run gets identical trees.
A minimal lockfile entry looks like:
{ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTg==" }}Always commit package-lock.json to version control. Without it, npm install may resolve to newer patch or minor versions, silently breaking reproducible builds.
Every lockfile entry also carries an integrity field — a base64-encoded hash of the exact tarball bytes npm resolved. This is what makes npm ci a security boundary, not just a version pin: a re-download that produces different bytes (a compromised registry, a corrupted transfer, a tampered mirror) fails the hash check instead of silently installing.
Output (verified on Node 24)
matches lockfile: true
tampered matches lockfile: falsenpm install vs npm ci
Section titled “npm install vs npm ci”These two commands serve different purposes:
# npm install — resolves and potentially updates the lockfilenpm install
# npm ci — installs exactly what is in the lockfile; fails if package.json# and package-lock.json are out of sync; always starts with a clean slatenpm cinpm install | npm ci | |
|---|---|---|
| Lockfile | May update | Must match — fails if out of sync |
| node_modules | Incremental | Deleted and rebuilt from scratch |
| Use case | Local development | CI/CD pipelines, Docker builds |
| Speed | Slower (resolves) | Faster (reads lockfile directly) |
npm ci’s “fails loudly” claim is not hypothetical — this is a real transcript. A project installs a lockfile, then package.json drifts (a dependency added by hand without re-running npm install):
npm cinpm error code EUSAGEnpm errornpm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.npm errornpm error Missing: [email protected] from lock filenpm error Missing: [email protected] from lock fileIt does not attempt to reconcile or partially install — it refuses outright. This is exactly the failure mode that should catch a stale lockfile in CI before a bad dependency tree ever reaches production.
The Dockerfile anti-pattern
Section titled “The Dockerfile anti-pattern”The single most common place this distinction is gotten wrong is a Dockerfile. Using npm install in an image build defeats the entire point of a lockfile — every image build can resolve slightly different transitive versions, even from an identical package.json:
# Anti-pattern: npm install in a Docker imageFROM node:24-slimWORKDIR /appCOPY package.json package-lock.json ./RUN npm install --omit=devCOPY . .CMD ["node", "server.js"]# Correct: npm ci pins the exact tree the lockfile recordedFROM node:24-slimWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY . .CMD ["node", "server.js"]npm ci --omit=dev also deletes node_modules before installing, so a stale layer from a previous build can never leak dev-only packages (or worse, a different version of a real dependency) into the production image. npm install in a Dockerfile is a genuine bug, not a style preference: two builds from the identical source tree, days apart, can produce different, unreproducible images.
overrides
Section titled “overrides”overrides forces a specific version for a dependency anywhere it appears in the tree, including nested inside a dependency you do not control — the tool for a transitive package pinned to a vulnerable exact version that its own maintainer has not bumped.
{ "overrides": { "minimatch": "^3.1.2" }}Real before/after, captured from a project whose only direct dependency is a package that itself pins an old, vulnerable minimatch exactly:
npm ls minimatch[email protected] /path/to/overrides-demo└─┬ [email protected] -> ./outer{ "overrides": { "minimatch": "^3.1.2" }}npm install && npm ls minimatch[email protected] /path/to/overrides-demo└─┬ [email protected] -> ./outerThe transitive minimatch moved from 3.0.4 to 3.1.5 without touching outer-lib at all. overrides cannot force a version that violates the dependency’s own peer requirements — npm still checks compatibility — but it is the correct tool for exactly this situation, where waiting for every maintainer up the chain to publish a bump is not an option.
npm audit
Section titled “npm audit”npm audit checks installed dependencies against the npm security advisory database and reports known vulnerabilities, with severity and a suggested fix. Real output on a project deliberately pinned to an old minimatch:
npm audit# npm audit report
minimatch <=3.1.3Severity: highminimatch ReDoS vulnerability - https://github.com/advisories/GHSA-f8q6-p94x-37v3minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern - https://github.com/advisories/GHSA-3ppc-4f35-3m26minimatch has ReDoS: matchOne() combinatorial backtracking via multiple non-adjacent GLOBSTAR segments - https://github.com/advisories/GHSA-7r86-cg39-jmmjminimatch ReDoS: nested *() extglobs generate catastrophically backtracking regular expressions - https://github.com/advisories/GHSA-23c5-xmqv-rm74fix available via `npm audit fix --force`Will install [email protected], which is outside the stated dependency rangenode_modules/minimatch
1 high severity vulnerabilitynpm audit fix applies fixes that stay within existing semver ranges; npm audit fix --force will install versions outside the declared range (as flagged above) to close the gap, which can be a breaking change — always review before running it in CI unattended.
Workspaces
Section titled “Workspaces”Workspaces let one root package.json manage multiple packages in a single repository (a monorepo), symlinking cross-package dependencies into each package’s node_modules instead of publishing and reinstalling them on every change.
{ "name": "my-monorepo", "private": true, "workspaces": ["packages/*"]}flowchart TD Root["root package.json (workspaces: packages/*)"] --> Core["packages/core (@ws-demo/core)"] Root --> Cli["packages/cli (@ws-demo/cli)"] Cli -->|"depends on ws-demo/core (workspace range: *)"| CoreLink["node_modules/@ws-demo/core -> symlink to packages/core"] CoreLink -.->|"resolves to"| Core
Real output from a two-package workspace (packages/core, packages/cli, where cli depends on core):
npm ls --allws-demo@ /path/to/ws-demo├─┬ @ws-demo/[email protected] -> ./packages/cli│ └── @ws-demo/[email protected] deduped -> ./packages/core└── @ws-demo/[email protected] -> ./packages/core-w/--workspace scopes a command to one package without cd-ing into it:
npm -w @ws-demo/core exec -- node -e "console.log(require('./index.js').add(2,3))"5No tarball, no publish step, no version bump — @ws-demo/cli sees whatever is currently on disk in packages/core through the symlink, which is exactly what makes workspaces fast for iterating on multiple packages together.
Mental Model
Section titled “Mental Model”Every dependency-management command in this lesson answers one question: “what exact bytes am I about to run, and can I trust where they came from?” A lockfile answers it for one install (integrity hashes). npm ci answers it for a whole environment (fail if the lockfile and manifest disagree, wipe node_modules first). overrides answers it when a transitive dependency you don’t control ships something you need to force anyway. npm audit answers it against a public vulnerability database. None of these are about “making dependencies work” — they are all about making sure what actually got installed matches what you believe was installed.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
npm ci in CI/Docker | Reproducible, fast (no resolution step), fails loudly on drift | Cannot be used to add or update a dependency — that still requires npm install locally first |
overrides for a transitive pin | Fixes a vulnerable/broken nested dependency without forking or waiting on a maintainer | Bypasses the maintainer’s own tested version — can introduce incompatibility the override doesn’t know about |
npm audit fix --force | Closes vulnerabilities npm’s default fix cannot reach | Installs out-of-range versions automatically — a real behavior change, not just a patch |
| Workspaces | Fast local iteration across packages, no publish-and-reinstall loop | Adds monorepo tooling complexity; a workspace-only bug can be invisible until the packages are actually published separately |
Common Misconceptions
Section titled “Common Misconceptions”- “
npm ciis just a fasternpm install.” It is a different contract: it refuses to run at all ifpackage.jsonand the lockfile disagree, and it always deletesnode_modulesfirst. Speed is a side effect, not the point. - “
overridesonly affects your own direct dependencies.” It reaches into the entire tree, including packages nested many levels deep that you never listed in your ownpackage.json. - “
npm audit fixandnpm audit fix --forcedo the same thing, just with more force.” The plain version only applies fixes within your existing semver ranges.--forcewill jump to a version outside your declared range — a real upgrade, with real potential for breaking changes.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated Dockerfiles reach for npm install far more often than npm ci, because npm install is the command that appears in almost every tutorial and README — npm ci is CI-specific knowledge that shows up far less in generic training data.
🐛 Spot the bug
FROM node:24-slimWORKDIR /appCOPY package.json package-lock.json ./RUN npm installCOPY . .CMD ["node", "server.js"]Show the answer
npm install inside a Docker image build can resolve a different dependency tree than the one recorded in package-lock.json, even from the identical package.json — the lockfile is present but never enforced. Two builds of the same commit, days apart, can silently ship different transitive versions. The fix is RUN npm ci --omit=dev: it installs exactly what the lockfile specifies, fails the build if the lockfile is out of sync, and skips devDependencies that have no place in a production image.