What Happens When You Run npm install?

Oct 3, 2026

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:

  1. Reads your package.json and package-lock.json

  2. Resolves a full tree of every package you need, including dependencies of dependencies

  3. Fetches those packages from the registry (or its local cache)

  4. 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.2 means any version >=4.18.2 and <5.0.0. Minor and patch updates are fine.

  • ~4.17.21 means any version >=4.17.21 and <4.18.0. Only patch updates are fine.

  • 4.17.21 with 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:

  1. Take a dependency and its version range.

  2. Ask the registry what versions exist.

  3. Pick the highest version that satisfies the range.

  4. 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 latest and next

  • 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 install uses the lockfile where it can, but may update it if package.json has changed.

  • npm ci deletes node_modules and installs strictly from the lockfile. If the lockfile and package.json disagree, 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 why npx and npm scripts can find eslint, 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-deps ignores peer dependency checks (the old npm 6 behavior).

  • --force pushes 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 overrides field in package.json to 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_modules is built from hard links to that store, so ten projects using the same version of lodash share 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

ToolLockfilenode_modules layoutStandout idea
npmpackage-lock.jsonFlat, hoistedThe default, ships with Node
Yarn Classicyarn.lockFlat, hoistedParallel installs, determinism
Yarn Berryyarn.lockNone (PnP) or configurableZip cache + resolution map
pnpmpnpm-lock.yamlSymlinked, strictGlobal store + hard links
Bunbun.lockFlat, hoistedRaw 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.json states what you want. package-lock.json records 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_modules is 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.

  • --force and --legacy-peer-deps silence 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.