wandres.dev
DEV VS BUILD · las dos mitades

Las dos naturalezas: ESM en dev, bundle en build

Vite no es una herramienta con un modo: son dos estrategias de evaluación bajo una misma interfaz. En desarrollo sirve tu ESM sin empaquetar, módulo a módulo y bajo demanda; en producción compila un bundle optimizado por adelantado. Por qué esa dualidad no es un accidente, sino la única respuesta posible a dos objetivos irreconciliables: minimizar la latencia de iteración y minimizar la latencia de carga.

⏱ 15 min

La confusión más cara sobre Vite es tratarlo como un único programa que a veces sirve y a veces compila. No lo es. Vite son dos estrategias de evaluación distintas sobre el mismo grafo de módulos: en desarrollo lo recorre de forma perezosa, sirviendo cada módulo ESM cuando el navegador lo pide; en producción lo evalúa de forma ansiosa, fundiéndolo entero en un artefacto optimizado. Comprender que vite y vite build persiguen objetivos opuestos —y por qué esos objetivos no pueden satisfacerse con un solo mecanismo— es el cimiento de todo lo que sigue en este nivel.

🎯 Al terminar esta lección sabrás
  • Distinguir los dos objetivos en tensión: latencia de iteración frente a latencia de carga.
  • Entender por qué el dev server sirve ESM nativo sin empaquetar tu código.
  • Entender por qué el build empaqueta y optimiza el grafo por adelantado.
  • Ver por qué Vite separa ambas naturalezas en lugar de forzar un único modo.

Dos objetivos irreconciliables

En desarrollo guardas un archivo decenas de veces por minuto, y lo único que importa es el retardo entre pulsar guardar y ver el cambio. Ese retardo se descompone en dos métricas: el arranque en frío —cuánto tarda el servidor en estar listo— y la latencia de HMR —cuánto tarda un cambio en reflejarse—. El tamaño del artefacto final te da exactamente igual: nadie descarga tu código de desarrollo, y ninguna optimización de bytes te devuelve un segundo de tu atención.

En producción el cálculo se invierte. El artefacto se construye una vez y se descarga millones de veces, así que lo que importa son los bytes que viajan por la red y el tiempo que el navegador tarda en parsear y ejecutar. Aquí pagas de buena gana varios segundos de compilación con tal de recortar kilobytes, porque ese coste se amortiza en cada visita y se paga una sola vez, lejos del usuario.

Estas dos metas optimizan variables opuestas, y ahí está la raíz de todo. Un único mecanismo afinado para una es forzosamente malo para la otra: empaquetarlo todo por adelantado —el modelo de webpack— da buena producción pero un arranque en frío que crece con el proyecto; servir módulos crudos da arranque instantáneo pero una cascada de cientos de peticiones inservible en producción.

Durante una década el frontend vivió atrapado en el primer cuerno del dilema. Herramientas como webpack empaquetaban el proyecto entero antes de poder servir la primera página, de modo que el arranque en frío crecía casi linealmente con el tamaño del código: en un monorepo grande, levantar el dev server podía costar minutos. Vite rompió ese acoplamiento negándose a empaquetar en desarrollo, y esa negativa es precisamente lo que le permite arrancar en una fracción de segundo sin importar cuánto haya crecido tu repositorio.

Dev optimiza la iteración

Métricas: arranque en frío y latencia de HMR. El artefacto no importa. La variable a minimizar es el tiempo entre guardar y ver.

🚀

Build optimiza la carga

Métricas: bytes por la red y tiempo de parseo. El tiempo de compilación importa poco. La variable a minimizar es lo que el usuario espera.

Dev: ESM bajo demanda

Los navegadores hablan ESM de forma nativa desde 2018, y Vite explota ese hecho hasta sus últimas consecuencias. En desarrollo no empaqueta tu fuente: levanta un servidor HTTP y, por cada import que el navegador encuentra, transforma solo ese módulo —borra los tipos de TypeScript, convierte el JSX en llamadas— y lo devuelve. El coste de arrancar no es proporcional al tamaño del repositorio, sino a lo que la ruta actual necesita importar para pintarse.

Cuando el navegador pide un módulo, Vite hace por él lo mínimo imprescindible:

  • Reescribe los bare imports como lodash-es a rutas que el navegador sepa pedir.
  • Borra los tipos de TypeScript y convierte el JSX en llamadas a funciones.
  • Inyecta el cliente de HMR para que ese módulo pueda recargarse en caliente.
  • Devuelve ESM que el navegador ejecuta sin intermediarios ni empaquetado.
# Dos comandos, dos naturalezas
vite            # dev server: sirve ESM sin empaquetar, bajo demanda
vite build      # produccion: resuelve, empaqueta y optimiza por adelantado
vite preview    # sirve el artefacto de build para verlo como en produccion

Esa propiedad —arranque que no crece con el proyecto— es la que define el dev server moderno. Pero tiene una excepción deliberada: las dependencias. Los node_modules son enormes, a menudo están en CommonJS y se reparten en miles de archivos internos diminutos; servirlos crudos provocaría una tormenta de peticiones y, encima, el CJS ni siquiera cargaría como ESM. Por eso Vite pre-empaqueta las dependencias con optimizeDeps —históricamente con esbuild, en Vite 8 con Rolldown— y las cachea con fuerza en disco.

La asimetría es intencionada y vale la pena nombrarla: tu código se sirve sin empaquetar porque cambia a cada segundo y debe permanecer granular para que el HMR sustituya un módulo aislado; tus dependencias se pre-empaquetan porque casi nunca cambian y conviene fijarlas de una vez. Cuando editas un archivo, solo ese módulo se vuelve a pedir y se intercambia en caliente, conservando el estado de la interfaz.

Ese pre-empaquetado tiene un matiz operativo que conviene conocer: ocurre la primera vez que arrancas y se cachea, así que el primer vite de un proyecto nuevo tarda un poco más mientras Vite descubre y fija las dependencias. Los arranques posteriores reutilizan esa caché y son casi instantáneos, hasta que cambias de dependencias o fuerzas un re-descubrimiento con --force.

// vite.config.ts: afinar que se pre-empaqueta y que no
export default defineConfig({
  optimizeDeps: {
    include: ["cjs-profundo"],   // forzar el pre-empaquetado de esta
    exclude: ["ya-es-esm"],      // dejar que esta se sirva sin tocar
  },
});
ℹ️
Un solo vite.config gobierna las dos naturalezas

No hay dos configuraciones: el mismo vite.config.ts rige dev y build. Lo que cambia es qué partes de la tubería se ejecutan. Algunos hooks de plugin corren solo en dev —los que sirven módulos— y otros solo en build —los que transforman el bundle—. Un plugin bien escrito declara en qué fase actúa; uno mal escrito es una fuente clásica de divergencias, el tema del siguiente nivel.

Build: un grafo empaquetado

vite build hace lo contrario en todo. Parte de los puntos de entrada, resuelve el grafo completo, aplica tree shaking para podar lo que nadie usa, lo divide en chunks bajo demanda, minifica y pone hash de contenido en cada nombre. La salida es un puñado de archivos versionados listos para un CDN. Es evaluación ansiosa: todo el trabajo por adelantado para que el navegador no pague nada evitable.

// Tu codigo puede ramificar segun la naturaleza activa
if (import.meta.env.DEV) {
  console.debug("solo en dev: nunca llega al bundle de produccion");
}
// En build, import.meta.env.DEV es el literal false y la rama se elimina

¿Por qué no enviar directamente ESM sin empaquetar a producción, como en dev? Porque un grafo de imports profundo se convierte en una cascada de peticiones encadenadas; aun con HTTP/2, los viajes de ida y vuelta y la imposibilidad de minificar y podar entre módulos cuestan más que un bundle. Empaquetar y trocear con criterio es el punto óptimo: pocos archivos, cada uno cargado justo cuando hace falta. El motor que lo produce fue Rollup durante años y es Rolldown en Vite 8.

Servir ESM crudo en producción falla por razones acumulativas:

  • Cada módulo es una petición, y un grafo real tiene cientos o miles de módulos.
  • Sin fundir módulos, el minificador no puede optimizar a través de sus fronteras.
  • Sin un grafo cerrado, el tree shaking no puede podar con seguridad lo que nadie usa.
  • La cascada de dependencias encadena latencias que el usuario acaba pagando en serie.

El resultado en disco lo dice todo. Donde en desarrollo no hay artefacto —solo módulos servidos al vuelo—, el build deposita en dist/ un conjunto reducido de archivos con hash de contenido en el nombre, pensados para que un CDN los cachee de forma agresiva e invalide solo cuando el contenido cambia.

# La salida de vite build: pocos archivos versionados para el CDN
dist/
  index.html
  assets/index-9f3a1c.js      # tu app empaquetada y minificada
  assets/vendor-4b2e0a.js     # dependencias en un chunk aparte
  assets/index-7c1d8e.css     # estilos extraidos y hasheados
📝
Sin artefacto en dev, con artefacto en build

La prueba más tangible de las dos naturalezas es mirar el disco. vite no escribe nada: transforma en memoria y responde a cada petición al vuelo. vite build materializa el grafo entero en dist/. Por eso no hay forma de “ver” tu build de producción mientras corres el dev server: no existe hasta que lo pides, y ese hueco entre lo servido y lo compilado es justo donde anidan los bugs del siguiente nivel.

flowchart LR
S[Tu codigo fuente] --> D{Modo}
D -->|vite dev| L[Servir ESM por modulo bajo demanda]
L --> LH[HMR y arranque que no crece con el repo]
D -->|vite build| E[Resolver el grafo entero]
E --> O[Podar dividir minificar y hashear]
O --> A[Artefactos versionados para CDN]
style S fill:#89b4fa,color:#11111b
style LH fill:#f9e2af,color:#11111b
style A fill:#a6e3a1,color:#11111b
Perezoso en dev, ansioso en build: dos evaluaciones del mismo grafo

El patrón profundo que subyace a todo Vite es una vieja dualidad de la informática: evaluación perezosa frente a evaluación ansiosa, aplicada al grafo de módulos. En desarrollo, Vite evalúa el grafo de forma perezosa —solo transforma el módulo que el navegador pide, justo cuando lo pide— y por eso el arranque no depende del tamaño del proyecto sino de lo que la ruta abierta necesita ver. En producción lo evalúa de forma ansiosa —resuelve, funde y optimiza el grafo completo antes de que ningún usuario lo toque— y por eso el navegador recibe un artefacto quirúrgico que no paga ni un byte evitable. No son dos herramientas pegadas con cinta: son dos estrategias correctas para dos preguntas distintas. Fundirlas en un único mecanismo sería traicionar a una de las dos, porque las variables que minimizan son opuestas: la iteración quiere granularidad y pereza; la carga quiere agregación y anticipación. Aquí está la lección que gobierna el nivel entero: cuando una fase se define por una restricción distinta, merece un mecanismo distinto, aunque comparta la interfaz. Vite estabiliza la interfaz —el mismo vite.config.ts, el mismo grafo, los mismos plugins— y varía la estrategia debajo. Quien confunde las dos naturalezas persigue fantasmas: intenta que el dev sea idéntico a la producción, o se sorprende de que la producción no se comporte como el dev. Quien las distingue sabe, ante cualquier síntoma, en cuál de las dos vive el problema, que es media solución antes de haber empezado.

⚔️ Palpa las dos naturalezas
  1. Crea un proyecto con Vite 8 y lanza vite: mide el arranque en frío. Añade cincuenta módulos que la ruta inicial no importe y comprueba que el arranque apenas cambia.
  2. Ejecuta vite build sobre el mismo proyecto y observa que ahora sí recorre el grafo entero: el tiempo escala con lo que hay que empaquetar.
  3. Pon una rama bajo import.meta.env.DEV, compila y búscala en el bundle: verifica que desapareció por completo.
  4. Inspecciona la carpeta de caché de optimizeDeps tras el primer arranque y confirma que tus dependencias se pre-empaquetaron, pero tu código no.
  5. Escribe en dos frases qué variable minimiza cada naturaleza y por qué un solo mecanismo no puede minimizar ambas.