What Actually Happens When You Run npm install
You type npm install and your dependencies appear in node_modules. But behind that single line is a complex dependency resolution algorithm, a filesystem layout engine, and a lockfile writer — all working together to produce a deterministic tree that your application can rely on.
Most developers treat npm install as a black box: it just works. But understanding what actually happens helps you diagnose resolution conflicts, avoid phantom dependencies, and write more reliable package.json files.
The Big Picture
npm install is not a single operation. It is a pipeline that:
-
Finds the project root and reads
package.json(and optionallypackage-lock.json) - Builds a dependency graph by recursively reading package metadata
- Resolves version ranges into concrete versions
- Fetches package tarballs from the registry or cache
-
Reifies the tree into
node_modulesusing a hoisting/dedup strategy -
Runs lifecycle scripts (
preinstall,install,postinstall) - Writes the lockfile to lock the resolved tree
Each step can introduce subtle behaviors that catch you off guard if you don't know what to expect.
Phase 1: Project Root and Dependency Reading
npm install starts by walking upward from your current directory looking for a package.json or node_modules directory. It uses the closest suitable package root as the project root.
Once found, it reads:
-
dependencies— installed as production dependencies -
devDependencies— installed when not in production (NODE_ENVset) -
optionalDependencies— installed but don't fail the build if missing -
peerDependencies— expected to be installed by the consumer; npm may warn but won't install them automatically (unless--legacy-peer-depsis set) -
overrides— force specific versions of transitive dependencies
Phase 2: Dependency Graph Resolution
npm builds a complete tree of all required packages, including transitive dependencies. This is where the resolver walks the graph:
- If
package-lock.jsonexists, it's treated as the absolute source of truth, ensuring deterministic installs across environments - If no lockfile exists, npm resolves version ranges (
^,~, etc.) against registry metadata and generates a new lockfile
The resolver also handles deduplication: when multiple branches of the dependency tree request the same package version, npm places a single copy at the highest possible level. However, genuinely incompatible versions (e.g., one package requires lodash@^1 and another requires lodash@^2) result in multiple copies.
Phase 3: The Three Install Strategies
The install-strategy determines where packages go in node_modules. The default is "hoisted":
| Strategy | What Happens |
|---|---|
| hoisted (default) | Non-duplicated packages are installed at the top level of node_modules. Duplicates as needed within the directory structure. Best for sharing common dependencies across many packages. |
nested (--legacy-bundling) |
Packages installed in place, no hoisting. Creates deep directory structures and duplicate installs since there's no dedup. Formerly --global-style. |
shallow (--install-strategy=shallow) |
Only direct dependencies at the top level; deeper dependencies are not hoisted. |
| linked | Packages installed in node_modules/.store and linked into place. Useful for development workflows. |
The hoisting strategy is what causes the "phantom dependency" failure mode: a package can sometimes import another package it never declared, because that package happens to be present at the root of node_modules from another dependency.
Phase 4: Fetching and Caching
Before making network requests, npm checks its local cache for every package in the tree. If a specific version exists in the cache (from a previous install or npm ci), it's used instantly without touching the registry.
For packages not in the cache, npm talks to the configured registry, retrieves package metadata (version records, distribution tags, tarball URLs, integrity hashes), selects a version, and downloads the tarball.
The integrity hash verifies that the downloaded tarball matches the expected hash. It does not prove the publisher was trustworthy or that the package is safe — a malicious package can have a perfectly valid integrity hash.
Phase 5: Reification into node_modules
This is the process of turning the logical dependency graph into the physical filesystem tree. The default strategy is hoisted:
- npm tries to place each dependency as high in the
node_modulestree as possible while keeping dependency ranges valid - The compatible copy is shared at the top level, so more consumers can find it
- Conflicting versions that can't be satisfied by a single copy stay nested below the package that needs them
Deduplication removes duplicates when one version satisfies all relevant ranges. But npm dedupe is sometimes needed after an install to clean up suboptimal placements.
Phase 6: Lifecycle Scripts
These scripts execute with the permissions of the installing user and may:
- Compile native modules (requiring build tools like Python, Make, or Visual Studio)
- Download platform-specific binaries
- Generate files or modify project configuration
- Run arbitrary code
This makes npm install a code-execution boundary, not merely a package extraction step. Be careful about running npm install from untrusted repositories, as the postinstall script could execute arbitrary commands on your system.
Common Failure Modes
Resolution Conflict
Version ranges cannot be satisfied together. For example, if one package requires lodash@^1 and another requires lodash@^2, npm may produce two copies rather than failing. This is valid but suboptimal.
Peer Dependency Conflict
A shared runtime contract is incompatible. Modern npm tries to resolve peer dependencies and can fail with ERESOLVE when constraints can't be satisfied. The --legacy-peer-deps flag bypasses enforcement but converts an explicit error into a potentially delayed runtime error.
Phantom Dependency
Your code imports something it does not declare. This happens because a transitive dependency is present at the root of node_modules from another dependency. The fix is to declare every package your code imports explicitly.
Lockfile Drift
The manifest and the recorded solution disagree. This occurs when an install or runtime issue appears and package-lock.json is absent, stale, or regenerated — a new package release can become eligible even though your application source is unchanged.
Mental Model
Once you see npm install as a resolver plus code-execution pipeline, dependency hell becomes less mysterious. You can inspect the dependency graph, identify the constraint that caused the result, and fix the actual failure instead of hoping that a second install produces a nicer folder.
Key takeaway: Treat package-lock.json as build input, node_modules as generated output, and every install script as executable code.
Quick Checklist
- [ ] Always commit
package-lock.json(orpnpm-lock.yaml/yarn.lock) to ensure reproducible builds - [ ] Run
npm dedupeafter significant dependency changes to clean up suboptimal hoisting - [ ] Use
--package-lock-onlyto update just the lockfile without reinstalling - [ ] Use
npm ciin CI environments for deterministic installs (it fails whenpackage.jsonand lockfile disagree) - [ ] Be aware that
npm installexecutes lifecycle scripts — inspect them if coming from an untrusted source - [ ] Declare every package your code imports to avoid phantom dependency issues
Top comments (0)