{"id":496,"date":"2026-07-23T13:02:10","date_gmt":"2026-07-23T13:02:10","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/pnpm-vs-npm-2026-the-phantom-dependency-i-had-been-shipping\/"},"modified":"2026-07-23T13:02:10","modified_gmt":"2026-07-23T13:02:10","slug":"pnpm-vs-npm-2026-the-phantom-dependency-i-had-been-shipping","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/pnpm-vs-npm-2026-the-phantom-dependency-i-had-been-shipping\/","title":{"rendered":"pnpm vs npm in 2026: The Bug It Found on Day One"},"content":{"rendered":"<p>Short version for the impatient: pnpm is faster and stricter than npm, and the strictness is the actual reason to switch. The speed is a nice bonus you stop noticing after a week.<\/p>\n<p>Longer version: I moved a client project off npm about eight months ago, mostly because our CI installs were taking four minutes and I was tired of watching the spinner. The install time dropped, sure. But the thing that actually mattered was a bug pnpm found on day one that had been sitting in that codebase for over a year. I&rsquo;ll get to it.<\/p>\n<p>I&rsquo;ve also hit enough sharp edges since then that I don&rsquo;t recommend pnpm to everyone anymore. So this is the honest version, including the parts where I had to back out.<\/p>\n<h2 id=\"what-pnpm-actually-does-differently\">What pnpm actually does differently<\/h2>\n<p>npm gives you a flat <code>node_modules<\/code>. Every package your dependencies depend on gets hoisted up to the top level, one big pile, deduplicated where possible. It works. It&rsquo;s also why <code>node_modules<\/code> is the heaviest object in the known universe.<\/p>\n<p>pnpm stores every package version once in a global content-addressable store on your disk, then hard links the files into your project. Install the same version of <code>lodash<\/code> in twelve projects and you&rsquo;ve got one copy on disk, twelve hard links. The <a href=\"https:\/\/pnpm.io\/symlinked-node-modules-structure\" rel=\"nofollow noopener\" target=\"_blank\">pnpm docs on the symlinked structure<\/a> walk through the layout, but the short version is that <code>node_modules<\/code> looks like this:<\/p>\n<pre><code class=\"language-text\">node_modules\n\u251c\u2500\u2500 foo -&gt; .\/.pnpm\/foo@1.0.0\/node_modules\/foo\n\u2514\u2500\u2500 .pnpm\n    \u251c\u2500\u2500 bar@1.0.0\n    \u2502   \u2514\u2500\u2500 node_modules\n    \u2502       \u2514\u2500\u2500 bar -&gt; &lt;store&gt;\n    \u2514\u2500\u2500 foo@1.0.0\n        \u2514\u2500\u2500 node_modules\n            \u251c\u2500\u2500 foo -&gt; &lt;store&gt;\n            \u2514\u2500\u2500 bar -&gt; ..\/..\/bar@1.0.0\/node_modules\/bar\n<\/code><\/pre>\n<p>Only <code>foo<\/code> is at the top level, because <code>foo<\/code> is what you actually put in your <code>package.json<\/code>. <code>bar<\/code> lives one level down, reachable only by <code>foo<\/code>. Node&rsquo;s resolution algorithm ignores symlinks and resolves to the real path, so this all just works without patching anything.<\/p>\n<p>That top-level <code>node_modules<\/code> being nearly empty is the whole trick.<\/p>\n<p>The disk savings are real but oversold. On my machine, seven projects that all use roughly the same React and TypeScript versions went from about 4.2 GB of <code>node_modules<\/code> down to a 1.1 GB shared store. Nice. Also completely irrelevant to whether my code works. I mention it because every pnpm pitch leads with this number, and it&rsquo;s the least interesting thing about the tool.<\/p>\n<p>The install speed is similar. First install on a cold store isn&rsquo;t dramatically faster than npm, because you&rsquo;re still downloading everything. It&rsquo;s the second, third and fortieth install that fly, since the files are already sitting in the store and pnpm just relinks them. Our CI went from around four minutes to about fifty seconds once the store was cached between runs. If you don&rsquo;t cache the store in CI, you get most of npm&rsquo;s install time back and you&rsquo;ll wonder what the fuss was about.<\/p>\n<h2 id=\"the-bug-it-found-on-day-one\">The bug it found on day one<\/h2>\n<p>Here&rsquo;s what happened. The project imported <code>date-fns<\/code> in about six files. <code>date-fns<\/code> was never in <code>package.json<\/code>. It was in <code>node_modules<\/code> because some other dependency pulled it in, npm hoisted it to the top, and Node happily resolved it.<\/p>\n<p>This is a phantom dependency, and it&rsquo;s a time bomb. The day that transitive dependency bumps its version, or drops <code>date-fns<\/code> entirely, your build breaks for reasons that have nothing to do with anything you changed.<\/p>\n<p>npm install: fine, no complaints, ships to prod.<\/p>\n<p>pnpm install: <code>Cannot find module 'date-fns'<\/code>, six files, immediately.<\/p>\n<pre><code class=\"language-js\">\/\/ what the code did\nimport { format } from 'date-fns';\n\n\/\/ what package.json said\n{\n  &quot;dependencies&quot;: {\n    &quot;express&quot;: &quot;^4.18.0&quot;,\n    &quot;react&quot;: &quot;^18.2.0&quot;\n    \/\/ no date-fns anywhere\n  }\n}\n<\/code><\/pre>\n<p>The fix was <code>pnpm add date-fns<\/code> and moving on with my life. Took ninety seconds. But I would never have found it otherwise, and it had been sitting there since 2024. Multiply that by however many packages in your tree are quietly doing the same thing.<\/p>\n<p>If you take one thing from this post: run <code>pnpm install<\/code> on your project once, just to see what falls out. You don&rsquo;t have to commit to anything.<\/p>\n<h2 id=\"where-it-bit-me\">Where it bit me<\/h2>\n<p>Now the part most pnpm posts skip.<\/p>\n<p>The ecosystem is full of packages that use dependencies they never declared. pnpm&rsquo;s own docs admit this outright, which is why it hoists everything into <code>node_modules\/.pnpm\/node_modules<\/code> by default as a compatibility hack. That gets you most of the way, but not all of it.<\/p>\n<p>Things that broke for me:<\/p>\n<p>React Native. Metro&rsquo;s resolver and symlinks have a long and unhappy history. I spent an afternoon on it, got it half-working, and went back to npm for that project. Not worth the fight.<\/p>\n<p>Some older webpack setups that resolve loaders by walking up directories. If a tool assumes it can reach into a flat <code>node_modules<\/code>, symlinks confuse it.<\/p>\n<p>Postinstall scripts. pnpm 10 stopped running dependency build scripts by default, which is a good security decision and an annoying migration. If something silently stops working after install, that&rsquo;s probably why:<\/p>\n<pre><code class=\"language-yaml\"># pnpm-workspace.yaml\nonlyBuiltDependencies:\n  - esbuild\n  - sharp\n<\/code><\/pre>\n<p>You allowlist the packages you actually trust to run code at install time. I like this a lot more than the npm default of &ldquo;everything runs, good luck,&rdquo; but you will find out the hard way which packages need it.<\/p>\n<h2 id=\"pin-the-version-or-ci-will-humiliate-you\">Pin the version or CI will humiliate you<\/h2>\n<p>This one cost me a genuinely stupid afternoon. My laptop had pnpm 9, CI had pnpm 10, the lockfile format changed between them, and the error message was not helpful about why.<\/p>\n<p>Fix is one line in <code>package.json<\/code>:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;packageManager&quot;: &quot;pnpm@10.13.1&quot;\n}\n<\/code><\/pre>\n<p>Corepack reads that field and uses exactly that version, so everyone on the team and every CI runner gets the same one. You can set it with <code>corepack use pnpm@latest<\/code> instead of typing it by hand.<\/p>\n<p>The wrinkle: <a href=\"https:\/\/github.com\/nodejs\/corepack\" rel=\"nofollow noopener\" target=\"_blank\">Corepack ships with Node from 14.19.0 up to but not including 25.0.0<\/a>. It&rsquo;s not bundled with Node 25 anymore. If you&rsquo;re on 25, install it yourself:<\/p>\n<pre><code class=\"language-bash\">npm install -g corepack\ncorepack enable\n<\/code><\/pre>\n<p>Yes, you install corepack with npm. The corepack README acknowledges the irony, which I appreciated.<\/p>\n<h2 id=\"when-i-still-reach-for-npm\">When I still reach for npm<\/h2>\n<p>I use pnpm by default now, but not always.<\/p>\n<p>Single-file scripts and throwaway prototypes get npm, because it&rsquo;s already there and I&rsquo;m not going to think about a lockfile for something I&rsquo;ll delete on Friday.<\/p>\n<p>Anything React Native, as covered.<\/p>\n<p>Libraries where I want to test what consumers actually experience. If most of my users are on npm with a flat <code>node_modules<\/code>, testing under pnpm&rsquo;s strict layout tells me my package works in a stricter environment than the one it&rsquo;ll live in. Useful, but not the same as testing the real thing.<\/p>\n<p>Codebases where I&rsquo;m the guest, not the owner. Switching a team&rsquo;s package manager is a social decision more than a technical one, and doing it unilaterally in a PR is a great way to annoy people.<\/p>\n<p>For monorepos it&rsquo;s not close. pnpm workspaces are the reason I stopped dreading multi-package repos, and I got into the tradeoffs there in <a href=\"https:\/\/abrarqasim.com\/blog\/monorepo-vs-polyrepo-2026-when-i-actually-merge-the-repos\" rel=\"noopener\">when I actually merge the repos<\/a>. Most of the client <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work I do<\/a> ends up as some flavor of monorepo, so this comes up constantly.<\/p>\n<h2 id=\"what-id-actually-do-this-week\">What I&rsquo;d actually do this week<\/h2>\n<p>Pick one project. Not your most important one.<\/p>\n<pre><code class=\"language-bash\">rm -rf node_modules package-lock.json\ncorepack enable\npnpm install\npnpm run build &amp;&amp; pnpm test\n<\/code><\/pre>\n<p>If it builds and the tests pass, you&rsquo;re done, commit the <code>pnpm-lock.yaml<\/code> and add the <code>packageManager<\/code> field. If it doesn&rsquo;t, read the missing-module errors carefully, because each one is a phantom dependency you&rsquo;ve been shipping. That list is worth having whether or not you keep pnpm.<\/p>\n<p>If it turns into a mess, <code>rm -rf node_modules pnpm-lock.yaml &amp;&amp; npm install<\/code> puts you back exactly where you started. The whole experiment costs about ten minutes and there&rsquo;s no version of it where you learn nothing.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I moved a client project from npm to pnpm eight months ago. The install speed was fine. The phantom dependency it caught on day one was the actual win.<\/p>\n","protected":false},"author":2,"featured_media":495,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I moved a client project from npm to pnpm eight months ago. The install speed was fine. The phantom dependency it caught on day one was the actual win.","rank_math_focus_keyword":"pnpm vs npm","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[165],"tags":[567,44,77,379,377,378,376,201],"class_list":["post-496","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-javascript","tag-corepack","tag-javascript","tag-monorepo","tag-node-js","tag-npm","tag-package-manager","tag-pnpm","tag-tooling"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/496","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=496"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/496\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/495"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=496"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=496"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=496"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}