wandres.dev
OPTIMIZACIÓN DE PRODUCCIÓN · bundle budgets

Vendor splitting y dedupe: dependencias cacheables sin duplicados

Separar las dependencias de terceros en su propio chunk es la jugada de caché más rentable del frontend: cambian despacio, así que tus despliegues diarios no las invalidan. Pero la estrategia se envenena con un enemigo silencioso, la duplicación: dos copias de la misma librería en el grafo por rangos de versión incompatibles. Configurar el vendor splitting en Rolldown, cazar duplicados con pnpm y resolve.dedupe, y entender por qué deduplicar un módulo con estado no es ahorro sino corrección.

⏱ 16 min

Tu código cambia cada día; tu framework, cada pocas semanas. Mezclar ambos en un mismo chunk es tirar caché a la basura: cada corrección de una línea tuya invalida también todos los bytes de las librerías, obligando al usuario a redescargarlas. Separar las dependencias de terceros en un vendor chunk aprovecha su lentitud de cambio para que sobreviva en la caché del navegador a decenas de tus despliegues. Pero esa estrategia tiene un enemigo silencioso que la corroe desde dentro: la duplicación. Cuando la misma librería aparece dos veces en el grafo, no solo pagas sus bytes dos veces —a veces rompes la aplicación—. Este nivel es sobre las dos caras de la misma moneda: separar bien lo cacheable y eliminar lo que sobra.

🎯 Al terminar esta lección sabrás
  • Configurar el vendor splitting en Vite 8 con Rolldown mediante advancedChunks y su forma clásica.
  • Detectar dependencias duplicadas en el grafo con el treemap y con pnpm why.
  • Consolidar versiones con overrides, pnpm dedupe y resolve.dedupe de Vite.
  • Entender por qué deduplicar un módulo con estado es una cuestión de corrección, no solo de peso.

Separar el vendor para la caché

Rolldown extrae código común por defecto, pero el vendor merece una regla explícita porque quieres controlar su granularidad. La API moderna de Vite 8 es advancedChunks, que define grupos por un test sobre la ruta del módulo, con umbrales de tamaño para evitar chunks minúsculos. La forma clásica manualChunks, una función que devuelve el nombre del chunk, sigue soportada por compatibilidad.

// vite.config.ts — vendor subdividido por familia con advancedChunks
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        advancedChunks: {
          minSize: 20_000,
          groups: [
            { name: "vendor-react", test: /node_modules\/(react|react-dom)\// },
            { name: "vendor-charts", test: /node_modules\/(d3|visx)\// },
            { name: "vendor", test: /node_modules\// },
          ],
        },
      },
    },
  },
});

El orden importa: el primer grupo cuyo test casa gana, así que las familias específicas van antes que el vendor genérico que recoge el resto. Aíslas lo grande y volátil —el framework, la librería de gráficas— en chunks propios y fundes lo demás en un vendor común. La razón es la caché: cuando actualizas una sola librería, solo cambia el hash de su chunk, y el navegador reutiliza todos los demás. Un vendor monolítico invalidaría el bloque entero por una sola actualización.

⚠️
Vendor significa terceros, no inicial

La trampa clásica del splitting manual es arrastrar al vendor inicial una dependencia que solo usa una ruta diferida. El automático la colocaría por alcanzabilidad en el chunk asíncrono de esa pantalla; una regla manualChunks torpe la fuerza al camino crítico, y el usuario descarga en el arranque una librería que no verá hasta pulsar un botón secundario. Antes de fijar una regla, comprueba en el treemap que la dependencia de verdad la comparten muchas rutas iniciales.

Cazar duplicados en el grafo

El vendor bien separado se envenena si la misma dependencia entra dos veces. Ocurre por la resolución de versiones: el paquete A pide util@^4.0 y el paquete B pide util@^4.2, y si los rangos no convergen en una única versión instalada, el gestor deja dos copias en node_modules bajo rutas distintas. El bundler, fiel al grafo, empaqueta ambas. En el treemap las ves como dos bloques gemelos con el mismo nombre.

flowchart TD
app[Tu aplicacion] --> a[libreria A pide util 4.0]
app --> b[libreria B pide util 4.2]
a --> u1[copia util 4.0]
b --> u2[copia util 4.2]
u1 -. mismo paquete dos veces .- u2
style u1 fill:#f38ba8,color:#11111b
style u2 fill:#f38ba8,color:#11111b

La primera herramienta es el gestor de paquetes. pnpm why util te dice quién pide cada versión y por qué conviven; pnpm dedupe reescribe el árbol para colapsar copias compatibles en una sola, y pnpm dedupe --check falla en CI si quedan duplicados evitables. Cuando dos rangos son irreconciliables y ninguno cede, fuerzas la mano con un override, que impone una versión única a todo el árbol.

// package.json — forzar una unica version en todo el grafo con pnpm
{
  "pnpm": {
    "overrides": {
      "util": "4.2.0"
    }
  }
}
💡
El bundler también puede deduplicar en resolución

Aunque node_modules tenga dos copias, Vite puede resolver ciertos paquetes a una sola instancia con resolve.dedupe. Es la red de seguridad para los singletons: aunque una dependencia transitiva arrastre su propia copia de tu framework, el bundler la colapsa a la del proyecto raíz. No sustituye al dedupe del gestor, pero rescata los casos donde el árbol físico no se puede aplanar del todo.

// vite.config.ts — colapsar singletons a una sola instancia
export default defineConfig({
  resolve: {
    dedupe: ["react", "react-dom"],
  },
});

Singletons: cuando duplicar es un bug

Para la mayoría de librerías, un duplicado es solo peso desperdiciado. Pero para una clase especial —los singletons con estado— es un fallo de corrección. React es el ejemplo canónico: su sistema de hooks y de contexto depende de que haya una única instancia del módulo en toda la aplicación. Con dos copias, un componente registra su contexto en una y otro lo lee de la otra; el valor no aparece, los hooks lanzan errores crípticos sobre múltiples versiones, y el bug no se parece en nada a un problema de tamaño.

La raíz es que un módulo no es solo código: es también identidad. Dos copias de un módulo con estado son dos universos que no se ven entre sí, cada uno con su propio store, su propio contexto, su propio registro. Por eso las librerías que son singletons se declaran como peerDependencies —“no me empaquetes tú, usa la del anfitrión”— y por eso deduplicarlas no es una optimización opcional sino una condición para que la aplicación funcione.

⚛️

Frameworks de UI

React, Vue o Solid guardan estado global —contexto, reactividad— que exige una sola copia. Dos instancias rompen los hooks y la propagación.

🎨

Sistemas de estilo

Librerías de CSS-in-JS con un registro de estilos compartido. Dos copias generan dos registros y estilos que se pisan o desaparecen.

🗃️

Stores y clientes

Un cliente de datos o un store global cacheado. Duplicarlo parte la caché en dos y los componentes leen de mitades distintas.

El grafo de módulos es un espacio de nombres, y la identidad es sagrada

La lección honda del dedupe es que un bundle no es una bolsa de bytes que quieres encoger, sino un espacio de nombres donde cada módulo tiene una identidad que debe preservarse. Cuando pensamos en deduplicar solemos verlo como compresión —quitar copias para pesar menos—, y para el código sin estado esa intuición basta. Pero para el código con estado la deduplicación es algo cualitativamente distinto: es garantizar que “el módulo X” se refiera a una y solo una entidad en todo el sistema, porque su corrección depende de que todos los que hablan de X hablen del mismo X. Dos copias de un módulo puro son un desperdicio; dos copias de un módulo con estado son una esquizofrenia —dos realidades paralelas que divergen en silencio hasta que un síntoma inexplicable aflora lejos de la causa—. Esta es la misma idea que en un sistema operativo hace que solo haya un kernel, que en una base de datos exige una única fuente de verdad para cada dato, que en un lenguaje con módulos garantiza que importar el mismo módulo dos veces devuelva la misma instancia. El bundler es, en el fondo, un pequeño enlazador que resuelve nombres a definiciones, y su trabajo silencioso más importante no es hacer el resultado pequeño sino hacerlo coherente: que el grafo lógico de “quién depende de quién” se preserve en el artefacto físico sin fracturar identidades. Cuando fuerzas un manualChunks que separa lo que debía ir junto, o cuando dejas que dos versiones de un singleton coexistan, no estás cometiendo una ineficiencia: estás rompiendo el contrato de identidad del que depende la semántica de tu programa. Quien entiende esto deja de ver el dedupe como una micro-optimización y empieza a verlo como lo que es: la disciplina de mantener un único significado para cada nombre en un sistema que ensambla miles de piezas escritas por miles de manos.

⚔️ Separa el vendor y elimina lo duplicado
  1. Configura advancedChunks para aislar tu framework en un chunk y fundir el resto de node_modules en un vendor común.
  2. Cambia una línea de tu código de app, reconstruye y confirma que el hash del vendor chunk no se movió.
  3. Busca en el treemap un bloque duplicado y usa pnpm why para descubrir qué dos paquetes piden versiones incompatibles.
  4. Consolida esa versión con pnpm dedupe o un override, reconstruye y verifica que el duplicado desapareció del mapa.
  5. Añade tu framework a resolve.dedupe y razona por qué, para un singleton con estado, esa línea es corrección y no solo ahorro.