# 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. 💥

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737027247393/8c7b8d71-0aaf-464c-a6e8-86db783517e6.gif align="center")

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](https://www.npmjs.com/package/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](https://stackoverflow.com/a/22004559/5257154), 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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737045792045/073440a8-a086-4c7e-abbf-352e095914a5.png align="center")

Here is the project structure:

```plaintext
packages/
├── common/
│   ├── src/
│   │   └── index.js
│   └── package.json
└── platform/
    ├── src/
    │   ├── App.js
    │   └── index.js
    └── package.json
package.json
```

In our **root** `package.json`, we have:

```json
{
  "name": "npm-monorepo-example",
  "scripts": {
    "start": "npm run start --workspace=@certa/platform"
  },
  "devDependencies": {
    "cypress": "^5.0.0"
  }
}
```

For `@certa/common`, we have:

```json
// packages/common/package.json
{
  "name": "@certa/common",
  "module": "src/index.js",
  "dependencies": {}
}
```

And the `index.js` file contains:

```javascript
// 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:

```json
// 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`:

```javascript
// 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`:

```javascript
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`.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737279558971/cb1ccf01-d21f-4850-98fd-dfe87c046b12.png align="center")

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` uses `date-fns@1.30.1`
    
* An import in `App.js` of `@certa/platform` would use `date-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:

```plaintext
npm start
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737050306944/9f65f1ba-3929-418d-b145-7ce3c34abee3.png align="center")

Our application breaks! 💥 But why? 😱

After removing cypress, the dependency structure changes:

```javascript
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`!

![](https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExbGgxY2xyZXF0djk3YndudXV1c2x6eGduemRzbm1qM2EzdTFkYmpqeCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/zrmTqopWm4W5cPg8Ah/giphy.gif align="center")

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*](https://github.com/PawanKolhe/npm-monorepo-example)*.*

# 🧐 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 strictness
    
* Installation speed
    
* Storage consumption
    
* Migration difficulty
    

There are already many comprehensive blog posts like [this](https://blog.logrocket.com/javascript-package-managers-compared/) and [this](https://nodesource.com/blog/nodejs-package-manager-comparative-guide-2024) 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](https://pnpm.io/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:

```json
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737105478871/657a393e-e20a-45d7-8d8b-a5e6cb22ac03.png align="center")

The `.pnpm` directory contains all installed dependencies, including multiple versions:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737105655171/39e95488-64d2-4381-a632-afe8836229bb.png align="center")

This structure guarantees strict access to only referenced dependencies. Learn more about it [here](https://pnpm.io/symlinked-node-modules-structure).

# 🎏 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:

1. **Increased bundle size:** The multiple versions will have to be bundled in your production build
    
2. **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.

```javascript
// 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);
};
```

```javascript
// 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737050306944/9f65f1ba-3929-418d-b145-7ce3c34abee3.png align="center")

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](https://www.npmjs.com/package/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`

```json
// packages/common/package.json
{
  "name": "@certa/common",
  "dependencies": {
    "react-intl": "^7.1.1"
  }
}
```

```javascript
// 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`

```json
// 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",
  }
}
```

```javascript
// 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737120892634/57e7c5a1-93f3-4361-87e9-80e57c18e4b6.png align="center")

Nice! 😊 Now we run our CI checks and deploy it.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737121125193/ab23154b-87ee-4ed7-aa3b-53311292e64a.png align="center")

`[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`:

```json
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*](https://github.com/PawanKolhe/npm-monorepo-example/tree/pnpm-multiple-instances-of-a-dependency)*.*

# 🏠 Centrally Managing Dependency Versions

pnpm offers a powerful feature called [“Catalogs“](https://pnpm.io/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.

```yaml
# 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.

```json
{
  "name": "@certa/platform",
  "dependencies": {
    "@certa/common": "workspace:*",
    "date-fns": "catalog:",
    "react": "catalog:",
    "react-dom": "catalog:",
    "react-scripts": "catalog:"
  }
}
```

```json
{
  "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*](https://github.com/PawanKolhe/npm-monorepo-example/tree/pnpm-catalogs)*.*

## 🚓 Enforcement

Centrally defining dependency versions is great, but how can you ensure that specific versions aren't set in individual packages? Enter [syncpack](https://github.com/JamieMason/syncpack), a handy tool to keep everything in check!

```bash
pnpm add -wD syncpack
```

Add the following script command to the root `package.json` file:

```json
{
  "scripts": {
    "syncpack": "syncpack list-mismatches"
  }
}
```

Here is how the `.syncpackrc` config file will look:

```json
{
  "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 dependencies
    
* Everything 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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737286250906/dccc344e-cd4d-4782-bb74-092b2b310127.png align="center")

Fixing the issue by moving the version to `pnpm-workspace.yaml`:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737286416223/29311c4f-07e8-474b-ad6c-a2d4dc51ad45.png align="center")

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**](https://www.getcerta.com/careers) and apply for the position that matches your skills and interests!
