← writings

npm was fine. then i used pnpm.

npm and pnpm

For years, my choice of JavaScript package manager was not a choice at all. npm came with Node.js, tutorials began with npm install, repositories had a package-lock.json, and I had more immediate problems, such as a red build or a dependency that had worked yesterday and had, overnight, acquired a personality disorder.

This is how a large amount of software infrastructure becomes infrastructure. It is present, it mostly works, and replacing it sounds less useful than finishing whatever one had opened the editor to do. A default has the considerable advantage of not requiring a meeting, even if the meeting is only with oneself.

Then I began moving between more projects. JavaScript, meanwhile, had moved into my disk, eaten the food, and started paying rent in gigabytes.

npm caches packages. This is invariably pointed out when disk use comes up, as though the existence of a cache resolves the shape of every installation built from it. Across many repositories, npm still constructs a local dependency tree for each project. The machine repeatedly downloads or unpacks versions of packages it already possesses elsewhere, and every repository gets its own monumental node_modules, much of it familiar.

pnpm was the first alternative whose storage model felt like the obvious one after I had used it. I moved because it wastes less disk, exposes undeclared dependencies, makes workspaces pleasantly uneventful, and asks before allowing packages to run scripts. None of this is a reason to develop a personality around a package manager.

pnpm keeps packages in a content-addressable store and links them into projects. If three repositories require the same version of a package, the disk does not have to impersonate three disks. This is a small implementation detail in the same way that a leaking tap is a small plumbing detail: uninteresting in isolation, conspicuous after it has been repeated throughout the house.

I expected installation speed to be the persuasive bit. It was good, but disk usage was what made the change stick.

A clean npm install had acquired the quality of a minor ceremony. Run the command, watch the terminal, receive a warning from a package last touched in 2017, and wonder whether the audit output is conveying useful information or attempting emotional blackmail. The computer then appears to rediscover JavaScript from first principles.

Of course, pnpm cannot abolish networks or physics. A first install takes time. Once the packages are in its store, however, subsequent installs are far less theatrical: the package exists, pnpm links it, and work continues. In CI, the difference accumulates. A few apparently harmless minutes repeated across runs eventually become a workday spent installing dependencies, which is a peculiar use of both computers and humans.

The storage model got me to try pnpm. Its refusal to conceal certain mistakes is why I stayed.

npm's hoisting can make an undeclared dependency appear legitimate. A package uses something it failed to declare, but the missing package happens to sit at the top of node_modules because another dependency brought it along. Everything works, which is the dangerous part. In another environment, the tree is arranged differently and production cannot find a package the project never said it needed.

pnpm is ruder about this. Packages generally get access to what they declare. Older libraries can break under that arrangement, and discovering this while trying to ship an unrelated feature is not an enjoyable philosophical lesson in dependency hygiene. Still, I would rather learn that a package is held together by coincidence while the failure is nearby and comprehensible. The alternative is to preserve the coincidence until a less convenient machine exposes it.

The annoyance is information.

Monorepos made the distinction clearer. Few people wake up with an organic desire for a monorepo. There is a web application, then a shared package, a script, another application, and eventually one is explaining workspace configuration to oneself at 1 AM as though the arrangement had been deliberate from the beginning.

pnpm workspaces have been boring for me: one workspace file, one lockfile, and a --filter flag for operating on a package without rousing the entire repository. Tooling should be boring in precisely this manner. I do not need a package manager to become a framework, a worldview, or a collaborative social exercise. It should install the declared packages quickly and stop them from becoming imaginative.

Install scripts deserve the same suspicion. Dependencies are code written by strangers, fetched from the internet, often several levels below the package one knowingly selected. Permitting all of them to execute whatever they please during installation is a startling default once stated plainly. pnpm provides more control over which build scripts are approved. I prefer being asked the question to inheriting an invisible, permanent yes.

There are costs. Old packages can behave strangely. CI configurations may need attention. Windows has a remarkable ability to turn filesystem details into a second occupation. A codebase that works because npm has hidden its undeclared dependencies will complain when those dependencies are hidden no longer.

For a small repository where npm works, changing package managers may achieve little beyond changing package managers. Software has enough ceremonial migrations already. But across multiple projects or inside a monorepo, pnpm has made my installs lighter, repeated work less repetitive, and dependency failures closer to their cause.

That is the entire case. It is not prettier branding and it is certainly not allegiance in the package-manager war. My computer does less pointless work, and the dependency graph tells fewer lies. For infrastructure that I have to run every day, that is enough.