DEV Community

Syed Anzar
Syed Anzar

Posted on

What Actually Happens When You Run `npm install`

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:

  1. Finds the project root and reads package.json (and optionally package-lock.json)
  2. Builds a dependency graph by recursively reading package metadata
  3. Resolves version ranges into concrete versions
  4. Fetches package tarballs from the registry or cache
  5. Reifies the tree into node_modules using a hoisting/dedup strategy
  6. Runs lifecycle scripts (preinstall, install, postinstall)
  7. 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_ENV set)
  • 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-deps is 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.json exists, 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:

  1. npm tries to place each dependency as high in the node_modules tree as possible while keeping dependency ranges valid
  2. The compatible copy is shared at the top level, so more consumers can find it
  3. 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 (or pnpm-lock.yaml / yarn.lock) to ensure reproducible builds
  • [ ] Run npm dedupe after significant dependency changes to clean up suboptimal hoisting
  • [ ] Use --package-lock-only to update just the lockfile without reinstalling
  • [ ] Use npm ci in CI environments for deterministic installs (it fails when package.json and lockfile disagree)
  • [ ] Be aware that npm install executes lifecycle scripts — inspect them if coming from an untrusted source
  • [ ] Declare every package your code imports to avoid phantom dependency issues

Top comments (0)