What Actually Happens When You Run npm install?
npm install looks like a file-download command. It is not.
It is a dependency solver, a registry client, a filesystem layout engine, a lockfile writer, and sometimes an arbitrary-code execution pipeline, all behind one short command.
That matters because many “works on my machine” failures are not caused by your application code. They come from npm producing a different dependency graph, exposing a package you never declared, or running an install script you did not inspect.
Here is the useful mental model:
package.jsondescribes constraints. The lockfile records a solution.node_modulesis the solution materialized on disk.
Phase 1: npm finds the project root
npm does not blindly treat the current directory as the project. For a local install, it walks upward from the working directory looking for a package.json or node_modules directory and uses the closest suitable package root.
That is why running a command from a nested workspace directory can still affect the repository root. In a monorepo, this behavior combines with workspace configuration and can change where dependencies are installed or linked.
Then npm reads the relevant manifest fields:
dependenciesdevDependenciesoptionalDependenciespeerDependenciesoverrides- scripts and install configuration
A version such as ^4.18.2 is not a version selection. It is a constraint: a set of versions npm is allowed to consider.
Phase 2: npm builds the dependency graph
Suppose your application declares:
{"dependencies":{"app-server":"^3.0.0","image-tool":"^2.0.0"}}
Those packages have dependencies of their own. npm recursively reads their metadata, then the metadata of their dependencies, until it has a graph containing direct and transitive packages.
The graph may look conceptually like this:
your-app
├── app-server@3.x
│ └── logger@^1.0.0
└── image-tool@2.x
└── logger@^2.0.0
There is no single logger version satisfying both ranges. npm therefore needs two copies, or it must fail if the conflict involves an unsatisfied peer dependency.
This is why a project with 12 direct dependencies can produce hundreds of installed packages. The manifest is short; the graph is not.
Phase 3: the lockfile changes the job
With no lockfile, npm resolves version ranges against registry metadata and chooses concrete versions.
With a compatible package-lock.json, npm uses the versions and dependency edges already recorded there. It may still update the lockfile when the manifest changed, entries are missing, or configuration requires a different tree.
A lockfile entry normally records information such as:
- exact package version
- resolved tarball URL
- integrity hash
- dependency relationships
- package metadata needed to reproduce the tree
The integrity hash is important, but it has a precise purpose. It verifies that the downloaded tarball matches the locked tarball. It does not prove that the publisher was trustworthy or that the package is safe. A malicious package can have a perfectly valid integrity hash.
The practical difference is:
npm install -> may resolve or reconcile the dependency graph
npm ci -> removes node_modules and installs the lockfile's graph
For CI, npm ci is usually the safer default because it fails when package.json and the lockfile disagree instead of silently making a new decision.
Phase 4: npm fetches package metadata and tarballs
For packages that are not already available locally, npm talks to the configured registry. It retrieves package metadata, selects a version, then downloads the package tarball.
The local cache matters here. npm can reuse previously fetched metadata and package content, which is why a second install may be much faster without changing your project.
A registry response is not just “the latest zip.” It contains version records, distribution tags, tarball URLs, and integrity data. The resolver uses that information to turn ranges into exact package versions.
This is also where supply-chain controls begin to matter:
- pin and review lockfile changes
- use a trusted registry or proxy
- inspect unexpected new transitive dependencies
- consider disabling lifecycle scripts in controlled build steps
Phase 5: npm reifies the tree into node_modules
npm calls the process of turning the logical dependency graph into the physical filesystem tree reification.
The default install strategy is hoisted. npm tries to place a dependency as high in the node_modules tree as possible while keeping the dependency ranges valid.
For example:
node_modules/
├── app-server/
├── image-tool/
├── logger@1.x/
└── image-tool/
└── node_modules/
└── logger@2.x/
The compatible copy is shared at the top level. The conflicting version stays nested below the package that needs it.
Hoisting is not the same thing as deduplication:
- Hoisting changes where a package is physically placed so more consumers can find it.
- Deduplication removes a duplicate when one existing version satisfies all relevant ranges.
You can ask npm to reconsider the existing tree with:
npm dedupe
But dedupe cannot merge genuinely incompatible versions. If one package requires logger@^1 and another requires logger@^2, two versions are a valid result, not necessarily an npm bug.
The phantom dependency trap
Hoisting creates a subtle failure mode: a package can sometimes import another package that it never declared.
Your application may contain:
const helper = require("helper");
It works because helper happens to be present at the root of node_modules, perhaps because another dependency installed it. Your own package.json does not declare it.
Then a transitive dependency changes, npm stops hoisting helper, and your application fails with MODULE_NOT_FOUND.
The fix is not “run npm install again.” The fix is to declare every package your code imports:
npm install helper
Strict package layouts, such as pnpm’s isolated symlink structure, expose these mistakes earlier. npm’s hoisted layout is convenient, but it can hide undeclared dependencies until the tree changes.
Peer dependencies are a different constraint
A normal dependency is implementation-owned: the package can use its own compatible copy.
A peer dependency says, in effect:
“I integrate with a package that the consuming application must provide.”
Plugins commonly use peers for frameworks and shared runtimes. Two copies of a stateful runtime can be incorrect even when both versions satisfy their individual package ranges.
Modern npm versions try to resolve peer dependencies and can fail with ERESOLVE when the constraints cannot be satisfied. Flags such as --legacy-peer-deps can bypass enforcement, but that converts an explicit compatibility error into a potentially delayed runtime error.
Use the escape hatch only when you understand which contract you are overriding.
Phase 6: lifecycle scripts execute code
This is the phase developers often mentally omit.
Packages can define lifecycle scripts such as:
{"scripts":{"preinstall":"...","install":"...","postinstall":"..."}}
These scripts may compile native modules, download platform-specific binaries, generate files, or run arbitrary commands with the permissions of the installing user.
That makes npm install a code-execution boundary, not merely a package extraction step.
For a controlled build, you can use:
npm ci --ignore-scripts
But do not apply this mechanically. Some legitimate packages need install scripts to build native bindings or generate required artifacts. The right question is: which scripts are expected, and can they run in a restricted build environment?
Useful inspection commands include:
npm explain some-package
npm ls --all
npm audit signatures
Review scripts in packages that introduce native binaries, unexpected network access, or unusual dependency changes.
Why npm install can change a project without changing your code
A manifest with ranges is intentionally flexible. If the lockfile is absent, stale, or regenerated, a new package release can become eligible even though your application source is unchanged.
A small transitive change can cause:
- a second copy of a library
- a peer dependency conflict
- a CommonJS/ESM compatibility error
- a larger bundle
- a new install script
- a different native binary
- a vulnerability in a newly resolved package
That is why lockfile diffs deserve code-review attention. A large lockfile diff is not “just noise”; it is a record of decisions your build will now make.
A debugging workflow that follows the mechanism
When an install or runtime issue appears, inspect the graph instead of deleting node_modules immediately:
npm ls package-name --all
npm explain package-name
npm config get install-strategy
Then classify the failure:
- Resolution conflict: version ranges cannot be satisfied together.
- Peer conflict: a shared runtime contract is incompatible.
- Phantom dependency: your code imports something it does not declare.
- Lifecycle failure: an install script needs a compiler, binary, permission, or network access.
- Lockfile drift: the manifest and the recorded solution disagree.
Each category has a different fix. Reinstalling everything is often just erasing evidence.
The mental model to keep
When you run npm install, npm does not “install the packages in package.json.” It:
- finds the effective project root
- reads dependency constraints
- builds a transitive graph
- consults or creates a lockfile solution
- fetches metadata and tarballs
- verifies package integrity
- reifies a hoisted or nested filesystem tree
- runs lifecycle scripts
- writes the resulting state back to disk
The most important operational rule follows from that sequence:
Treat
package-lock.jsonas build input,node_modulesas generated output, and every install script as executable code.
Once you see npm install as a resolver plus code-execution pipeline, dependency hell becomes less mysterious. You can inspect the graph, identify the constraint that caused the result, and fix the actual failure instead of hoping that a second install produces a nicer folder.