Dependency Management Nightmares: Our Journey from Yarn to pnpm


Ever wondered what lurks in the depths of your node_modules folder? Adding or updating a dependency in your monorepo can create complex, hard-to-debug problems when not set up properly. It's like a hidden landmine, waiting to explode at any moment. π₯
In this post, I'll dive deep into the challenges of dependency management in monorepos and share powerful solutions we discovered along the way. You'll learn about:
Common dependency management pitfalls we encountered and how to troubleshoot them
Real-world bugs that drove our decision to change package managers
How to implement centralized dependency version control across all packages
Techniques to safeguard against dependency conflicts before they occur
While our journey ultimately led us from Yarn v1 (Yarn Classic) to pnpm, the insights apply to any monorepo setup regardless of your current package manager.
Note: While these issues are specific to monorepo setups, the lessons are valuable even if you're working with a traditional repository structure.
π» The Phantom Dependency Problem
TLDR; Phantom dependencies are the ghosts haunting your monorepoβmodules your code secretly uses without declaring, causing everything to work fine today but mysteriously break tomorrow.
A Real-World Example
What seemed like a simple taskβremoving an unused dev dependencyβturned into an unexpected nightmare that exposed a fundamental flaw in our dependency management strategy.
I was tasked with removing cypress from our monorepo because we no longer used it for E2E tests. It seemed simpleβjust delete some code and take cypress out of the package.json file. As a dev dependency, it was only used for tooling and wasn't bundled with our application, so I assumed it wouldn't even require QA testing. But, oh boy, was I wrong!
π οΈ The Setup: A Simple Monorepo
Let's examine a typical npm monorepo with two internal packages:
@certa/platform
: The entry point for our React application@certa/common
: A utility package with commonly used functions
Our application simply displays the current date in a specific format:
Here is the project structure:
packages/
βββ common/
β βββ src/
β β βββ index.js
β βββ package.json
βββ platform/
βββ src/
β βββ App.js
β βββ index.js
βββ package.json
package.json
In our root package.json
, we have:
{
"name": "npm-monorepo-example",
"scripts": {
"start": "npm run start --workspace=@certa/platform"
},
"devDependencies": {
"cypress": "^5.0.0"
}
}
For @certa/common
, we have:
// packages/common/package.json
{
"name": "@certa/common",
"module": "src/index.js",
"dependencies": {}
}
And the index.js
file contains:
// packages/common/src/index.js
import { format } from "date-fns";
// date-fns v1 format
export const DATE_FORMAT = "DD/MM/YYYY";
export const formatDate = (date) => {
return format(date, DATE_FORMAT);
};
Hold on! Did you notice that date-fns
isn't defined in @certa/common
's package.json
or the workspace root? Yet, the code still works! This is a phantom dependencyβan import that shouldn't work but does. We'll explain why shortly.
For @certa/platform
, we have:
// packages/platform/package.json
{
"name": "@certa/platform",
"scripts": {
"start": "react-scripts start"
},
"dependencies": {
"@certa/common": "^1.0.0",
"date-fns": "^2.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1"
}
}
And here's App.js
:
// packages/platform/src/App.js
import { formatDate } from "@certa/common";
export default function App() {
return <h1>Print date: {formatDate(new Date())}</h1>;
}
After running npm install
, here's how date-fns
is structured in node_modules
:
node_modules/
βββ date-fns@1.30.1
packages/
βββ common/
β βββ ... date-fns@1.30.1 (inherited)
βββ platform/
βββ node_modules/
βββ date-fns@2.30.0
π΅οΈ The Mystery: Where Did date-fns@1.30.1
Come From?
If you peek into the root node_modules
folder, you'll find many dependencies not explicitly listed in your workspace. This happens because npm flattens the node_modules
structure, allowing access to all packages regardless of what's in your package.json
.
In our case, date-fns@1.30.1
is a transitive dependency of cypress
. Specifically, cypress
depends on @cypress/listr-verbose-renderer
, which depends on date-fns@1.30.1
.
A transitive dependency is a library that your code indirectly relies on because one of your direct dependencies requires it to function properly.
π€¨ The Common Misconception About Dependencies
Many developers assume that if a dependency like date-fns
is added to the application entry point (@certa/platform
), the same version will be used throughout the application. This is false.
In reality:
The
import { format } from "date-fns"
in@certa/common
usesdate-fns@1.30.1
An import in
App.js
of@certa/platform
would usedate-fns@2.30.0
This happens because Node.js resolves dependencies from the nearest node_modules
folder.
𧨠The Breaking Change: Removing a Dev Dependency
Let's remove "cypress": "^5.0.0"
from the root package.json
and run our application:
npm start
Our application breaks! π₯ But why? π±
After removing cypress, the dependency structure changes:
node_modules/
βββ date-fns@2.30.0
packages/
βββ common/
β βββ ... date-fns@2.30.0 (inherited)
βββ platform/
βββ node_modules/
βββ date-fns@2.30.0
The version of date-fns
in @certa/common
changed from v1.30.1
to v2.30.0
!
This inconsistency stems from npm and Yarn Classic's behavior of hoisting dependencies to the root. While this optimization saves disk space, it creates unpredictable dependency resolution.
The source code for this example can be found here.
π§ Finding a Stricter Package Manager
This dependency version inconsistency was a real eye-opener, and it made us determined to prevent it from happening again. We wanted tighter control over dependencies, ensuring that only the packages explicitly listed in package.json
were available in our monorepo.
Since npm and Yarn Classic were designed with different principles, they don't offer a solution to this problem. Thus began our quest for an alternative package manager.
We compared the two main alternatives: Yarn Berry (v2+) and pnpm. Both have reached feature parity, so our decision came down to:
How the
node_modules
folder ensures dependency strictnessInstallation speed
Storage consumption
Migration difficulty
There are already many comprehensive blog posts like this and this comparing the two in detail, so Iβll not dive into the details.
We chose pnpm for these reasons:
Strict dependency access by default - exactly what we needed
Superior performance - we measured a 70% installation speed boost over Yarn Classic check the benchmarks in our monorepo
Compatibility with existing tooling - unlike Yarn Berry's radical approach of eliminating
node_modules
entirely, pnpm works with existing Node.js tools out-of-the-box
π How pnpm Ensures Strict Dependency Access
pnpm took inspiration from npm v2's nested structure but implemented it using symbolic links:
node_modules
βββ .pnpm
βββ bar@1.0.0
β βββ node_modules
β βββ bar -> <store>/bar
β βββ index.js
β βββ package.json
βββ foo@1.0.0
βββ node_modules
βββ foo -> <store>/foo
βββ index.js
βββ package.json
The node_modules/.pnpm
directory itself (where dependencies are actually stored) is a flat structure.
Only packages explicitly listed in your package.json (just cypress
in our example below) are visible in the top-level node_modules folder. Indirect dependencies remain hidden:
The .pnpm
directory contains all installed dependencies, including multiple versions:
This structure guarantees strict access to only referenced dependencies. Learn more about it here.
π The Dangers of Multiple Dependency Versions
Imagine you need to upgrade a library to a newer version.
When upgrading libraries in a large monorepo (which often contains 10+ packages), teams frequently opt to update one package at a time. This incremental approach is more manageable but can lead to using multiple versions of the same dependency in a single application.
So, can we use multiple versions of a dependency in a single bundled application?
Simple answer is: Yes you can, but it comes at a cost. The cost is:
Increased bundle size: The multiple versions will have to be bundled in your production build
Risk of incompatibility: Different versions of a dependency may have breaking API changes and could lead to unexpected behavior or errors in your application
The increased bundle size is obvious, but incompatibility issue is not, so letβs explore two specific issues we encountered.
π§© Incompatible APIs Between Versions
It's not always clear when different versions of date-fns
are being used across various internal packages, especially when these packages are managed by different teams. Library APIs often change from one version to another, which can lead to problems.
Let's dive into an example to illustrate how these API changes can cause issues. We'll modify the App.js
example above to show this in action.
In @certa/common
, we have defined DATE_FORMAT
constant to store the data format string. We'll use this constant in the format
function imported from @certa/platform
to display the date in a different locale.
// packages/common/src/index.js
// data-fns v1
import { format } from "date-fns";
// date-fns v1 format
export const DATE_FORMAT = "DD/MM/YYYY";
export const formatDate = (date) => {
return format(date, DATE_FORMAT);
};
// packages/platform/src/App.js
import { formatDate, DATE_FORMAT } from "@certa/common";
// data-fns v2
import { format } from "date-fns";
import { de } from "date-fns/locale";
export default function App() {
return (
<div>
<h1>Print date: {formatDate(new Date())}</h1>
<h1>Print date: {format(new Date(), DATE_FORMAT, { locale: de })}</h1>
</div>
);
}
Letβs run the app.
Looks like thereβs an error. β Turns out the format syntax changed from date-fns
v1 to v2 . The format()
API expects v2 format (dd/MM/yyyy
) but was given v1 format (DD/MM/YYYY
).
It's not immediately obvious when different versions of date-fns
are used across various internal packages, especially if those packages are managed by different teams.
Errors like these are quite likely to happen. At Certa, we've learned valuable lessons from such experiences. Now, we make sure not to use multiple versions of a dependency unless it's absolutely necessary.
βοΈ Multiple Dependency Instances Problem
Another serious issue occurs with libraries that use React Context. Here's a real example we faced when upgrading react-intl
from v5 to v7:
In our application, we use react-intl for internationalization across our platform. We planned to upgrade from react-intl@5.8.0
to react-intl@7.1.1
.
Imagine a monorepo with just two packages. What happens if you use different versions of a dependency in each package? This might occur if you're trying to upgrade gradually, focusing on one package at a time. Let's explore the impact of this approach.
After updating only @certa/common
to react-intl@7.1.1
, here's how the code for both packages would look.
@certa/common
// packages/common/package.json
{
"name": "@certa/common",
"dependencies": {
"react-intl": "^7.1.1"
}
}
// packages/common/src/Welcome.jsx
import { useIntl } from "react-intl";
export const Welcome = () => {
const intl = useIntl();
return (
<h1>
{intl.formatMessage({
id: "myMessage",
defaultMessage: "Hello world"
})}
</h1>
);
};
@certa/platform
// packages/platform/package.json
{
"name": "@certa/platform",
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@certa/common": "workspace:*",
"date-fns": "^2.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-intl": "^5.8.0"
},
"devDependencies": {
"vite": "catalog:",
"@vitejs/plugin-react": "^4.3.4",
}
}
// packages/platform/src/App.js
import { IntlProvider } from "react-intl";
import { Welcome } from "@certa/common/src/Welcome.jsx";
// Translated messages in French with matching IDs to what you declared
const messages = {
myMessage: "Hello world, welcome to the Certa platform!"
};
export default function App() {
return (
<IntlProvider messages={messages} locale="fr" defaultLocale="en">
<Welcome />
</IntlProvider>
);
}
Weβre using the <IntlProvider>
in @certa/platform
and importing a component from @certa/common
that consumes that context.
After running pnpm start
, we see that the app is running fine.
Nice! π Now we run our CI checks and deploy it.
[React Intl] Could not find required intl object. needs to exist in the component ancestry.
Users immediately start reporting errors in our application. β π±
Why did it work in development but fail in production? In our case, we were using Vite, which handles dependencies differently between development and production. In development mode, Vite uses a looser module resolution strategy that sometimes masks dependency conflicts. However, in production, the optimization process creates a more strictly isolated bundle that exposes these incompatibilities.
The main issue: pnpm installs both versions of react-intl
:
node_modules/
βββ .pnpm/
βββ react-intl@5.25.1_react@18.3.1_typescript@4.9.5
βββ react-intl@7.1.1_react@18.3.1
packages/
βββ platform/
β βββ react-intl@^5.8.0 (symlink -> react-intl@5.25.1_react@18.3.1_typescript@4.9.5)
βββ common/
βββ react-intl@^7.1.1 (symlink -> react-intl@7.1.1_react@18.3.1)
Notice that there are two separate copies of react-intl
installed. This means the <IntlProvider>
context that useIntl()
in @certa/common
tries to access is different from the one wrapping our application in @certa/platform
. A workaround could be to re-export the useIntl
hook from @certa/platform
and use it across other packages in the monorepo.
This isn't something a developer would easily spot, so it's wise to avoid using multiple versions of the same dependency whenever possible.
The source code for this example is here.
π Centrally Managing Dependency Versions
pnpm offers a powerful feature called βCatalogsβ that allows you to define dependency versions centrally in the pnpm-workspace.yaml
file. This ensures consistency across all packages in your monorepo.
Setting it up is straightforwardβjust run pnpx codemod pnpm/catalog
to automate the process.
Dependency versions are defined in the pnpm-workspace.yaml
file at the root of the workspace.
# pnpm-workspace.yaml
packages:
- packages/*
catalog:
# Can be referenced through "catalog"
cypress: ^5.0.0
date-fns: ^2.30.0
react: ^18.2.0
react-dom: ^18.2.0
react-scripts: ^5.0.1
catalogs:
# Can be referenced through "catalog:old"
old:
date-fns: ^1.30.1
Now in your package.json
files, you can reference these centralized versions.
When adding a dependency, we can use the catalog:
protocol instead of specifying the version range directly.
{
"name": "@certa/platform",
"dependencies": {
"@certa/common": "workspace:*",
"date-fns": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-scripts": "catalog:"
}
}
{
"name": "@certa/common",
"dependencies": {
"date-fns": "catalog:old"
}
}
π Benefits of Catalogs
Faster dependency additions: Simply reference the catalog: protocol for existing dependencies
Consistent versioning: Eliminate accidental version mismatches from typos
Easier upgrades: Update one line in pnpm-workspace.yaml instead of modifying multiple package.json files
The source code for this example is here.
π Enforcement
Centrally defining dependency versions is great, but how can you ensure that specific versions aren't set in individual packages? Enter syncpack, a handy tool to keep everything in check!
pnpm add -wD syncpack
Add the following script command to the root package.json
file:
{
"scripts": {
"syncpack": "syncpack list-mismatches"
}
}
Here is how the .syncpackrc
config file will look:
{
"versionGroups": [
{
"label": "Use workspace protocol when developing local packages",
"dependencies": [
"$LOCAL"
],
"dependencyTypes": [
"prod",
"dev"
],
"pinVersion": "workspace:*"
},
{
"label": "Use catalog:old protocol for all dependencies",
"packages": [
"@certa/common"
],
"dependencies": [
"react-intl"
],
"dependencyTypes": [
"prod",
"dev"
],
"pinVersion": "catalog:old"
},
{
"label": "Use catalog protocol for all dependencies",
"dependencies": [
"**"
],
"dependencyTypes": [
"prod",
"dev"
],
"pinVersion": "catalog:"
}
]
}
This setup ensures that:
Internal packages use
workspace:*
Specific packages use
catalog:old
for designated dependenciesEverything else uses
catalog:
This will ensure that your monorepo is following a single version policy. You can customize the configuration to allow different versions of a dependency if needed.
Running pnpm syncpack
will flag any violations:
Fixing the issue by moving the version to pnpm-workspace.yaml
:
Run this check in your CI pipeline to maintain dependency consistency across your project.
Conclusion
Dependency management issues like phantom dependencies and version conflicts are common headaches in monorepo setups. By understanding these problems and implementing the solutions outlined in this post, you can take the lead in your organization and become the hero π¦Έ who saves your application from dependency hell.
The key takeaways:
Be aware of phantom dependencies and their risks
Consider pnpm for strict dependency management
Avoid multiple versions of the same dependency when possible
Use centralized version management with Catalogs
Enforce dependency standards with tools like syncpack
If you found this post helpful, please share it with your network! π»
We're hiring!
If solving challenging problems at scale in a fully-remote team interests you, check out our careers page and apply for the position that matches your skills and interests!
Subscribe to my newsletter
Read articles from Pawan Kolhe directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Pawan Kolhe
Pawan Kolhe
Frontend Engineer