Why you shouldn't use npm workspaces

Edrick LeongEdrick Leong
Jul 11, 2024·
8 min read

When setting up a project, npm is usually the package manager most people use because it is the default. You can also use npm to set up a monorepo by using npm workspaces. However, I do not recommend using npm workspaces for managing multiple packages in a monorepo. In this article, I will explain why you shouldn't use npm workspaces and why you should use pnpm workspaces instead.

How does npm workspaces work?

Let's first explore how npm workspaces work.

You can create a npm workspace by specify a workspaces field in your package.json. This field should be an array of globs that point to the directories of your workspaces. For example, specifying packages/* will include all the directories in the packages directory as packages.

// package.json
{
  "name": "root",
  "workspaces": ["packages/*"]
}

You can then create individual packages in the workspace inside the packages directory. For example, to create a utils, web and docs package. The utils package is used in the web and docs package.

// packages/utils/package.json
{
  "name": "utils",
  "type": "module" // So we can use ES module syntax (ie. import / export)
}
// packages/web/package.json
{
  "name": "web",
  "type": "module"
}
// packages/docs/package.json
{
  "name": "docs",
  "type": "module",
  "dependencies": {
    "date-fns": "3.6.0"
  }
}

Running npm install in the root of your project will install the dependencies of all the packages in the workspace.

Since docs has a dependency on date-fns, npm will install date-fns in the root node_modules folder. It will also include each package in the workspace in the node_modules folder. This is how your node_modules would look like:

node_modules/
- date-fns/
- docs/
- utils/
- web/

Notice that the web package and docs package does not have a dependency on the utils package but it can import files from the utils package. We will touch on this later.

For more information on npm workspaces, you can refer to the official documentation.

What are the problems with npm / npm workspaces?

There are a few problems with npm / npm workspaces that make it not ideal for managing multiple packages in a monorepo. These reasons are ordered by importance:

1. No encapsulation of dependencies

In npm workspaces, npm installs the dependencies of all workspaces in the root node_modules folder. This means that any package can access the dependencies of any other package in the workspace.

For example, let's say we have two packages in a workspace, docs and web. If docs has a dependency on date-fns, npm will install date-fns in the root node_modules folder. Then web can access date-fns even though it is not a direct dependency of web or date-fns.

// packages/docs/package.json
{
  "name": "docs",
  "type": "module",
  "dependencies": {
    "date-fns": "3.6.0"
  }
}
// packages/web/index.js
import { format } from "date-fns"; // Imports date-fns from the root node_modules folder

export function formatDate(date) {
  return format(date, "yyyy-MM-dd");
}

console.log(formatDate(new Date())); // 2024-07-09

This can make it difficult to reason about the dependencies of each package. It is easy for someone in the team to accidentally use a dependency that is not intended for that package. Not only that, it makes it easy to import other packages in the workspace as a dependency. For example, the utils package can import the web package, causing a circular dependency.

// packages/utils/index.js
import { formatDate } from "web";

console.log(formatDate(new Date())); // 2024-07-09

This alone is a good reason to migrate to pnpm workspaces, but let's look at 2 more reasons why npm is not ideal in general.

2. Direct use of transitive dependencies

In npm, you can directly use the transitive dependencies of a package. A transitive dependency is a dependency of a dependency. This problem isn't specific to the workspaces feature, but happens because of npm's flat dependency tree.

For example, if the docs package has a dependency on formik, you can use the dependencies of formik directly in the docs package. If we import formik:2.4.6, we can use lodash which is one of formik's dependencies directly in our code.

// packages/docs/package.json
{
  "name": "docs",
  "version": "1.0.0",
  "dependencies": {
    "date-fns": "3.6.0",
    "formik": "2.4.6" // One of the dependencies of formik is lodash
  }
}
// packages/docs/index.js
import _ from "lodash";

console.log(_.upperCase("hello")); // HELLO

The reason why this works is because npm uses a flat dependency tree. This means that all dependencies (and their dependencies) are installed in the node_modules folder. So all of formik dependencies are installed in the node_modules folder.

node_modules/
- date-fns/
- formik/
- lodash/
- ... # other dependencies of formik

This is a problem because now anyone can use lodash even though it is not a direct dependency of the docs package, and it should not be used directly. It's also not clear where lodash is coming from, and if formik decides to remove lodash as a dependency in the future, the docs package will break.

This problem becomes even worse because of the first problem mentioned above, and means that the web package can also use lodash directly.

So how can we solve these problems?

We can solve these problems by using pnpm workspaces instead of npm workspaces.

First, what is pnpm?

pnpm is a fast, disk space efficient package manager that uses a content-addressable storage to store dependencies. This means that if you have multiple projects that use the same dependencies, pnpm will only install the dependencies once. This can lead to faster installation times and less disk space being used.

pnpm also has workspaces support, but they work differently than npm workspaces

Let's look at how pnpm workspaces work

In order to create a pnpm workspace, you will need a pnpm-workspace.yml file in the root of your project. This file should contain the paths to your workspaces. For example, if you have a packages directory that contains all your workspaces, you can specify the following in your pnpm-workspace.yaml:

# pnpm-workspace.yaml
packages:
  - packages/*

You can then create individual packages in the workspace inside the packages directory similar to the npm example. If we want to have utils, web and docs packages and docs and web packages depend on the utils package, we can create the following packages:

// packages/utils/package.json
{
  "name": "utils",
  "type": "module"
}
// packages/web/package.json
{
  "name": "web",
  "type": "module",
  "dependencies": {
    "utils": "workspace:*"
  }
}
// packages/docs/package.json
{
  "name": "docs",
  "type": "module",
  "dependencies": {
    "date-fns": "3.6.0",
    "utils": "workspace:*"
  }
}

Running pnpm install in the root of your project will install the dependencies of all the workspaces. The difference from npm workspaces is that pnpm will install the dependencies of each package in its own node_modules folder. This means that each package can only access its own dependencies and not the dependencies of other packages in the workspace.

You can notice here we are using utils: "workspace:*" to specify that we want to use the utils package in the workspace. This is how your node_modules would look like:

node_modules/
- .pnpm # local pnpm store
- .modules.yaml

packages/
- docs/
  - node_modules/
    - date-fns
    - utils
- utils/
- web/
  - node_modules/
    - utils

So how does pnpm solve the problems with npm & npm workspaces?

1. Encapsulation of dependencies

As mentioned above, each package can only access its own dependencies. For example, if we have two packages in a workspace, docs and web, and docs has a dependency on date-fns, pnpm will install date-fns in the docsnode_modules folder. Then web cannot access date-fns since it is not a direct dependency of web.

// packages/docs/package.json
{
  "name": "docs",
  "version": "1.0.0",
  "dependencies": {
    "date-fns": "3.6.0"
  }
}
// packages/web/index.js
import { format } from "date-fns"; // Error: Cannot find module 'date-fns'

2. No direct use of transitive dependencies

In pnpm, you cannot directly use the transitive dependencies of a package. This means that you can only use the dependencies that are specified in the package.json file of the package.

For example, if the docs package has a dependency on formik, you cannot use the dependencies of formik directly in the docs package.

// packages/docs/package.json
{
  "name": "docs",
  "version": "1.0.0",
  "dependencies": {
    "date-fns": "3.6.0",
    "formik": "2.4.6" // One of the dependencies of formik is lodash
  }
}
// packages/docs/index.js
import _ from "lodash"; // Error: Cannot find module 'lodash'

The reason that lodash cannot be used directly in the docs package is because pnpm does not use a flat dependency tree. This means that the dependencies of formik are not installed in the node_modules folder of the docs package (or the root node_modules folder). Rather, they are installed in a global store. This is how your node_modules would look like:

node_modules/
- .pnpm # project's pnpm store
  - date-fns@3.6.0
  - formik@2.4.6_react@18.3.1
  - lodash@4.17.21
  - ... # other dependencies of formik
docs/
- node_modules/
  - formik/ # linked to .pnpm/formik@2.4.6_react@18.3.1

The root node_modules/.pnpm will contain a hard link to the formik package in the global store. And then, the node_modules in the docs package will contain a hard link to the formik package in the root node_modules/.pnpm folder.

Conclusion

In conclusion, pnpm workspaces are a better choice than npm workspaces for managing multiple packages in a monorepo. pnpm workspaces provide encapsulation of dependencies and do not allow direct use of transitive dependencies.

35
Subscribe to my newsletter

Read articles from Edrick Leong directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Edrick Leong
Edrick Leong

Software Engineer in Perth