npm Dependencies Without Surprises
JavaScript & Node.js

npm Dependencies Without Surprises

9 October 20265 min read922 words
Tags#npm#semver#lockfile#dependencies

The build worked yesterday. Nothing changed on your side, and today it fails. Almost always the answer is a dependency that moved underneath you, and almost always it was allowed to because of how the project is set up. This post covers the mechanics: what the version ranges mean, what the lockfile is for, how to update on purpose, and how much attention an audit report deserves.

What the carets and tildes mean

Versions follow semantic versioning: major, minor, patch. The promise is that a patch fixes something, a minor adds something backwards-compatible, and a major may break you. The range in your package.json says how much drift you accept:

  • ^1.4.2 accepts any 1.x at or above 1.4.2. This is npm's default, and it means minor versions arrive without you asking.
  • ~1.4.2 accepts patch releases only, so 1.4.9 but not 1.5.0.
  • 1.4.2 is exact.

One exception catches people: for versions below 1.0.0, the caret behaves much more narrowly, because pre-1.0 packages are explicitly allowed to break in minor releases. A project full of ^0.x dependencies is relying on maintainers being careful rather than on a rule.

Ranges are a promise about intent, not a guarantee about behaviour. Plenty of breakage arrives in patch releases that were meant to be safe.

The lockfile is the real dependency list

package.json describes what you accept. package-lock.json records what you actually got, down to every transitive dependency and its integrity hash. That file is what makes an install reproducible, which is why it belongs in version control, including for libraries.

Two commands, two different jobs:

  • npm install may update the lockfile to satisfy the ranges. Use it when you are deliberately changing dependencies.
  • npm ci installs exactly what the lockfile says, fails if the lockfile and manifest disagree, and starts from a clean node_modules. Use it in CI and in any automated build.

A pipeline running npm install is a pipeline where a build can change without a commit. Switching it to npm ci removes an entire category of mystery failures, and is usually faster as well.

Updating on purpose

Dependencies that are never updated become an upgrade project later, and dependencies that update themselves cause the failure at the top of this article. The middle path is a scheduled, deliberate update.

npm outdated shows current, wanted and latest side by side. Take patches and minors as a batch, run the tests, commit. Take majors one at a time, read the changelog first, and give each its own commit so a revert is a single step. If you use an automated update bot, group the patch updates and let the majors arrive as individual pull requests.

Before every update round, make sure the test suite is actually meaningful. Dependency updates are exactly the change type that a shallow suite waves through.

Reading an audit report in proportion

npm audit matches your installed versions against a vulnerability database. It is useful, and it overstates urgency, because it does not know whether the vulnerable code path is one you use.

Three questions put a finding in proportion. Is the package a runtime dependency or a build-time one, since a vulnerability in a bundler plugin does not ship to users. Is the vulnerable function reachable from your code, or is it in a corner of the library you never call. And is the described attack relevant to how you run it, since a denial-of-service in a parser matters for a public API and barely registers in a local script.

Fix what is reachable and current. Be careful with npm audit fix --force: it is allowed to install major versions across your tree to satisfy the advisory, which is a much larger change than the report suggests.

Fewer dependencies is a feature

Every package is code you did not write, running with the same permissions as code you did. Before adding one, ask what it would take to do without it. Modern JavaScript and the Node standard library cover a lot of what small utility packages were written for, and the language has absorbed many of them.

When you do add a package, spend two minutes on it: when was the last release, how many open issues sit unanswered, how many dependencies does it drag in, and does it have a licence you can live with. A package with one dependency and a maintained changelog is a different proposition from one with forty.

Supply chain habits

  • Review lockfile changes. A pull request that touches one line of package.json and four hundred lines of lockfile deserves a look at what moved.
  • Be wary of install scripts. Packages can run code on install. npm ci --ignore-scripts is a reasonable default in CI for anything that does not genuinely need them.
  • Prefer packages with provenance. Registries increasingly publish attestations linking a package to the repository and build that produced it.
  • Watch for name confusion. Typosquatted packages rely on a missing letter or a plausible scope. Copy names from the documentation rather than typing them from memory.

A routine that holds

Commit the lockfile. Use npm ci everywhere automated. Set a recurring slot, monthly works for most teams, to take the patch and minor updates in one batch and schedule the majors. Run the audit in that same slot rather than reacting to every notification, and keep a note of the findings you consciously accepted and why.

That turns dependency management from a series of surprises into a small, boring routine, which is what you want from it.

Comments

No comments yet. Be the first to share your thoughts.

npm Dependencies: Semver, Lockfiles, Audits, Updates