Skip to content

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 lists packages that must be present for your application to run. When someone installs your package, npm installs these alongside it.

Terminal window
npm install express

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

Terminal window
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.

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.

Node.js
Output (verified on Node 24)
matches lockfile: true
tampered matches lockfile: false

These two commands serve different purposes:

Terminal window
# npm install — resolves and potentially updates the lockfile
npm 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 slate
npm ci
npm installnpm ci
LockfileMay updateMust match — fails if out of sync
node_modulesIncrementalDeleted and rebuilt from scratch
Use caseLocal developmentCI/CD pipelines, Docker builds
SpeedSlower (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):

Terminal window
npm ci
npm error code EUSAGE
npm error
npm 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 error
npm error Missing: [email protected] from lock file
npm error Missing: [email protected] from lock file

It 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 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 image
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY . .
CMD ["node", "server.js"]
# Correct: npm ci pins the exact tree the lockfile recorded
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
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 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:

Terminal window
npm ls minimatch
[email protected] /path/to/overrides-demo
└─┬ [email protected] -> ./outer
{
"overrides": { "minimatch": "^3.1.2" }
}
Terminal window
npm install && npm ls minimatch
[email protected] /path/to/overrides-demo
└─┬ [email protected] -> ./outer

The 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 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:

Terminal window
npm audit
# npm audit report
minimatch <=3.1.3
Severity: high
minimatch ReDoS vulnerability - https://github.com/advisories/GHSA-f8q6-p94x-37v3
minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern - https://github.com/advisories/GHSA-3ppc-4f35-3m26
minimatch has ReDoS: matchOne() combinatorial backtracking via multiple non-adjacent GLOBSTAR segments - https://github.com/advisories/GHSA-7r86-cg39-jmmj
minimatch ReDoS: nested *() extglobs generate catastrophically backtracking regular expressions - https://github.com/advisories/GHSA-23c5-xmqv-rm74
fix available via `npm audit fix --force`
Will install [email protected], which is outside the stated dependency range
node_modules/minimatch
1 high severity vulnerability

npm 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 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
A two-package workspace, symlinked instead of published

Real output from a two-package workspace (packages/core, packages/cli, where cli depends on core):

Terminal window
npm ls --all
ws-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:

Terminal window
npm -w @ws-demo/core exec -- node -e "console.log(require('./index.js').add(2,3))"
5

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

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.

ChoiceBenefitCost
npm ci in CI/DockerReproducible, fast (no resolution step), fails loudly on driftCannot be used to add or update a dependency — that still requires npm install locally first
overrides for a transitive pinFixes a vulnerable/broken nested dependency without forking or waiting on a maintainerBypasses the maintainer’s own tested version — can introduce incompatibility the override doesn’t know about
npm audit fix --forceCloses vulnerabilities npm’s default fix cannot reachInstalls out-of-range versions automatically — a real behavior change, not just a patch
WorkspacesFast local iteration across packages, no publish-and-reinstall loopAdds monorepo tooling complexity; a workspace-only bug can be invisible until the packages are actually published separately
  • “npm ci is just a faster npm install.” It is a different contract: it refuses to run at all if package.json and the lockfile disagree, and it always deletes node_modules first. Speed is a side effect, not the point.
  • “overrides only affects your own direct dependencies.” It reaches into the entire tree, including packages nested many levels deep that you never listed in your own package.json.
  • “npm audit fix and npm audit fix --force do the same thing, just with more force.” The plain version only applies fixes within your existing semver ranges. --force will jump to a version outside your declared range — a real upgrade, with real potential for breaking changes.

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-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
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.

Which section of package.json should a test framework like Vitest be listed under?
What is the main purpose of peerDependencies?
What happens when you run npm ci and package.json is out of sync with package-lock.json?
What is wrong with this Dockerfile, given that package-lock.json is already being copied in?
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
A project depends on outer-lib, which pins an old, vulnerable [email protected] exactly (no range). Which package.json field lets you force minimatch to a patched version everywhere in the tree, without forking or waiting on outer-lib's maintainer?