Guide · Sep 1, 2026

Same Version, Different Range: How Monorepo Dependencies Drift Apart

Three workspaces in one repo. All three depend on TypeScript. All three are running 5.3.0 right now. And their manifests say:

apps/web     "typescript": "^5.3.0"
apps/admin   "typescript": "~5.3.0"
apps/api     "typescript": "5.3.0"

Nothing is broken. Every check passes, the installed version is identical everywhere, and a diff of any two of these files reports the same version number on both sides. That's the whole problem: this isn't a disagreement about what's installed today, it's a disagreement about what's allowed to happen next — and that kind of disagreement doesn't show up as a failure until it already has.

What each of those actually permits

The three lines authorize very different futures. ^5.3.0 accepts any 5.x release from 5.3.0 up, so 5.7.2 satisfies it. ~5.3.0 accepts patches only — 5.3.9 yes, 5.4.0 no. 5.3.0 accepts exactly 5.3.0 and nothing else.

Since all three currently resolve to the same build, the divergence is invisible while it's cheap to fix. It becomes visible later, when the versions have already separated and you're debugging the symptom rather than the cause.

Why they separate

The trigger is that installs don't all happen at the same moment. A lockfile records what was resolved when it was written; a range is re-resolved whenever something forces a fresh resolution. So the drift arrives through ordinary events:

Someone adds a package to apps/web and the resolver refreshes that part of the tree, pulling ^5.3.0 up to the newest 5.x. apps/admin stays on 5.3.x because its range won't go further. apps/api stays exactly where it is. Or a new contributor installs without a lockfile, or CI runs a clean install against a stale lockfile, or a dependency bot bumps one workspace and opens a PR that looks entirely reasonable in isolation.

None of these is a mistake. Each one respects the ranges as written. The ranges were just written to mean different things.

What breaks, in rough order of how confusing it is

Two copies in one bundle. If two workspaces end up on incompatible versions of the same runtime library and both get bundled, you ship both. For most utility libraries this costs bytes and nothing else. For anything holding module-level state — a React with its hook dispatcher, a client with a connection pool, anything using instance checks — two copies is a genuine bug, and the error message rarely mentions versions at all. You get a hook call that's suddenly invalid, or an object that fails an instanceof check against a class that looks identical.

Tooling that disagrees with itself. A TypeScript minor bump can add checks. If apps/web is on 5.7 and apps/api is on 5.3, the same shared code can compile in one workspace and fail in another, and the CI job that fails is the one nobody touched. The same applies to ESLint, Prettier reformatting files differently depending on which workspace ran, and bundler plugins that expect a matching core version.

Peer dependency warnings that are technically correct. A shared internal package declaring peerDependencies: { "react": "^18.0.0" } starts warning in whichever workspace drifted, and because peer warnings are frequently noise, the one that matters gets scrolled past.

Why review doesn't catch it

Two structural reasons, and they compound.

The first is that a range change is a one-character diff in a file people skim. Changing ~5.3.0 to ^5.3.0 reads as a formatting tweak, and in the PR that introduced it, it probably was — someone ran an install that rewrote the manifest, or copied a line from another workspace.

The second is that these problems are properties of the set, not of any pair. If web and admin both use ^ and only api pins exactly, then comparing web against admin comes back clean and tells you nothing. You'd have to remember to run the third comparison. With five workspaces there are ten pairs, with ten there are forty-five, and a diff tool — which by definition has a left side and a right side — can only ever show you one of them at a time. The odd workspace out is invisible until you happen to pick the pair that contains it.

Picking a policy instead of accumulating one

The fix isn't making everything exact or everything caret — it's deciding once, on purpose, and then having the repo reflect that decision.

The usual split: applications pin exact versions, because you want the thing you tested to be the thing you deploy, and a lockfile plus a dependency bot already gives you controlled upgrades. Published libraries use ranges, because a library that pins exactly forces duplicate installs on everyone who consumes it. If you publish from your monorepo, that means the two kinds of workspace genuinely should differ — which is fine, as long as it's the rule rather than an accident.

Shared tooling deserves its own rule regardless: TypeScript, ESLint, and the build toolchain should be identical across every workspace, exact, because the entire point is that all workspaces are checked the same way. A repo where each workspace independently drifts to its own TypeScript has given up the main benefit of being one repo.

If your package manager supports catalogs — pnpm and bun both do — they remove the class of problem outright by letting workspaces reference a version defined once at the root, so there's no per-manifest range to drift.

Checking where you currently stand

Enforcement belongs in CI, and syncpack and manypkg are the established tools for it — they read every manifest in a workspace and fail the build on mismatches. If this is a repo you own and this problem recurs, install one of them; a check that runs on every PR beats a check someone remembers to run.

For the audit before that decision — or for the cases those tools aren't shaped for, like comparing manifests from separate repos, a paste from a teammate, or your project against a starter template — FreeToolDev's bulk package.json version checker takes as many manifests as you paste and reports every shared dependency where they disagree, separated by whether it's a major version difference, a version difference within one major, a range-operator difference like the one above, or a package that moved between dependencies and devDependencies. It runs in the browser with nothing uploaded, which matters for a file that names your private registry and internal packages.