How pnpm fits ten copies of React in the space of one
You have ten projects on your machine. All ten depend on React. npm gives each project its own full copy of the package tree. pnpm gives all ten a link to a single shared copy. Same bytes, stored once, ten projects pointing at it. This is how.
The package in your project is not really there
Open node_modules/react in a pnpm project and it is not a folder. It is a symlink:
node_modules/react -> .pnpm/react@19.2.4/node_modules/react
So the real thing lives under .pnpm/, still inside your project. But that copy under .pnpm/ is not a full copy either. Its files link out to a global store that lives once per machine.
Two hops, two kinds of link. Hover the packages below to see them light up.
The symlink does one job: it builds a tree that Node can resolve. import 'react' walks into node_modules/react, follows the symlink, and lands on the real files. That symlink stays inside the project. It never reaches the store.
The files under .pnpm/ do the other job: they connect to the store, where the one real copy lives.
The store keeps one copy, addressed by its contents
The global store sits at ~/Library/pnpm/store on macOS, or ~/.local/share/pnpm/store on Linux. Every package file your machine has ever installed lives there once.
Files in the store are addressed by a hash of their contents. Same hash means same bytes, and those bytes never change. That is the property that makes sharing safe: if two projects want the exact same file, they can point at the exact same stored copy, and neither can surprise the other by editing it.
Git works the same way. Its object database is content addressed by SHA too. pnpm just brought the idea to node_modules.
The link to the store: hardlink on Linux, clone on macOS
Here is the part most people get wrong, including me until I checked my own machine.
"pnpm uses hardlinks" is the common wisdom. On Linux it is true. The store file and the project file are the same inode. One inode, two names pointing at it:
inode #100 <- react/index.js, the real bytes
├─ ~/.local/share/pnpm/store/.../index.js (1)
└─ my-app/.../.pnpm/react@.../index.js (2)
link count = 2
Install the same package in a second project and it hardlinks to the same inode again. The count goes to 3. The number tells you how many projects share this one copy.
On macOS with APFS, pnpm picks a different mechanism: a copy-on-write clone, made with clonefile. The project file gets its own new inode. Its disk blocks are shared with the store, but only until something writes. On the first write, the filesystem copies just the changed blocks and the two files part ways. Because the inode is its own, the link count reads 1.
You can check either one yourself:
# macOS
stat -f "inode=%i links=%l" node_modules/.pnpm/react@19.2.4/node_modules/react/index.js
# inode=88074942 links=1
# Linux
stat -c "inode=%i links=%h" node_modules/.pnpm/react@.../node_modules/react/index.jsls -li works on both. The first column is the inode, the third is the link count.
Why macOS goes with the clone
A hardlink means the store file and your project file are literally one file. If any tool writes into node_modules, it writes straight into the store, and corrupts that package for every project on the machine. Linux pnpm guards against this by keeping the store read only.
A clone has no such risk. Write into it and it splits off on its own, the store copy untouched. When the filesystem can do copy-on-write, pnpm takes the safer path. Same disk savings, less to worry about.
Reading the link count
The link count is just how many names point to one inode.
On Linux, it doubles as a sharing counter. Count 2 means the store plus one project. Count 5 means four projects share this copy. A count above 1 is proof, right there, that the file is not yours alone.
On macOS, the count is always 1. The clone gave the file its own inode, so the counter has nothing to add up. The space is still shared, just not where a link count can see it.
Check it yourself
Want to see the sharing? The command depends on your OS, because the two link types hide it in different places.
On Linux, the store uses hardlinks, so a store file and its copy in node_modules are the same inode. du dedupes by inode: within one run it counts each inode once, and a second hardlink to an inode it already counted adds nothing. The catch is that the dedup only happens inside a single du traversal. So walk the store and your node_modules in the same command, and node_modules adds almost nothing on top of the store:
# Linux: du dedupes by inode, so count them together
du -sh ~/.local/share/pnpm/store # the one real copy
du -sh ~/.local/share/pnpm/store node_modules # node_modules barely adds to itA single du -sh node_modules on its own will not look shrunk, since each package sits there exactly once. The sharing only shows when the store and the project are counted together.
On macOS, the clone gives every file its own inode, so du counts each one at full size and cannot see the sharing. Watch df instead. It measures free space on the whole volume, so it catches what actually got written:
# macOS: du overcounts, so bracket the install with df
before=$(df -k . | awk 'NR==2{print $4}')
pnpm install
after=$(df -k . | awk 'NR==2{print $4}')
# before and after barely differ = almost nothing new hit the diskSame reason under both. The sharing lives in the disk blocks, one layer below the inode. du and the link count look at inodes, so on macOS neither can see it. df measures the filesystem as a whole, so it always can.
The speed is a bonus
pnpm is also faster than npm and yarn to install. That falls out of the same design: most of an install is linking files that already sit in the store, not downloading and copying them. Nice to have, but it is a side effect of the store, not the point. The point is that one copy on disk can serve every project you own.