Everyone knows a warm npm cache is faster than a cold one. Nobody on my team could say by how much, on a real project, with real numbers. So I made a small project, installed it six times, and timed every run with a stopwatch that doesn't lie: date +%s%N before and after.
The setup
One package.json, five boring, real dependencies — the kind of thing half the tutorials on the internet install in their first step:
{ "name": "npm-timing-test", "dependencies": { "express": "^4.21.2", "lodash": "^4.17.21", "axios": "^1.7.9", "chalk": "^5.3.0", "commander": "^12.1.0" } } Five declared dependencies, 87 packages in the lockfile once the tree resolves, 13 MB of node_modules when it lands. Nothing exotic. If your project looks like this, these numbers are roughly your numbers.
The machine: a small Linux VM with 1.5 shared CPUs, Node 24.22.1, npm 10.9.4. Each run deleted node_modules and the lockfile first, so every install rebuilt the tree from scratch. Cold runs also ran npm cache clean --force beforehand. Every install used --no-audit --no-fund to keep the output clean.
The numbers
| Run | What changed | Time |
|---|---|---|
| Cold 1 | cache wiped | 37,334 ms |
| Cold 2 | cache wiped, repeat | 34,561 ms |
| Warm 1 | cache intact | 1,467 ms |
| Warm 2 | cache intact, repeat | 1,526 ms |
Warm + --prefer-offline | cache intact | 1,537 ms |
Cold + --prefer-offline | cache wiped | 36,198 ms |
Cold, twice: 37.3 and 34.6 seconds. Mean, about 36 seconds. Warm, twice: 1.47 and 1.53 seconds. Mean, about 1.5 seconds.
That's a 24x difference. Not 2x. Not "noticeably faster." Twenty-four times. On a five-dependency project that resolves to 87 packages.
And I verified the installs actually worked, because a fast install of broken packages is worth nothing: after the last run, require('express') loaded fine, version 4.22.3.
The two things that surprised me
--prefer-offline did almost nothing. Warm with the flag: 1,537 ms against 1,467 ms without it — inside the noise between my two warm runs. Cold with the flag: 36.2 seconds against 37.3 and 34.6 without it. Also noise.
That makes sense once you think about it. --prefer-offline tells npm to skip the network when the cache has what it needs — but on a cold cache, the cache has nothing, so npm goes to the network anyway. It's a "don't re-download what you already have" flag, not a "don't download" flag. On a warm cache, npm was already barely touching the network. The flag's real home is a flaky hotel wifi or a metered connection, not your daily install.
The cold-cache cost is almost entirely network. After the cold runs, the npm cache on disk measured 213 MB. The warm runs just unpacked those tarballs into node_modules — 13 MB of files, 1.5 seconds, done. The other 35 seconds is downloading, verifying, and resolving. Which means the fix for slow installs was never a flag. It's keeping the cache warm: don't npm cache clean in CI unless you have a reason, and if your CI wipes its cache every run, you're paying the 36-second tax on every single build for nothing.
The honest caveats
Six runs is a small sample — n=2 for cold, n=2 for warm, n=1 each for the flag variants. I didn't measure network speed, and a shared VM's network isn't your network. Your numbers will differ, probably by seconds on the cold runs and by a few hundred milliseconds on the warm ones.
But the shape of the result won't change, because the mechanism is structural: cold installs download ~200 MB of tarballs, warm installs unpack what's already on disk. No flag changes that ratio. If anything, a bigger real project makes the gap wider — this one is only 87 packages, and plenty of production apps resolve to ten times that.
The takeaway I actually kept: before reaching for a faster install tool, check whether your cache survives between runs. On this machine, the difference between "cache intact" and "cache wiped" was 1.5 seconds versus 36. That's the whole performance story, and it costs nothing to fix.
All timings measured by me on November 10, 2026, on the machine described above. Raw per-run output is in my notes; the table above is the complete set of runs, nothing omitted.
