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!
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.
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:
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…
It took package-lock.json
and install dependencies without any cache.
Command used:
npm i
It took package-lock.json
without any cache.
Command used:
npm ci
It took package-lock.json
with global cache.
Command used:
npm ci
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:
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.
yarn has two approaches to installing dependencies:
“Zero Installs” (default) - creates .yarn
folder and lists packages in yarn.lock
& .pnp.cjs
files.
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 local 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.
It took yarn.lock
file and install dependencies without cache.
Command used:
yarn install
It took
Command used:
yarn install --frozen-lockfile
It took
Command used:
yarn install --frozen-lockfile
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:
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 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.
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
doesn’t store information about registries for the listed packages.
⚠️ Keep in mind that pnpm sometimes stores dependencies in the global cache, instead of keeping it a project.
It took pnpm-lock.yaml
and install dependencies without any cache.
Command used:
pnpm install
It took pnpm-lock.yaml
without cache.
Command used:
pnpm i --frozen-lockfile
It took pnpm-lock.yaml
with global cache.
Command used:
pnpm i --frozen-lockfile
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:
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:
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.
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.
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
and tsc --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
.
# |
npm |
yarn |
pnpm |
---|---|---|---|
Generate a lock file |
60 sec |
16.5 sec |
31 sec |
Install dependencies without any cache |
18 sec |
11 sec |
8 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…
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.
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.
pnpm (5-8 sec) is almost twice as fast as npm (8-18 sec) with yarn (8-11 sec) in the middle.
I think pnpm does the best job if your requirement for the package manager is as simple as “install dependencies only.”
Even though pnpm 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:
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.
What are your criteria for choosing a package manager? Don’t be shy, and let me know your thoughts in the comments section below! 👇😊