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:
-
Generate a lock file without any cache.
-
Install dependencies from existing lock files without any cache.
-
Install dependencies from existing lock files with global cache.
There are two types of cache:
-
Global.
Usually stored in the user’s home directory (f.e.,
~/.yarn/berry/cache
). -
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:
- Manage dependencies (install, remove, update)
- Publish packages (private, public)
- Link-local packages
- 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):
Now, let’s see how it performs in terms of installation speed…
Generate package-lock.json Without Any Cache
It took package-lock.json
and install dependencies without any cache.
Command used:
npm i
Install Dependencies From package-lock.json Without Any Cache
It took package-lock.json
without any cache.
Command used:
npm ci
Install Dependencies From package-lock.json With Global Cache
It took 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:
- Install dependencies for all projects within the workspace.
- Install dependencies for a single specific project.
- 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:
-
“Zero Installs” (default) - creates
.yarn
folder and lists packages inyarn.lock
&.pnp.cjs
files. -
A regular one - similar to npm, stores dependencies into
node_modules
and lists them inyarn.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:
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
It took yarn.lock
file and install dependencies without cache.
Command used:
yarn install
Install Dependencies From Existing Lock Files Without Any Cache
It took
Command used:
yarn install --frozen-lockfile
Install Dependencies From Existing Lock Files With Global Cache
It took
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:
- Install dependencies for all projects within the workspace.
- Install dependencies for a single specific project.
- 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
It took pnpm-lock.yaml
and install dependencies without any cache.
Command used:
pnpm install
Install Dependencies From pnpm-lock.yaml Without Global Cache
It took pnpm-lock.yaml
without cache.
Command used:
pnpm i --frozen-lockfile
Install Dependencies From Existing Lock File With Global Cache
It took 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:
-
eslint
andtsc --noEmit
throw a “JavaScript Heap Out of Memory” error in my GitHub Actions pipelines. -
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! 👇😊