Technologies and Software Engineering

NodeJS NPM Phantom Dependencies Understanding and Mitigation

Explore NodeJS and NPMs phantom dependencies caused by node-modules structure. Learn about the issues they create and solutions.

Overview

NodeJS and NPM manage package dependencies by physically representing the dependency graph on disk within node_modules folders. This system, combined with NodeJS’s module resolution algorithm, introduces phantom dependencies: undeclared packages a project implicitly relies upon due to the flattened node_modules structure or ancestral node_modules directories.

Key Insights

  • NPM models package dependencies using physical folder copies on disk, diverging from traditional package managers that use central package stores.
  • NodeJS’s module resolution rules augment the file system’s tree structure, introducing “extra graph edges” that allow modules to be found outside of direct declarations.
  • The installed node_modules tree is not unique and depends on NPM’s installation heuristics, which are sensitive to factors like package addition order.
  • Phantom dependencies lead to difficult-to-diagnose issues such as incompatible versions and missing dependencies for consumers of a published library.
  • In monorepos, root-level node_modules folders can introduce even more insidious phantom dependencies for nested projects.
  • Tools like Rush and PNPM mitigate these problems by enforcing strict dependency declarations, preventing accidental reliance on phantom packages.

Technical Details

Traditional vs. NodeJS Dependency Resolution

Conventional package managers represent package dependencies as a directed acyclic graph (DAG), where a central store often houses packages, and module resolvers traverse this graph. DAGs can feature “diamond dependencies,” where multiple packages depend on a common sub-dependency.

NodeJS and NPM adopt a distinct approach:

  • Physical Representation: NPM models graph vertices as actual package folder copies on disk. Edges are implied by subfolder relationships within node_modules.
  • Resolution Rule: Since file system trees cannot naturally form diamond shapes, NodeJS implements a special resolution rule. This rule introduces extra graph edges by allowing require() calls to probe for modules in the node_modules folders of all parent directories.
  • Phantom Dependencies Origin: These extra edges relax the file system’s tree structure, enabling it to represent some DAGs but also creating paths to packages not explicitly declared by a project. These undeclared yet accessible packages are termed phantom dependencies.

Unique Characteristics of NPM’s node_modules Approach

NPM’s disk-based model presents several unique behaviors:

  • Extensive Duplication: Each project typically receives its own node_modules tree, resulting in numerous package folder copies. Even small NodeJS projects can contain thousands of files in their node_modules directory.
  • Evolution of Installation Algorithms:
    • NPM 2.x: Produced very deep and duplicated node_modules trees, which minimized phantom dependencies.
    • NPM 3.x: Introduced a flattened installation algorithm to reduce duplication. This flattening, however, significantly increased the prevalence of phantom dependencies. The algorithm may also select slightly older package versions (while satisfying SemVer) to further minimize folder duplication.
  • Non-Unique Tree Structure: The installed node_modules tree is not deterministic. Multiple valid arrangements can approximate the dependency DAG. The final structure depends on the package manager’s heuristics, with NPM’s behavior even sensitive to the order in which packages are added to a project.

Consequences: The Problem with Phantom Dependencies

A phantom dependency occurs when a project uses a package not explicitly listed in its package.json file.

Consider this example:

my-library/package.json

{
  "name": "my-library",
  "version": "1.0.0",
  "main": "lib/index.js",
  "dependencies": {
    "minimatch": "^3.0.4"
  },
  "devDependencies": {
    "rimraf": "^2.6.2"
  }
}

my-library/lib/index.js

var minimatch = require('minimatch');
var expand = require('brace-expansion'); // ???
var glob = require('glob'); // ???

// (more code here that uses those libraries)

In this scenario, brace-expansion is a dependency of minimatch, and glob is a dependency of rimraf. During installation, NPM often flattens these into my-library/node_modules. NodeJS’s require() function finds them without consulting package.json files, making it appear to work correctly. However, this is a bug, not a feature, and leads to critical issues:

  • Incompatible Versions: A project implicitly using a phantom dependency (e.g., brace-expansion) lacks control over its version. While SemVer permits a dependency’s patch release to upgrade an indirect dependency’s major version without breaking its own API, this can introduce unexpected breakage for consumers relying on the phantom package’s specific version or behavior. These issues often surface only for downstream users in different node_modules arrangements.
  • Missing Dependencies: If a phantom dependency originates from devDependencies (e.g., glob from rimraf in my-library), it will not be installed for consumers of the published library. While require("glob") should fail, it often doesn’t immediately. Many consumers might coincidentally have glob installed due to other dependencies, making the bug appear as an intermittent, hard-to-reproduce issue for a small percentage of users.

Phantom node_modules Folders in Monorepos

Monorepos introduce another class of phantom dependency problem through ancestral node_modules folders.

Consider a monorepo with a root-level package.json:

my-monorepo/package.json

{
  "name": "my-monorepo",
  "version": "0.0.0",
  "scripts": {
    "deploy-app": "node ./deploy-app.js"
  },
  "devDependencies": {
    "semver": "~5.6.0"
  }
}

This root package.json might include semver as a devDependency for a deploy-app script. The resulting folder structure after npm install could be:

- my-monorepo/
  - package.json
  - node_modules/
    - semver/
    - ...
  - my-library/
    - package.json
    - lib/
      - index.js
    - node_modules/
      - brace-expansion
      - minimatch
      - ...

Due to NodeJS’s parent folder probing, my-library/lib/index.js can successfully execute require("semver"), even though semver is not declared in my-library/package.json nor installed directly under my-library/node_modules. This is an insidious phantom dependency, as my-library implicitly relies on a package installed at a higher level in the file system hierarchy, potentially outside its own declared scope.

Mitigating Phantom Dependencies with Rush

Rush directly addresses phantom dependency issues by implementing a symlinking strategy for project dependencies. This strategy ensures that:

  • Each project’s node_modules directory contains only its declared direct dependencies.
  • This approach immediately catches phantom dependencies at build time, forcing developers to explicitly declare all packages their code uses.

For even stricter control, the PNPM package manager, when used with Rush, extends these protections to all indirect dependencies. PNPM allows for workarounds for “bad” packages via a pnpmfile.js configuration, ensuring comprehensive dependency integrity.

Search

Find technical notes