How pnpm fits ten copies of React in the space of one

pnpmpackage-managerfilesystemhardlinkcopy-on-write

Say ten projects on one machine all use React. npm puts a full copy of React in each project; pnpm links all ten to a single copy. How does that work?

node_modules/react is a symlink

Open node_modules/react in a pnpm project:

node_modules/react -> .pnpm/react@19.2.4/node_modules/react

So the actual package files live under .pnpm/, still inside the project. But that copy under .pnpm/ is not a full copy either. Its files link to a store shared by the whole machine, where each file is stored once.

Between node_modules/react and the store there are two kinds of link. Hover a package below and its links light up.

When you import 'react', Node finds the symlink at node_modules/react and follows it to the files under .pnpm/. That symlink only points inside the project's .pnpm/. It never points at the store.

The files under .pnpm/ are what link 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 installed on the machine lives there once.

Files in the store are named by a hash of their contents. If two projects both install the same react/index.js, the contents match, so the hash matches, and the store holds one file that both point to.

Git's .git/objects/ works the same way: files there are named by their SHA.

The link to the store: hardlink on Linux, clone on macOS

"pnpm uses hardlinks" is only true on Linux. 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.

On macOS with APFS (Apple's filesystem), pnpm uses a copy-on-write clone instead, via the clonefile call. 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.

Both can be checked with stat:

# 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.js

ls -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 the 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. On Linux, pnpm handles this with verifyStoreIntegrity: before linking into a project, it checks whether the store file has been modified.

A clone has no such risk. When a file in the project is written, the filesystem copies only the changed blocks, and the store copy stays the same. When the filesystem supports copy-on-write, pnpm uses clones; pnpm's docs call cloning the fastest and safest method.

Reading the link count

The link count is 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.

On macOS, the count is always 1. A cloned file has its own inode, and no second name points to it. The space is still shared, but the sharing happens in disk blocks, which the link count does not track.

Check it yourself

Linux and macOS need different commands, because a hardlink shares the inode while a clone shares disk blocks.

On Linux, a store file and its copy in node_modules are the same inode, and within one run du counts each inode once, so a second hardlink to an inode it already counted adds nothing. So walk the store and 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 it

A single du -sh node_modules on its own reports the full size, since nothing else in that run points at the same inodes. 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. Use df instead, which measures free space on the whole volume:

# 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 disk

Installs are faster too

In pnpm's own benchmarks, reinstalling the alotta-packages project with a warm cache and a lockfile takes npm 16.4 seconds and pnpm 12 2.23 seconds, about 7.4 times faster. In that scenario every package is already in the store, so pnpm only creates links instead of downloading and copying.

A clean install, with nothing cached and an empty store, is still 7.4 times faster: npm takes 1 minute 40 seconds, pnpm 12 takes 13.6 seconds. There is nothing to link yet, and pnpm's own explanation is the order of the steps. npm resolves every dependency first, then downloads them all, then writes them all into node_modules. pnpm starts downloading packages into the store while it is still resolving, and fetches the remaining ones during the linking stage as it links.