Why You Don't Need PNPM And YARN

cover
23 Jul 2024

Hello everyone!

I’m sure you’ve seen Node.js projects using different package managers, i.e.:

I’ve seen that myself and worked with all of the above, but I always had a question in my mind - what drives people/teams to use yarn or pnpm instead of npm? What are the pros? Are there any cons?

Well… Let’s find out!

Performance Comparison Rules

I decided to compare npm, yarn, and pnpm in terms of their “speed”…

You’ll see 3 measures below:

  1. Generate a lock file without any cache.

  2. Install dependencies from existing lock files without any cache.

  3. Install dependencies from existing lock files with global cache.

There are two types of cache:

  1. Global.

    Usually stored in the user’s home directory (f.e., ~/.yarn/berry/cache).

  2. Local.

    Stored in the project directory (f.e., <project-dir>/.yarn).

While #2 & #3 are the most common use cases in my experience, I also took #1 just in case (though it’s a veeery rare case).

I used a sample project from create-react-app as an example for benchmarks.

npm

It’s a default package manager for the Node.js ecosystem - what else to say? It comes with the installation package, so it’s basically ready to use when you install Node.js on your machine (or in any CI provider if you set up Node.js there).

That’s a huge “pro” in my mind - you don’t need to install it separately!

Nothing outstanding there - it just… works! And I haven’t seen any major bugs over the years - it seems pretty stable and gets the job done.

The features of npm I used so far:

  1. Manage dependencies (install, remove, update)
  2. Publish packages (private, public)
  3. Link-local packages
  4. Manage workspaces.

Manage Dependencies

npm stores dependencies in node_modules folder of your project root. Pretty straightforward.

ℹ️ package-lock.json stores information about registries for the listed packages - it comes in VERY handy if you’ve got packages from a single scope, i.e. @example-company in different registries (for example - npm & GitHub Packages):

package-lock.json entry

Now, let’s see how it performs in terms of installation speed…

Generate package-lock.json Without Any Cache

Generate package-lock.json and install dependencies without any cache

It took 1 minute for npm to generate a package-lock.json and install dependencies without any cache.

Command used:

npm i

Install Dependencies From package-lock.json Without Any Cache

Install dependencies from package-lock.json without any cache

It took 18 seconds for npm to install dependencies from package-lock.json without any cache.

Command used:

npm ci

Install Dependencies From package-lock.json With Global Cache

Install dependencies from package-lock.json with global cache

It took 8 seconds for npm to install dependencies from package-lock.json with global cache.

Command used:

npm ci

Manage Workspaces

I was able to create a workspace and manage dependencies for the entire workspace at once and for specific projects separately.

In other words - it gets the job done without any bugs/problems, and the official documentation is pretty straightforward.

Workspace features that I used so far:

  1. Install dependencies for all projects within the workspace.
  2. Install dependencies for a single specific project.
  3. Run a single script for all projects at once recursively.

yarn

Honestly, I haven’t tried some of the yarn features much. I mean, I used it a lot in terms of “installing dependencies” while working on some projects, and that’s quite it.

yarn does not come with a Node.js installer, so you’d have to install it separately. It means that there’d be an additional step in your CI pipelines - you’d have to set up yarn before you install your project dependencies.

Manage Dependencies

yarn has two approaches to installing dependencies:

  1. Zero Installs” (default) - creates .yarn folder and lists packages in yarn.lock & .pnp.cjs files.

  2. A regular one - similar to npm, stores dependencies into node_modules and lists them in yarn.lock file.

ℹ️ yarn lock files store information about registries for all listed packages ONLY if you use the old (regular) installation approach.

⚠️ Keep in mind that “Zero Installs” seems to be storing packages in the global cache and providing links to your lock files:

Package links

It might be important for you if you’ve got a Dockerfile or CI pipeline where you install dependencies in one clean environment and then want to move it to another (you’ll have to copy both .yarn folder and local cache).

Since the default approach for yarn now is “Zero Installs” and has better performance than the old approach - we’re going to record benchmarks with this approach only.

Generate Lock Files Without Any Cache

Generate lock files with yarn and install dependencies

It took 16.5 seconds for yarn to generate a yarn.lock file and install dependencies without cache.

Command used:

yarn install

Install Dependencies From Existing Lock Files Without Any Cache

Install dependencies with "Zero Install" approach and without any cache

It took 11 seconds for yarn to install dependencies with the “Zero Install” approach and without any cache.

Command used:

yarn install --frozen-lockfile

Install Dependencies From Existing Lock Files With Global Cache

Install dependencies with "Zero Install" approach and global cache

It took 8 seconds for yarn to install dependencies with the “Zero Install” approach and global cache.

Command used:

yarn install --frozen-lockfile

Manage Workspaces

I was able to create a workspace and manage dependencies for all projects at once and for specific projects separately.

Workspace features that I used so far:

  1. Install dependencies for all projects within the workspace.
  2. Install dependencies for a single specific project.
  3. Run a single script for all projects at once recursively.

The documentation is fine, but command names and flags are somewhat confusing.

For example, I must execute this to run test script in root (.) and nested b2b project:

yarn workspaces foreach -A --include '{.,b2b}' run test

In comparison with npm:

npm run test --workspace=b2b --include-workspace-root

pnpm

pnpm is currently on hype - a lot of companies and open-source projects use it.

Just like yarn - pnpm does not come with a Node.js installer, so you’d have to install it separately. It means that there’ll be an additional step in your CI pipelines - you’ll have to set up pnpm before you install your project dependencies.

Manage Dependencies

pnpm is considered to be Fast, disk space efficient package manager

Indeed, I agree with the “disk space efficient” statement in terms of managing dependencies locally.

By default, pnpm de-duplicates shared dependencies. pnpm creates symlinks for the packages that are used in multiple dependencies. i.e., if packages a and b use package c as a dependency - pnpm is going to store package c as a single copy and create symlinks for packages a and b. That way, the package manager does not create hard copies and saves memory on your SSD/HDD.

ℹ️ pnpm-lock.yaml by default doesn’t store information about registries for the listed packages. You can change that by setting

lockfile-include-tarball-url=true in .npmrc in your workspace root.

⚠️ Keep in mind that pnpm sometimes stores dependencies in the global cache, instead of keeping it a project.

Generate pnpm-lock.yaml Without Any Cache

Generate pnpm-lock.yaml

It took 31 seconds for pnpm to generate a pnpm-lock.yaml and install dependencies without any cache.

Command used:

pnpm install

Install Dependencies From pnpm-lock.yaml Without Global Cache

Install dependencies from pnpm-lock.yaml without global cache

It took 16 seconds for pnpm to install dependencies from pnpm-lock.yaml without cache.

Command used:

pnpm i --frozen-lockfile

Install Dependencies From Existing Lock File With Global Cache

Install dependencies from pnpm-lock-yaml with cache

It took 5 seconds for pnpm to install dependencies from pnpm-lock.yaml with global cache.

Command used:

pnpm i --frozen-lockfile

Manage Workspaces

Now, that’s where things become really interesting…

pnpm has a lot of configuration options, but some core functionality simply doesn’t work!

Let’s review a couple of bugs that I faced:

pnpm install --filter

It’s important to be able to install dependencies for specific projects only -- it’s quite useful for monorepos when you create pipelines related to specific projects within the workspace.

i.e., imagine you’ve got in your workspace:

  • a web app,
  • backend server,
  • test project (end-to-end tests).

All of these are separate npm projects, but they are part of the same repo ☝️

Now, you want a pipeline to run end-to-end tests only. So, you need end-to-end test dependencies only, right?

Well, you won’t be able to do that - pnpm is forcing you to install dependencies for the entire workspace!

pnpm install --filter <project-name> was supposed to install dependencies for selected projects only, but it doesn’t work.

There’s a year-old bug and it was recently closed with a non-working fix.

recursive-install=false

pnpm by default installs dependencies for the entire workspace (all projects) when you run pnpm install

You can alternate this behavior if you set recursive-install=false in .npmrc in your workspace root.

BUT it introduces another bug that is almost 2 years old already.

shared-workspace-lockfile=false

pnpm by default stores the dependencies list in a single lock file (same as npm and yarn).

You can alternate this behavior as well if you set shared-workspace-lockfile=false in .npmrc in your workspace root.

That would allow us to keep the workspace feature and use --ignore-workspace flag to install dependencies for a specific project.

Anyway, this setting introduces a couple of more issues:

  1. eslint and tsc --noEmit throw a “JavaScript Heap Out of Memory” error in my GitHub Actions pipelines.

  2. Some of the dependencies are stored in the global cache and symlinked in node_modules/.pnpm.

Performance Comparison Results

#

npm

yarn

pnpm

Generate a lock file

60 sec

16.5 sec

31 sec

Install dependencies without any cache

18 sec

11 sec

16 sec

Install dependencies with global cache

8 sec

8 sec

5 sec

According to the benchmark above, npm is the slowest package manager ☝️

Anyway, let’s interpret these results…

Generate a Lock File

It is a rare case. Usually, a lock file is created on project initialization and then expands when you install/update packages.

With that in mind - it doesn’t seem like a very important thing to rely on when you choose a package manager.

Install Dependencies

In most cases, your projects keep a specific list of dependencies and you rarely add/remove something.

Most likely, you’ll bump versions of your packages from time to time - these changes are small and you'll re-use the rest of the packages from cache.

In other words, the common use case is -- fetch new packages from the package registry and grab the rest from the cache.

All of these tools are pretty close in their install time:

  • npm (8-18 sec)
  • yarn (8-11 sec)
  • pnpm (5-16 sec)

Conclusion

Facts

  • pnpm is indeed “a fast and disk efficient” package manager - it’s quite clear in the current review!

  • yarn has the fastest time to generate a lock file and install dependencies without any cache.

  • pnpm workspace feature is bugged and some of the bugs have been unresolved for years.

  • both pnpm and yarn require an additional setup in CI pipelines, while npm doesn’t.

  • both pnpm and yarn don’t store package registry information in their lock files, while npm does.

  • all package managers have similar installation time - there’s only matter of seconds between them.

Author’s Thoughts

I think pnpm and yarn does the best job if your requirement for the package manager is as simple as “install dependencies only.”

Even though pnpm and yarn doesn’t come with a Node.js installer out-of-a-box, it’s easy to set up in CI pipelines with either corepack or existing action.

I prefer npm, because:

  • it’s stable (especially workspaces),

  • comes with Node.js and doesn’t require an additional setup in the CI pipeline,

  • stores package registries in package-lock.json so you are able to install dependencies with a single scope from different registries.

These pros outweigh the seconds of speed and disk space that I’d save with yarn or pnpm.

It’s important to mention that most of the installs will likely run in your CI provider, where you can simply cache your dependencies. Disk efficiency is not an argument for CI installs as well - you only install these dependencies once since every time the environment is new (the profit of de-duplication is irrelevant).

What are your criteria for choosing a package manager? Don’t be shy, and let me know your thoughts in the comments section below! 👇😊