Software Engineering · Dependencies

When One Graph Contains Several Copies of the Same Package

How flat installation trees force duplicate versions into a single directory, what breaks when two copies coexist, and how hoisting decides which copy a require call finds.

Semantic versioning is meant to make this situation avoidable: two parts of a program needing the same library at compatible versions, with the publisher announcing which releases stay compatible. It does not make the situation impossible, because the compatible choice is not always available. When liba requires core@^1 and libb requires core@^2, and no version of core satisfies both, the resolver must either refuse the graph or install both. Most ecosystems install both, and the way they arrange the two copies in the filesystem determines what the program does at runtime.

The mechanism is worth understanding precisely because its failures are subtle. A duplicated package is not a correctness problem in a language where modules are values passed explicitly. It becomes one wherever module identity carries state, and it becomes an operational problem wherever the duplication is invisible.

Two layouts, two meanings

The classical layout nests dependencies inside their dependents: liba/node_modules/core and libb/node_modules/core exist as independent directories, and each dependent resolves its own copy by looking first in its own directory. Sharing happens only when the versions happen to match and the installer chooses to hoist.

The flat layout, adopted by npm and imitated elsewhere, installs as much as possible into a single top-level directory and nests only where a conflict forces it. The motivation is path length and deduplication: on Windows the historical MAX_PATH limit of 260 characters makes deeply nested node_modules trees fail to extract, and every duplicated copy costs disk and install time. Flattening solves both, and it creates the resolution question that nesting did not have.

Under a flat layout the filesystem is not a tree of dependencies; it is a set of directories whose meaning depends on lookup order. Node’s resolution algorithm walks up from the requiring file: for a module at app/liba/index.js requiring core, it checks app/liba/node_modules/core, then app/node_modules/core, then parent directories, then the global paths. The first match wins, and nothing in the lookup verifies that the found copy is the version the requiring package asked for.

That is the load-bearing detail. The installer’s job is to place copies such that every package’s upward search lands on a version satisfying its own requirement. Hoisting is the strategy for doing so with as few directories as possible.

How hoisting picks a location

The installer walks the resolved graph and, for each package, tries to place it at the shallowest location reachable from every dependent that needs it. If the top-level directory is empty for that name, the package goes there. If the top-level directory already holds a different version, the installer must nest: it places the new copy inside the dependent’s own directory, where the upward search will find it before reaching the conflicting top-level one.

The choice of which version gets the top-level slot is a policy, not a specification. Installers commonly prefer the version required by the most dependents, or the highest version, or simply the first encountered in traversal order. Whichever is chosen, the other versions are nested, and the resulting tree is determined by traversal order in a way that a diff will show.

// Resolution as Node performs it: the first existing directory wins,
// regardless of what the requiring package's manifest says.
const fs = require('node:fs');
const path = require('node:path');

function resolveFrom(startDir, name) {
  let dir = startDir;
  while (true) {
    const candidate = path.join(dir, 'node_modules', name);
    if (fs.existsSync(candidate)) return candidate;
    const parent = path.dirname(dir);
    if (parent === dir) return null;      // reached the filesystem root
    dir = parent;
  }
}

Two consequences follow directly. First, adding a dependency can change which copy an existing package resolves, if the new dependency occupies the top-level slot that the existing package was previously reaching. A lockfile regeneration that hoists differently can therefore alter runtime behaviour without changing any version number. Second, removing a dependency can leave a package resolving to a nested copy it was never meant to use, or to nothing at all, if the copy that satisfied it was a sibling’s nested directory that got pruned.

Both are symptoms of the same underlying property: in a flat layout, the dependency edge is not represented anywhere in the installed tree. The tree records only that a directory exists at a path. Which package depends on it, at what range, and whether the range is satisfied, all live in the lockfile rather than in the layout.

Duplicate instances and singleton state

When a package is duplicated, two copies of its module code exist, and the language runtime treats them as distinct modules with distinct exports. For a stateless utility library this is invisible. For anything holding module-level state, it is not.

The clearest case is a registry of instances, a cache, or a configuration object created at module load. If liba and libb each get their own core, and core maintains a module-level map, the map exists twice. Objects created through one copy are not recognised by the other, instanceof checks across the boundary fail, and a singleton intended to be process-wide is instantiated per copy.

Dependency injection frameworks, ORMs with a global connection pool, and validation libraries that register schema types all rely on module-level identity. A duplicated copy silently produces two registries, and the failure appears as a schema that is “not registered” or a connection that “was never opened” even though the code paths look correct.

The second case is class identity across a boundary. A value produced by copy A and passed to copy B fails an instanceof test against B’s class, because the two classes are different objects. Libraries that check their arguments with instanceof reject valid values, and the error message names a type the caller is demonstrably holding.

Ecosystems with explicit module systems and a single global registry per package name avoid this entirely: the language’s loader keys modules by name, not by filesystem path, so one copy exists process-wide. That is a real advantage of such designs, and it comes at the cost of flexibility — only one version of a package can be loaded in a process at a time.

Detecting and reconciling duplication

Duplication is discoverable by inspection of the installed tree, since each copy occupies its own directory. A directory listing that finds the same package name at multiple depths is the raw signal; matching those copies against the lockfile’s recorded requirements separates forced duplication from accidental.

Accidental duplication arises when a range could have been satisfied by an existing copy but the installer chose not to share. Common causes: a dependent’s range excludes the hoisted version even though a nested one would satisfy it, an installer that runs on a subset of the graph and then a second install that adds packages without reconciling the first, or a workspace layout where each workspace installs independently.

Forced duplication is a genuine constraint conflict, and the resolution options are limited. One is to make the ranges compatible upstream — a change to the publishers, not to the consumer. Another is to select a version of the intermediate package that does not straddle the conflict, which peer and major-version constraints make hard to arrange. A third is to accept the duplication and eliminate the shared state, by passing the registry explicitly rather than relying on module-level identity.

The third option is a design change in the dependent code, and it is the one that generalises. Code that receives its collaborators as arguments does not care how many copies of the collaborator’s module exist, because it never compares identity across a boundary it did not create.

Cost is not only disk

Duplication has three costs, only the first of which is obvious.

Disk and transfer: each copy is a full artifact, so a package duplicated across five dependents is fetched and stored five times unless the installer deduplicates by content hash. Content-addressed stores and hard links reduce the storage cost to the unique-bytes cost, but the installed tree still contains five directories.

Startup and memory: each copy is parsed and evaluated separately at load time. For a package that initialises a large table or compiles a schema at module load, the work is repeated per copy. This is measurable in cold-start latency for serverless environments, where every copy’s initialisation is on the critical path.

Resolution and review: a tree with many nested copies is harder to audit. The set of versions actually loaded is a function of lookup order, and determining it requires simulating the algorithm rather than reading a list.

Bundlers change the question

A bundler that traces imports statically can collapse duplicates: if two dependents import the same package at different versions, the bundler may include both copies, or may include one and rewrite the imports, depending on configuration. Including both reproduces the runtime situation described above, with the same singleton hazard, because the bundled module registry keys by resolved path.

The difference is that bundling makes duplication visible in the artifact. Two copies of the same module code appear twice in the output, and a size report shows it. That visibility is the main practical benefit: the duplication was always there, and the bundle makes it reviewable.

Installation layout determines whether duplication exists; bundling determines whether it survives into the artifact. Neither determines whether it is safe. That depends on whether the duplicated module holds state, which is a property of the package’s design rather than of the graph.

Where duplication is unavoidable, the durable answer is to make the shared thing explicit — a single instance created by the application and passed down, rather than one created per module copy. The dependency graph then describes the code structure without dictating the object graph, which is the separation that makes resolution a decision about versions rather than about program semantics.

References

  1. Node.js Modules documentation: Loading from node_modules folders — Node.js project
  2. npm docs: folders and node_modules resolution — npm documentation
  3. npm docs: package-lock.json and the node_modules tree — npm documentation