Dependency Management Nightmares: Our Journey from Yarn to pnpm

Pawan KolhePawan Kolhe
12 min read

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

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 strictness

  • Installation 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:

  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.

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

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!

0
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