Build Stack Review

Dependency Hoisting Side Effects in JavaScript Monorepos

Phantom dependencies and version splits in monorepos hide failures until deployment reveals them.

· 8 min read
Cover illustration for “Dependency Hoisting Side Effects in JavaScript Monorepos”
Package Manager Mechanics · October 17, 2026 · 8 min read · 1,837 words

Dependency hoisting in JavaScript monorepos creates structural risks as well as inconvenient install behavior. Phantom dependencies, doppelgänger version splits, and local-to-CI drift come from where shared packages are placed and how they are resolved across workspaces. To control those risks, teams need to understand the resolution graph first, then use dependency isolation, scanning, and reproducible environments for the failures each can fix.

How hoisting algorithms create phantom dependencies and doppelgängers

Hoisting places shared dependencies higher in the directory tree so that multiple workspaces can resolve them. That placement can make a package available to code that never declared it in its own package.json. This is a phantom dependency, which resolves because the package is visible in the root node_modules.

Version conflicts produce a different structural effect. When two workspaces need incompatible versions of the same package, the algorithm can hoist one and nest the other, leaving two live instances at runtime. This is the doppelgänger problem. A package that calls useState can use a different React instance from the host application, which can cause a cryptic runtime error even though there was no install-time warning.

Both failure modes can remain silent in npm and Yarn until execution exposes them. A bundler that does not understand hoisted modules, or code using __dirname-relative paths, can reveal assumptions that an otherwise successful installation concealed. Strict dependency resolution, including pnpm's strict mode, changes this layout to limit what a package can import.

Where phantom dependencies and doppelgängers actually break things

A team that publishes a shared package may find that its workspace tests passed in the wrong environment. Inside the monorepo, the package can reach dependencies hoisted by other workspaces. Once published under node_modules/foo, those undeclared dependencies may no longer be available. The package has crossed a deployment boundary that its development tests did not reproduce.

Even work that stays inside the repository can disturb another workspace. In a Yarn 1 monorepo, adding @testing-library/react to one package caused tests in an unrelated package to fail. Yarn hoisted the version used by the majority of packages and created a separate nested node_modules for the minority version. The team was adding test support, but the resulting placement changed which React instance each workspace could see.

Deployment crosses another boundary because a fresh CI installation can build a workspace graph that differs from the one used in local development. Hoisting can depend on install order, existing lockfile state, and which workspaces are present. Publishing automation adds related hazards when it releases only part of a set of interdependent changes or exposes peer dependency combinations that were never tested together. Conversely, automation that bumps multiple packages unnecessarily produces version noise that obscures what changed. In each case, a workflow intended to share or release code loses the relationship between a package and the conditions under which it was tested.

Why hoisting makes security scanning unreliable without a deliberate strategy

A workspace manifest describes what that workspace declares, but hoisting means it does not necessarily describe everything the workspace runs. Scanning only the workspace folder may miss the installed version, while scanning only root files loses the context needed to assign remediation.

The doppelgänger problem also changes the scope of a fix. If one vulnerable version is hoisted and shared, correcting it can remove the vulnerability across many packages at once. If workspaces pin incompatible versions, several vulnerable copies can coexist and each must be patched.

Teams need root lockfile scans to find what is installed and workspace manifest scans to find who owns and uses it. A monorepo with four packages can scan everything on every pull request, whereas one with two hundred projects cannot use that as its general strategy. Scheduled full scans catch newly disclosed vulnerabilities in packages that have not changed, and scheduled aggregate SBOM generation gives teams a view of the fleet and a record for audits.

What the SBOM format choice means when your dependency graph is hoisted

A correct SBOM must preserve both the resolved graph and the workspace relationships that explain it. Format choice then determines how security, legal, and other consumers can use that information. As of 2026, the two dominant formats are CycloneDX from OWASP, now at version 1.7, and SPDX from the Linux Foundation, now at version 3.0.1.

CycloneDX 1.6 is preferred for security use cases and is a default for enterprise security teams in 2026. SPDX is preferred for license compliance and supply chain integrity, including by enterprise legal teams and customers adjacent to federal cloud security authorization programs. US government SBOM minimum elements guidance references SPDX, CycloneDX, and SWID without preferring one or specifying an SPDX version. The practical answer in 2026 is to emit both CycloneDX and SPDX because security and legal teams often run separate toolchains against the same artifact.

Whichever format is emitted, its value depends on component origins, versions, and relationships at the workspace level. ENISA's SBOM implementation guide identifies transparency, traceability, compliance, and security as core capabilities, all of which require more than a flat package inventory. A root-only SBOM repeats the ownership defect of a root-only scan. Trivy from Aqua combines SBOM generation and vulnerability scanning in one pass for teams that want both outputs without adding a separate scanning step to CI.

How environment drift from hoisting surfaces in CI and what it costs

CI makes hoisting costs visible because the same code can pass locally and fail remotely without an explanatory change in the diff. The failure appears non-deterministic to the team even when it follows from a different resolution graph. In the Next.js 16 and Turbopack case, the conflict manifests when the bundler resolves modules during next dev or next build, after remaining silent during pnpm install. An install-time benchmark therefore says little about the hours of CI debugging that can follow.

The documented fix uses pnpm.overrides to force a single React version across the monorepo. It addresses the doppelgänger at its source rather than trying to repair individual nested installations. The Railway deployment case exposes a separate cause of divergence because Railway runs the build from the service subdirectory, a different location from the monorepo root. A root .npmrc setting of shamefully-hoist=true applies when pnpm installs from the root but does not necessarily apply from a subdirectory. Developers who always run from the root can therefore miss the configuration boundary that CI crosses.

Debugging consumes hours, release trains break, and local success gives false confidence about deployment readiness. Affected-only builds also depend on an accurate project graph, so a hoisting-induced resolution change reaching an unrelated package can force a full rebuild and erase the cache advantage. Stronger isolation can also show where the ecosystem falls short: Expo SDK 54 supports isolated dependencies, but some React Native libraries still have build or resolution errors when they use them.

How pnpm's strict mode and the workspace protocol reduce hoisting exposure

pnpm's strict mode changes dependency access through its content-addressable store and symlinked layout. Files in node_modules are hard links to the store, while symlinks shape which dependencies a project can reach. pnpm's documentation distinguishes this layout from root-level hoisting by describing direct dependencies at the root of the modules directory and default internal hoisting into node_modules/.pnpm/node_modules. Strict configuration matters because public hoisting can reopen access that isolation is intended to restrict.

Internal package references need a separate safeguard. The workspace: protocol tells pnpm to resolve a sibling from the workspace rather than the registry, and publication replaces workspace:* with the actual version number. npm does not support the workspace: protocol, while Yarn Berry's PnP mode provides strict dependency resolution by removing node_modules entirely.

Compatibility can nevertheless require an escape hatch. For build tooling such as eslint, prettier, and typescript, the Railway fix recommends replacing global shamefully-hoist=true with granular hoist-pattern[] entries. Selective hoisting through hoist-pattern[] or public-hoist-pattern[] is still easy to break, especially when stacks with many native dependencies need a growing list of patterns. Expo's documented fallback is a hoisted linker when libraries cannot work with isolated dependencies, accepting greater phantom dependency exposure to unblock the build. These choices limit exposure where they can, while pnpm.overrides fixes version conflicts so teams do not have to manage nested node_modules by hand.

What reproducible environment tooling adds that package manager configuration alone cannot

Package manager settings control dependency placement within an installation, but they do not define the whole environment performing that installation. A lockfile does not record the Node version, native build tools, or shell environment. Consequently, identical .npmrc and package.json settings do not make a developer machine equivalent to a CI runner on a different OS or architecture. The missing boundary is the host toolchain.

Onboarding makes that gap concrete. A developer can clone the repository and run pnpm install without reproducing the conditions under which CI resolves and builds the project. Traditional local setups can take hours because the host environment is not part of the reproducible artifact. A declarative, versioned, portable environment definition captures this layer so that developer machines, CI runners, and deployment targets can share it. This complements package manager configuration rather than replacing dependency isolation or version alignment.

Capturing the full dependency graph, including the toolchain, in a declarative manifest supports a deterministic SBOM that reflects what actually ran. That is a stronger account than an inventory derived only from declarations in package.json. It also enables a shift from post-hoc log scanning to hash-level CVE blocking.

A practical decision sequence for teams dealing with hoisting side effects now

First, diagnose the failure before changing the installation strategy. A package that works in the workspace but fails after standalone publication points to a phantom dependency. Runtime errors involving hooks, singletons, or event emitters suggest a doppelgänger, especially when framework versions coexist. A fresh CI failure without a relevant code change points to environment or resolution divergence. When a workspace manifest and the root lockfile give different vulnerability results, details about what is installed or who owns it are missing.

For phantom dependencies, use pnpm strict mode or Yarn Berry PnP, with workspace: references for internal packages where supported. For doppelgängers, use pnpm.overrides or Yarn's resolutions to make the packages use the same version. For configuration divergence, run CI from the monorepo root and replace global hoisting with targeted build-tool patterns. Where React Native requires extensive exceptions, full hoisting through node-linker=hoisted with explicit build.gradle path corrections is more stable than maintaining an exhaustive selective-hoisting list, though it retains the exposure.

Then repair scanning before the next CVE disclosure. Combine root lockfile and workspace manifest scans, schedule full scans for unchanged packages, and generate aggregate SBOMs. Emit both CycloneDX 1.7 and SPDX 3.0 for the separate security and legal toolchains. This follows dependency diagnosis because remediation needs both the installed versions and the workspaces responsible for them.

Finally, address toolchain drift separately from npm dependency management. Declarative environment tooling captures the Node version, native build tools, and OS-level libraries that a lockfile does not control. That investment comes after identifying package-level failures so that environment work does not substitute for correcting the dependency graph.

Sources

  1. Work with monorepos - Expo documentation

More in Package Manager Mechanics