Why It's Time to Move from npm to pnpm: The Case for Switching

Fin Garrard
September 22, 2026
9
mins
Dev
Dev
Fin Garrard
September 22, 2026
9
minutes
<  Back to blogs

Why It's Time to Move from npm to pnpm: The Case for Switching

Fin Garrard
September 22, 2026
9
mins
Dev

You've read the "npm has problems" posts. You've probably nodded along. And you're still typing npm install every morning.

You're not alone. Most developers who never moved off npm know the arguments already. It's slow. It duplicates packages across every project. Its node_modules is a swamp. But the switching cost feels vague and the benefits feel abstract, so the terminal keeps opening on the same command it always has.

This post isn't a benchmark shootout. When you look at pnpm vs npm honestly, the argument comes down to three things that affect your day: how fast installs run, whether your dependency tree is telling the truth, and whether your monorepo tools cooperate. Speed gets you in the door. Correctness is what keeps you there. Monorepo support is the ceiling.

Vue, Vite, Nuxt, SvelteKit, Prisma, Astro and Turborepo have all migrated to pnpm since 2022. That's not a coincidence, and it's not fashion. It's the same conclusion every engineering team eventually reaches once they've felt the difference. Here's what they saw.

What actually makes pnpm different from npm

The one-line version: pnpm keeps a single copy of every package on your disk and links to it from each project. npm copies every package into every project's node_modules.

That sounds like a boring implementation detail. It isn't. Almost every advantage pnpm has over npm falls out of that one design choice.

When you run npm install, npm builds a flat node_modules folder. Every dependency and every dependency's dependency gets hoisted up to the top level so everything sits side by side. If you have ten projects that all use React, you have ten separate copies of React on your machine.

pnpm works differently. It maintains a global content-addressable store, typically at ~/.pnpm-store. Every unique package version lives there exactly once. Inside each project, pnpm creates a nested node_modules where the top level only contains the packages your package.json declares directly. Everything else lives in node_modules/.pnpm, linked from the global store.

Two consequences fall straight out of this:

  • The same React install exists once on disk, not once per project.
  • Your code can only see the packages you explicitly declared. Transitive dependencies are hidden from your app code.

Everything below is downstream of those two facts.

Install speed and disk cost

pnpm is roughly two to three times faster than npm on a typical project, and the gap widens on CI where cold caches are the norm. Benchmarks vary by hardware and project size, but the direction is consistent across every serious comparison run in the last two years.

The reason is straightforward. Installing a package with pnpm mostly means creating a link. Installing the same package with npm means copying files. Links are cheap. Copies aren't. Once the global store has a package version, every subsequent project that needs it gets it almost instantly.

On CI this compounds. A team running ten deployments a day across three environments can shave meaningful minutes off compute time just by swapping the package manager. That's real money on Vercel, Netlify, GitHub Actions, or anywhere else you pay for build minutes.

Disk is the other quiet win. Twenty JavaScript projects on your laptop probably contain twenty copies of React, twenty copies of TypeScript, twenty copies of every lodash function you've ever imported. pnpm collapses that to one. It's not unusual to reclaim ten to fifteen gigabytes after migrating a machine that's been running npm for years.

The industry has clocked this. In the 2024 State of JavaScript survey, pnpm reached 40% usage and 64% positivity among monorepo tools, and the survey explicitly called it the clear front-runner in that category. The Stack Overflow Developer Survey 2024 shows pnpm's retention rising for the second year in a row while npm's usage share, though still dominant, is being chipped at by every new project that starts fresh.

We see this most sharply on client projects where CI cost has been creeping up quietly. On the React Native monorepos we build with pnpm and Turborepo, cold installs go from something you notice to something you don't, and warm installs become effectively free. Multiply that across every PR and every deploy and the savings are hard to ignore. If you'd like to see how we approach this kind of work, our software development services page covers the wider picture.

The correctness argument nobody talks about enough

This is the part most posts skip past. It's also the reason you'll never go back once you've felt it.

pnpm enforces that your code can only import packages that are actually declared in your package.json. npm doesn't. That sounds obvious until you realise how much of your working code relies on the opposite being true.

Here's what happens with npm. You install react-date-range. That package has date-fns as one of its own dependencies, so npm hoists date-fns up to the top level of node_modules. Now, from anywhere in your app, you can write import { format } from 'date-fns' and it works, even though date-fns was never in your package.json.

That's a phantom dependency. Your app is importing a package it never declared. It works today because a transitive dependency happens to pull it in. It'll break the day that transitive dep bumps its version, drops the peer, or gets replaced.

These bugs are the worst kind. They pass local development, pass CI, pass staging, and then blow up in a Docker build or a production deploy where the resolution graph differs by a hair. You spend two hours on Slack asking why the build only fails on the deploy machine.

pnpm makes this impossible. Its node_modules layout means your app code physically cannot reach a package that isn't declared. Import something you didn't install, and you get a module resolution error immediately, at install or at first run, not in production.

The enforcement is free. It's not a lint plugin you have to remember to configure. It's not a CI job that might get skipped. It's pnpm install, and the audit happens whether you asked for it or not.

When teams migrate from npm to pnpm, the first install often surfaces a handful of phantom dependencies. That's not pnpm being difficult. It's pnpm showing you bugs that were already there.

pnpm workspaces and monorepos

If you've ever tried to build a monorepo with npm workspaces, you know why the ecosystem consolidated around pnpm. pnpm workspaces are built into the tool, and the workspace protocol makes local package linking genuinely painless.

A pnpm workspace starts with a single file at the repo root:

  • pnpm-workspace.yaml lists which directories contain packages, typically packages/* and apps/*.
  • Each package has its own package.json as normal.
  • Local packages reference each other with the workspace: protocol, e.g. "@myapp/ui": "workspace:*".

The workspace:* protocol is the piece that makes it work. During development, pnpm links to the local package directly, so any change you make is picked up instantly across the whole repo. On publish, pnpm rewrites it to the actual version number. You get local iteration speed and correct published packages without maintaining two sets of dependency ranges.

Filters are the other half. pnpm --filter web build runs the build script only in the web package. pnpm --filter "./apps/*" test runs tests across every app. This is the sort of thing you assemble from bash scripts with npm workspaces, and it ships out of the box with pnpm.

That's why every serious monorepo tool now assumes pnpm underneath it. Turborepo's own repo uses it. So do Vue's, Vite's, Nuxt's, and most of the modern frontend ecosystem you'd want to model your setup on.

We rely on this setup across most of the monorepos we build for clients, and it's what lets a small team ship confidently across several apps at once. Our recent client work goes deeper into how we structure real projects.

Where Turborepo fits in

People sometimes ask whether they need Turborepo if they're already on pnpm workspaces. It's a fair question because the two tools sound like they overlap, but they don't. They stack.

Split it cleanly: pnpm decides how packages link to each other. Turborepo decides which package scripts run, in what order, and whether they need to run at all. pnpm gives you a working monorepo. Turborepo makes it fast.

Turborepo reads your workspace layout, builds a task graph from your turbo.json, and runs tasks with two properties that matter: correct ordering, and caching. A turbo.json entry like this:

  • "build": { "dependsOn": ["^build"] }

tells Turborepo that before it builds any package, it should build that package's own dependencies first. Run turbo build on your app and it'll build the shared UI package, the shared utils package, and anything else the app depends on, in the correct order, in parallel where safe.

Then it caches everything by content hash. Next time you run the same task with the same inputs, Turborepo restores the output from cache instead of rebuilding. On CI, this is dramatic. A build that took four minutes cold takes ten seconds warm.

The combination that's become the modern default is pnpm workspaces for the linking layer, Turborepo for the task graph and cache. Neither replaces the other. Together they're what a fast, correct JavaScript monorepo looks like in 2026.

The migration is cheaper than you think

The biggest thing keeping teams on npm isn't a technical objection. It's the assumption that switching is a project. For most codebases, it isn't.

The mechanical steps are short:

  1. Install pnpm globally: npm install -g pnpm, or use Corepack.
  2. Delete node_modules and package-lock.json.
  3. Run pnpm import to convert your existing lockfile.
  4. Run pnpm install.
  5. Update your CI scripts and Dockerfiles to call pnpm instead of npm.

Most of the time, that's it. Your package.json doesn't change. Your scripts don't change. The commands you type all day are almost identical.

Two things sometimes break, and both are worth breaking. The first is phantom dependencies, as covered above. If a package your code imports isn't in your package.json, add it. You've just fixed a latent bug. The second is packages that misbehave with a strict node_modules layout, usually older tools that assumed hoisting. You can list them under public-hoist-pattern in .npmrc, or in rare cases enable shamefully-hoist for the whole project. Neither is common in modern codebases.

If your organisation has strict tooling approval processes, migrating a single project first is the sane move. Pick something small. Run it for a month. Let the CI time savings and the two or three phantom dependencies you'll find make the case for you.

The bottom line

Speed is what gets you to try pnpm. It's the visible thing, the one you notice in the first hour. But it's not why you stay.

You stay because your dependency tree stops lying to you. Because your monorepo stops fighting you. Because when a build works on your machine, you actually believe it'll work everywhere else.

The rest of the ecosystem has already made this move. Being on npm in 2026 isn't a considered choice for most teams. It's inertia. Try pnpm on one project, feel the loop tighten, and decide from there.

FAQs

Is pnpm compatible with npm packages?

Yes. pnpm installs from the same npm registry, reads the same package.json, and handles the same package formats. Every npm package works with pnpm. The lockfile format is different (pnpm-lock.yaml instead of package-lock.json), but pnpm import converts an existing npm lockfile in one command.

Is pnpm actually faster than npm, or is that marketing?

It's genuinely faster. Independent benchmarks put pnpm at roughly two to three times npm's install speed on typical projects, with bigger gains on CI cold installs. The reason is architectural, not tuning: pnpm links files from a global store where npm copies them per project. You'll feel the difference on your second install of the day.

What are phantom dependencies and why does pnpm block them?

A phantom dependency is a package your code imports but never declared in package.json. With npm's flat node_modules, transitive dependencies get hoisted up and become reachable by accident. pnpm's isolated node_modules makes this impossible, so import errors surface at install time instead of exploding in production when a dependency tree shifts.

Do I need Turborepo if I'm using pnpm workspaces?

Not always. pnpm workspaces alone give you package linking, filters, and monorepo installs. Turborepo adds task orchestration and build caching on top. For a small monorepo with a handful of packages, pnpm workspaces might be enough. Once you have several apps and shared packages with real build steps, Turborepo pays for itself in CI minutes.

What's the difference between workspace:*, workspace:^, and workspace:~?

All three tell pnpm to link a local workspace package. The difference shows up when you publish. workspace:* becomes the exact current version. workspace:^ becomes a caret range (compatible minor and patch updates). workspace:~ becomes a tilde range (patch updates only). Use * for internal packages that aren't published, and ^ or ~ for published ones where you want consumers to receive updates.

Are there any projects where I shouldn't use pnpm?

A few. If you're brand new to JavaScript, npm's zero-setup makes tutorials easier to follow. If your organisation has locked down its approved toolchain, don't fight that battle over a package manager. And if you're maintaining an old project full of tools that assume hoisting, the migration cost may outweigh the benefits. For everything else, especially professional projects, monorepos, and anything with a CI pipeline, pnpm is the better default.

Any thoughts?

Leave a comment

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Responses
--

ReplyDelete

ReplyDelete