What Actually Happens When You Run `npm install`?

dev.to

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.json describes constraints. The lockfile records a solution. node_modules is 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:

  • dependencies
  • devDependencies
  • optionalDependencies
  • peerDependencies
  • overrides
  • 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"}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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":"..."}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then classify the failure:

  1. Resolution conflict: version ranges cannot be satisfied together.
  2. Peer conflict: a shared runtime contract is incompatible.
  3. Phantom dependency: your code imports something it does not declare.
  4. Lifecycle failure: an install script needs a compiler, binary, permission, or network access.
  5. 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:

  1. finds the effective project root
  2. reads dependency constraints
  3. builds a transitive graph
  4. consults or creates a lockfile solution
  5. fetches metadata and tarballs
  6. verifies package integrity
  7. reifies a hoisted or nested filesystem tree
  8. runs lifecycle scripts
  9. writes the resulting state back to disk

The most important operational rule follows from that sequence:

Treat package-lock.json as build input, node_modules as 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.

Sources

Source: dev.to

arrow_back Back to News