...
Back

Pinning a Transitive Dependency Is Not Paranoia

A package you never installed, at a version you never chose, broke a page you never edited. The lockfile was supposed to prevent this — here is why it didn't.

Pinning a Transitive Dependency Is Not Paranoia

Pinning a Transitive Dependency Is Not Paranoia 📌

The bug looked impossible. A visual effect on the homepage started throwing a version-check error at runtime. We had not touched that code, that package, or its version in the manifest. The error came from a plugin package refusing to run against a core package it considered mismatched.

Both were transitive. We had asked for one library; it had pulled in a family of sibling packages that check each other's versions at runtime, and a fresh install had resolved two of those siblings to versions that disagreed.


Why the lockfile didn't save us

Lockfiles are excellent and they have exactly two holes, both of which we walked into.

A lockfile only binds where it is used. If any environment installs without it — a container build that copies the manifest but not the lock, a CI step using a different install command, a tool that regenerates rather than respects it — that environment resolves fresh. Our local machines were pinned. Our build was not, in the way that mattered.

A lockfile records a resolution; it does not record your intent. When anything causes a re-resolve — adding a package, a major upgrade, a registry hiccup — the resolver is free to pick any version satisfying the declared ranges. Those ranges were declared by your dependency's author, for their own compatibility needs, not for the runtime invariant their plugin system happens to enforce.

So the failure needs no villain. Package A declares ^3.0.0 for its core. Plugin B declares ^3.0.0 too. One resolves to 3.0.0 and the other to 3.1.x, the plugin's runtime check compares them, and a page that rendered yesterday throws today.


Runtime version checks make this a crash instead of a wobble

The reason this class of bug is sharp rather than subtle is a specific pattern: plugin ecosystems that verify version agreement at runtime. Animation libraries, chart libraries, editor frameworks, bundler plugin families — anything where a core and its plugins share internal contracts.

The check is defensible. Its authors are preventing a much worse failure, where mismatched internals corrupt state silently. But it converts a normally-benign version skew into a hard error in production, and it does so in code you did not write, about packages you did not name.


The fix, in increasing order of force

Overrides. Every major package manager has a way to force a transitive dependency to an exact version across the tree — overrides in npm and bun, resolutions in yarn, pnpm.overrides in pnpm. When a sibling family must agree, pin the whole family:

{
  "overrides": {
    "@example/core": "3.0.0",
    "@example/plugin-a": "3.0.0",
    "@example/plugin-b": "3.0.0"
  }
}

Pin every member, not just the one that threw. Pinning one member of a mutually-checking family just moves which pair disagrees.

Install with the lockfile, everywhere. In containers, copy the lockfile in the same layer as the manifest and use the frozen-install flag. An install that is allowed to re-resolve in CI has silently opted out of the guarantee you thought you had.

Comment the pin. A version pin with no explanation is indistinguishable from an accident, and the next person to do dependency cleanup will remove it. One line — what broke, what error, roughly when — is the difference between a decision and a mystery.


When to unpin

Pins are debt. They freeze you against security patches and they make the next major upgrade harder. The discipline is to treat each pin as having an exit condition, written down at the moment you add it: unpin when the upstream compatibility issue is resolved, or when we next upgrade the parent library.

The general principle is worth stating plainly, because it is easy to get backwards. Your dependency's version ranges express its compatibility needs. They do not express the invariants your application requires. When those diverge — and the runtime-checking plugin family is exactly where they diverge — the pin is not paranoia. It is you writing down a requirement that nobody else had any way to know about.