How many times have you run npm install without giving a single thought to what happens after you hit Enter?
You type the command, grab your coffee, scroll through social media, and come back hoping everything installed successfully. If it works, great. If it doesn't, you delete node_modules, run npm install again, and pray to the JavaScript gods. š
But what actually happened between pressing Enter and seeing that little progress bar finish?
Where did the packages come from? How did npm know which versions to download? What's the deal with package.json, package-lock.json, and that ridiculously large node_modules folder? And why does npm sometimes break your entire project when you thought you were just installing a package?
I ask because we're in an era where there's an endless supply of tech content telling us what to build, what tools to use, and what skills are trending. But somewhere along the way, understanding the fundamentals seems to have become optional.
So, let's slow down for a moment. No AI-generated hand-waving, no unnecessary jargon, and no pretending we understand something because we've used it a hundred times.
Let's pop the hood and find out what actually happens when you run npm install.
What happens?
When you run npm install, npm does roughly four things:
-
Reads your
package.jsonandpackage-lock.json -
Resolves a full tree of every package you need, including dependencies of dependencies
-
Fetches those packages from the registry (or its local cache)
-
Writes them to disk in
node_modules, then runs any install scripts
Letās take each in turn.
1: Read what you asked for
Your package.json declares intent, not exact versions:
{
"dependencies": {
"express": "^4.18.2",
"lodash": "~4.17.21"
}
}
Those symbols are semver ranges (semantic versioning: MAJOR.MINOR.PATCH):
-
^4.18.2means any version>=4.18.2and<5.0.0. Minor and patch updates are fine. -
~4.17.21means any version>=4.17.21and<4.18.0. Only patch updates are fine. -
4.17.21with no symbol means exactly that version.
So package.json says āsomething like this.ā It does not say āexactly this.ā That gap is why lockfiles exist.
2: Dependency Resolution
This is the brain of the operation. npm needs to turn your loose ranges into a concrete list of exact versions, for your packages and everything underneath them.
express depends on body-parser, which depends on bytes, and so on. Your three direct dependencies can easily become hundreds of installed packages. Internally, npm builds a dependency tree (the logic lives in a library called Arborist), and the process looks like this:
-
Take a dependency and its version range.
-
Ask the registry what versions exist.
-
Pick the highest version that satisfies the range.
-
Look at that packageās own dependencies, and repeat.
If a package-lock.json already exists and still satisfies package.json, npm skips most of this guessing. It just follows the lockfile.
3: The npm Registry
The registry (registry.npmjs.org by default) is just a big HTTP server. Thereās no magic. When npm needs express, it requests the packageās metadata document, called a packument. It contains:
-
Every published version
-
Each versionās dependencies
-
dist-tags like
latestandnext -
For each version, a tarball URL and an integrity hash
npm picks a version from that list, then downloads the .tgz tarball. After downloading, it verifies the file against the integrity hash (a SHA-512 checksum). If the bytes donāt match, the install fails. This guards against corrupted downloads and tampered packages.
Downloads are stored in a local cache (~/.npm/_cacache), so installing the same package version again doesnāt hit the network.
package-lock.json: The Receipt
If package.json is your shopping list, package-lock.json is the receipt. It records exactly what was installed:
"node_modules/express": {
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
"integrity": "sha512-...",
"dependencies": {
"body-parser": "1.20.1"
}
}
For every package in the tree, it stores:
-
the exact version
-
the URL it came from
-
the integrity hash
-
its own dependencies
This gives you reproducibility. Without a lockfile, you and a teammate could run npm install a week apart and get different versions of a sub-dependency, which leads to the classic āworks on my machineā bug. With a lockfile, everyone installs the identical tree.
Two commands people often confuse:
-
npm installuses the lockfile where it can, but may update it ifpackage.jsonhas changed. -
npm cideletesnode_modulesand installs strictly from the lockfile. If the lockfile andpackage.jsondisagree, it errors out. This is what you want in CI/CD pipelines.
Always commit your lockfile.
4: Building node_modules
After resolving and fetching, npm reifies the tree: it extracts the tarballs onto disk. Then it:
-
creates executable links in
node_modules/.bin(this is whynpxand npm scripts can findeslint,vite, etc.) -
runs lifecycle scripts (
preinstall,install,postinstall,prepare) for packages that define them
That last point matters for security. Install scripts are arbitrary code that runs on your machine. Supply-chain attacks often hide there, which is why tools offer ways to restrict or disable them.
Why itās āflatā
Early versions of npm nested dependencies inside each other, which created deeply nested folders and many duplicate copies. Modern npm hoists packages: it moves them as far up as possible so they sit in the top-level node_modules.
node_modules/
āāā express/
āāā body-parser/ ā hoisted, though you never installed it directly
āāā bytes/
Why Dependency Conflicts Happen
Say your project needs two packages:
-
Package A needs
shared-lib@^1.0.0 -
Package B needs
shared-lib@^2.0.0
No single version of shared-lib satisfies both. This is the diamond dependency problem.
For regular dependencies, npm can often solve it by nesting. It puts one version at the top level and another inside the package that needs it:
node_modules/
āāā shared-lib@2.x/
āāā package-a/
ā āāā node_modules/
ā āāā shared-lib@1.x/ ā nested copy
āāā package-b/
Each package gets the version it needs. The cost is duplicate code and bigger installs.
Where it really breaks: peer dependencies
Some packages (plugins, frameworks, UI libraries) say: āI donāt bring my own copy of X. I expect you to provide it, and it must be a compatible version.ā Thatās a peer dependency. A React component library, for example, declares React as a peer dependency because you canāt have two copies of React in one app.
If library one wants react@^17 and library two wants react@^18, nesting wonāt save you, because the whole point of a peer dependency is that there should only be one. You get the infamous:
npm ERR! code ERESOLVE
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^17.0.0" from some-library@2.1.0
npm 7 and later install peer dependencies automatically and fail loudly when they conflict. Older versions (npm 6 and earlier) only warned, which is why old projects āworkedā until upgraded.
The tempting, dangerous fixes
-
--legacy-peer-depsignores peer dependency checks (the old npm 6 behavior). -
--forcepushes through the conflict anyway.
Both make the error go away. Neither makes the conflict go away. You may ship an app with two incompatible versions fighting each other at runtime.
Better options:
-
Upgrade or downgrade one of the conflicting packages so the ranges overlap.
-
Check whether a newer version of the offending library supports your version.
-
Use the
overridesfield inpackage.jsonto pin a version across the tree. Do this only when you understand the risk.
How Yarn and the Other Tools Work
Everything above describes npm, but the others share the same skeleton: read manifest ā resolve ā fetch ā link into place. The difference is in the details, and those details are what make them fast, strict, or different.
Yarn Classic (v1)
Yarn arrived in 2016 to fix real npm problems of the time: slow sequential installs and non-deterministic results. It introduced:
-
yarn.lock, the lockfile idea (npm later adopted its own) -
parallel downloads
-
an offline cache
It still produces a flat, hoisted node_modules, so it behaves a lot like npm. npm has since caught up on most of these points, so the gap between them is much smaller today.
Yarn Berry (v2+) and PlugānāPlay
Modern Yarn took a more radical turn. With PlugānāPlay (PnP), Yarn doesnāt create a node_modules folder at all. Instead:
-
Packages stay as zipped archives in a cache.
-
Yarn generates a single map file (
.pnp.cjs) saying āpackage X, version Y lives here.ā -
Nodeās module resolution is patched to consult that map.
The benefits are much faster installs and strictness: you canāt import a package you didnāt declare, so no phantom dependencies. The trade-off is that some tools in the ecosystem assume node_modules exists and need workarounds. Yarn can also fall back to a regular node_modules layout through its nodeLinker setting.
pnpm
pnpm tackles the disk-space and strictness problems in a different way:
-
All packages are stored once in a global, content-addressable store on your machine.
-
Each projectās
node_modulesis built from hard links to that store, so ten projects using the same version oflodashshare one copy on disk. -
Inside
node_modules, it uses symlinks to build a nested structure where each package can only see what it actually declared.
The result is fast, space-efficient installs with no phantom dependencies by default. Itās also popular in monorepos for these reasons.
Bun
Bun is a JavaScript runtime with a built-in package manager written in a low-level language (Zig). It follows the same resolve/fetch/link flow but is aggressively optimized: fast parallel network work, a global cache, and efficient file-copying techniques. Its lockfile has changed format over time, so check the current docs for the details. The headline is speed, and itās mostly compatible with npmās registry and package.json.
Quick Comparison
| Tool | Lockfile | node_modules layout | Standout idea |
|---|---|---|---|
| npm | package-lock.json | Flat, hoisted | The default, ships with Node |
| Yarn Classic | yarn.lock | Flat, hoisted | Parallel installs, determinism |
| Yarn Berry | yarn.lock | None (PnP) or configurable | Zip cache + resolution map |
| pnpm | pnpm-lock.yaml | Symlinked, strict | Global store + hard links |
| Bun | bun.lock | Flat, hoisted | Raw speed |
All of them pull from the same npm registry by default. The registry is the shared foundation, and the tools differ in how they resolve, store, and link what they download.
Takeaways
-
package.jsonstates what you want.package-lock.jsonrecords exactly what you got. -
Resolution is a tree-walk that picks the highest version satisfying each semver range.
-
The registry is an HTTP server of metadata and tarballs, verified by integrity hashes.
-
node_modulesis flat for efficiency, which causes phantom dependencies. -
Conflicts come from incompatible version requirements, and peer dependencies are the ones that canāt be nested away.
-
--forceand--legacy-peer-depssilence errors without fixing them. -
Yarn, pnpm and Bun share the same pipeline but make different trade-offs between speed, disk space and strictness.
Once you understand this, errors stop being random red text and start being readable. Thatās the difference between running commands and knowing what your tools are doing.