We changed our front-end package manager for pnpm

Jerome Boileux
Yousign Engineering & Product
3 min readNov 24, 2022

Note: this article was originally posted on June 1st 2021

At Yousign, you may already know that we are very early adopters. We are constantly looking to challenge our tools, our processes, in short, we seek continuous improvement.

Why changing our package managers?

The question seems quite legitimate. Like many, we use (by default?) Yarn as a package manager on our front-end stack; mainly because we have a monorepo / workspace architecture.

But then why come to challenge Yarn?

The real starting point was the desire to optimize our CI builds, which led us to study alternatives.

There were three options:

So why pnpm?

First, the benchmarks presented by the tool seem promising: https://pnpm.io/benchmarks

pnpm benchmark capture

Pnpm is fast, and that’s why:

pnpm benchmark capture

Then, it’s the migration that appears to be the easiest, there are no breaking changes to use pnpm instead of yarn, just some commands to adapt.

But above all, the answer is more theoretical and lies in the structure used by pnpm to organize the dependencies: flat node_modules directory structure.

Let’s take the classic structure proposed by npm before version 3:

node_modules
└─ foo
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.json

Here, each dependency has its own /node_modules folder, which seems pretty clean; however, what higher versions of npm (as well as yarn) tried to solve was the depth of the resulting dependency tree, as well as the fact that the dependencies were copied multiple times into these folders.

This resulted in a so-called “flat” structure, something like this:

node_modules
├─ foo
| ├─ index.js
| └─ package.json
└─ bar
├─ index.js
└─ package.json

Rather well thought out, you may say, except that in a complex dependency tree like we can quickly have with workspaces, all the dependencies were accessible by all the modules, without requiring any declaration. Ouch!

pnpm tries to solve the problem of heaviness posed by the npm version 2 structure without flattening the dependency tree. For this, it relies on a clever system of symlinks and stores:

→ - a symlink

node_modules
├─ foo → .pnpm/foo/1.0.0/node_modules/foo
└─ .pnpm
├─ foo/1.0.0/node_modules
| ├─ bar → ../../bar/2.0.0/node_modules/bar
| └─ foo
| ├─ index.js
| └─ package.json
└─ bar/2.0.0/node_modules
└─ bar
├─ index.js
└─ package.json

The result is a compatible, clean and predictable structure, which is still efficient to build because symlinks are faster than copy entire packages.

At the end:

  • pnpm is more secure because it is stricter
  • pnpm is faster and more efficient (links to a store)
  • pnpm is better at handling monorepos architectures

From yarn to pnpm in practice

The theory is appealing, but in practice, with a monorepo and a dependency tree much more complex than foo / bar, what does it give?

The migration was actually quite simple to set up and easy to execute, it was necessary to:

  • install pnpm globally locally and in the IC
  • replace yarn commands by pnpm or their equivalent (yarn worspaces appName cmd 👉 pnpm cmd — filter appName)
  • remove yarn.lock and replace them with pnpm-lock.yaml (more readable on the repo)
  • do an intermediate cleaning step, because some imports were not working anymore (they were simply not correctly declared)

Conclusion

Builds in local or in the CI are indeed faster (about x2) (the benchmark is confirmed), the space occupied by the dependencies on the disks is also reduced by about 40%.

Apart from this “performance optimization” aspect which triggered the study and the migration, we have the rather satisfying impression of having cleaned up the architecture and the declarations, partitioned our applications to make them more secure and recovered control over our dependencies.

On the front side, we will soon tell you about another structuring migration in progress… stay tuned.

--

--