wandres.dev
PACKAGE MANAGERS · npm, yarn, pnpm

El problema de node_modules: duplicación, fantasmas y determinismo

Las tres patologías estructurales del árbol plano: la duplicación en disco y los doppelgängers que hinchan node_modules, la dependencia fantasma que compila hasta que deja de hacerlo, y el no determinismo que el lockfile solo mitiga a medias. No son bugs de npm, son consecuencias emergentes de un espacio de nombres plano y compartido: la motivación exacta de todo lo que pnpm hace distinto.

⏱ 16 min

Se dice que node_modules es el objeto más pesado del universo, y el chiste esconde un diagnóstico incompleto: el peso es solo el síntoma visible. Debajo hay tres patologías más profundas y entrelazadas —la duplicación en disco, la dependencia fantasma y el no determinismo— que no son fallos de npm sino consecuencias lógicas del diseño del árbol plano de la lección anterior. Nombrarlas con precisión no es un ejercicio de queja: es entender el problema que pnpm, yarn y todo el toolchain moderno intentan resolver, cada uno a su manera.

🎯 Al terminar esta lección sabrás
  • Cuantificar la duplicación en disco y el fenómeno de los doppelgängers.
  • Definir la dependencia fantasma y por qué compila hasta que deja de hacerlo.
  • Entender el determinismo, el papel del lockfile y por qué reproducible no es correcto.
  • Ver las tres patologías como propiedades emergentes del diseño, no como bugs.

La duplicación: doppelgängers y el peso

El hoisting deduplica el caso fácil —una versión compartida se iza una vez—, pero se rinde ante el conflicto. Cuando dos subárboles necesitan versiones incompatibles del mismo paquete, npm no puede izar las dos al tope, así que una gana el puesto y las demás se quedan anidadas en copias separadas. El resultado son los doppelgängers: múltiples copias físicas de paquetes por todo el árbol, a veces incluso de la misma versión, porque no siempre existe un único lugar donde izarla sin romper a alguien.

node_modules/
  a/node_modules/lodash/   # lodash 4.17.21, copia 1
  b/node_modules/lodash/   # lodash 4.17.21, copia 2 identica, otro inodo

A esto se suma que cada proyecto de tu máquina tiene su propio node_modules completo: no hay nada compartido entre ellos. Diez proyectos que usan React arrastran diez copias íntegras de React y su grafo. La aritmética explica los gigabytes: la duplicación ocurre dentro de cada árbol por los conflictos de versión, y entre árboles porque no existe un almacén común. El peso no es descuido, es el precio de un modelo donde cada instalación es una isla.

du -sh node_modules   # el peso, a menudo cientos de MB por proyecto
npm dedupe            # reacomoda el arbol para colapsar copias

npm dedupe reordena el árbol para fundir copias que puedan compartir una versión, pero es un parche sobre el modelo, no una cura: no puede unificar versiones incompatibles ni compartir nada entre proyectos, que es justo donde está el grueso del desperdicio. Trata el síntoma dentro de una isla sin cambiar que cada proyecto siga siendo una isla.

La dependencia fantasma

Una dependencia fantasma es un paquete que puedes importar y que resuelve en tiempo de ejecución, pese a que no figura en tu package.json. Funciona por accidente: el paquete llegó a tu árbol como transitiva de otra cosa y el hoisting lo izó al tope, donde el resolvedor de Node lo encuentra igual que si lo hubieras declarado.

// package.json declara solo "express"; nunca declaraste "debug".
// Pero express arrastra debug y npm lo iza al tope de node_modules:
import debug from "debug";   // compila y funciona... hoy

El problema no es que falle, sino cuándo falla. El código anterior funciona hasta el día en que una actualización de tu dependencia directa deja de arrastrar esa transitiva, o la sube a un major con otra API, o migras a un gestor estricto que ya no la iza. Entonces un import que llevaba meses verde revienta sin que nadie tocara tu línea. Es un fallo diferido y silencioso, y encima una brecha de seguridad: ejecutas —y confías en— código que nunca declaraste ni auditaste.

Por qué funciona Por qué se rompe después
El hoisting iza la transitiva al tope Una dep directa deja de arrastrarla
Node resuelve caminando hacia arriba La transitiva sube de major y cambia su API
El árbol plano no distingue directa de transitiva Cambias a pnpm o yarn PnP, que sí distinguen

El mecanismo exacto de por qué resuelve merece verse paso a paso, porque es idéntico al de una dependencia legítima: el algoritmo de Node no tiene forma de saber que debug no lo declaraste. Solo camina hacia arriba y lo encuentra donde el hoisting lo dejó.

flowchart TD
code[tu codigo import debug] --> r1[busca en node_modules del archivo]
r1 --> r2[sube a node_modules del proyecto]
r2 --> found[encuentra debug izado al tope]
found --> ok[resuelve, aunque no lo declaraste]
style found fill:#f9e2af,color:#11111b
style ok fill:#f38ba8,color:#11111b

El determinismo: reproducible no es lo mismo que correcto

Rangos semver más el reloj del registro dan no determinismo (nivel 6). El lockfilepackage-lock.json desde npm 5, en 2017— parchea esto congelando el grafo resuelto exacto: versiones, procedencia e integridad de cada nodo. Con él, npm ci reproduce el árbol byte a byte en cualquier máquina, y npm install lo prefiere mientras el package.json siga siendo compatible.

npm install    # puede actualizar el lockfile si package.json cambio
npm ci         # instala EXACTO lo del lockfile o falla; el modo de CI

Pero hay que separar dos propiedades que suenan parecidas y no lo son. La reproducibilidad garantiza que el mismo lockfile produzca el mismo árbol; la corrección garantizaría que ese árbol respeta los contratos declarados. El lockfile te da lo primero y no lo segundo: congela fielmente un árbol plano que sigue exponiendo fantasmas. Reproduces con exactitud un estado que ya venía contaminado. El determinismo es un suelo imprescindible, no un techo; resuelve la deriva temporal, pero no cura la fuga estructural del layout.

⚠️
Un lockfile no te protege de las fantasmas

Es un error común creer que “tengo lockfile, luego mis dependencias están bajo control”. El lockfile fija qué versiones hay y dónde, pero no cambia las reglas de visibilidad: si el árbol es plano, toda transitiva izada sigue siendo importable. Puedes tener una reproducibilidad perfecta y, aun así, un import "debug" fantasma esperando a romperse en la próxima actualización. La reproducibilidad y la estrictitud son ejes distintos; necesitas los dos, y el lockfile solo te da uno.

La dimensión de seguridad

Las fantasmas no son solo una bomba de relojería funcional: son un agujero en la superficie de auditoría. Cuando importas un paquete que no declaraste, ejecutas código que nunca decidiste incorporar y que probablemente ni figura en tu revisión de dependencias. En un ecosistema donde un proyecto medio arrastra cientos de transitivas, la distancia entre “lo que declaré” y “lo que ejecuto” es precisamente el terreno donde crecen los ataques de cadena de suministro: una transitiva comprometida —por typosquatting, por una cuenta de mantenedor secuestrada o por una versión maliciosa publicada horas después de un compromiso— entra en tu proceso sin que tú la hayas nombrado jamás.

El árbol plano agrava el problema por partida doble. Difumina la frontera entre lo directo y lo transitivo, que es justo la frontera que deberías vigilar, y al deduplicar por izado hace que un solo paquete comprometido en el tope quede visible para todo el árbol. Herramientas como npm audit escanean el grafo contra bases de vulnerabilidades conocidas, pero llegan tarde por definición: solo conocen lo ya reportado. La defensa estructural —saber con exactitud qué es directo y qué es transitivo, e impedir que lo segundo se cuele como lo primero— es una propiedad del layout, no de un escáner, y es otra de las cosas que el diseño de pnpm te da de serie.

🗄️

Duplicación

Conflictos de versión anidan copias dentro del árbol, y la ausencia de almacén común las multiplica entre proyectos. De ahí los gigabytes.

👻

Fantasma

Importas lo que no declaraste porque el hoisting lo izó. Compila hoy y revienta el día que la transitiva cambie o desaparezca.

🎲

No determinismo

Rangos más reloj dan árboles distintos. El lockfile lo congela, pero congela un árbol que sigue siendo permeable a fantasmas.

Estas patologías son emergentes: la cura es arquitectónica, no un parche

Lo revelador de las tres patologías es que ninguna es un bug que alguien pueda arreglar con una corrección puntual: son propiedades emergentes de una decisión de diseño, la de usar un espacio de nombres plano, mutable y compartido como layout de instalación. La duplicación emerge de que no hay una única fuente de contenido; la fantasma, de que la planitud borra la distinción entre lo declarado y lo arrastrado; el no determinismo, de que el orden de izado importa. Cuando los problemas de un sistema son emergentes de su estructura, no se resuelven parcheando el comportamiento —añadir un lockfile, avisar de duplicados, escribir un linter que cace importaciones no declaradas—, sino cambiando la estructura que los genera. Por eso la respuesta seria no fue una bandera más en npm, sino un rediseño del layout: un almacén direccionable por contenido que elimina la duplicación por construcción, y un árbol de symlinks estricto que hace la fantasma literalmente irrepresentable, porque lo no declarado deja de estar en un lugar donde el resolvedor pueda encontrarlo. Esa es la tesis de pnpm, y es la próxima lección. La enseñanza transferible, válida mucho más allá de los package managers, es diagnóstica: cuando un sistema produce una y otra vez la misma clase de fallo por vías distintas, deja de tratar los síntomas y pregunta qué invariante estructural falta. Los parches gestionan el problema; el rediseño lo disuelve.

⚔️ Caza tus propias patologías
  1. En un proyecto real, ejecuta du -sh node_modules y anota el tamaño; luego busca dos copias de un mismo paquete en distintas rutas (un doppelgänger).
  2. Encuentra un import en tu código de un paquete que no esté en tus dependencies ni devDependencies: acabas de identificar una fantasma.
  3. Explica, para esa fantasma, cuál de los tres disparadores de la tabla la haría fallar y cuándo.
  4. Argumenta por qué tener package-lock.json no elimina esa fantasma, distinguiendo reproducibilidad de corrección.
  5. Predice qué pasaría con ese import si migraras el proyecto a un gestor de árbol estricto.