Por qué difieren: los bugs de dev que rompen en build
Dos tuberías sobre el mismo fuente producen, inevitablemente, dos comportamientos que a veces divergen. Esa divergencia es una familia entera de bugs: código que funciona en dev y se rompe en build por el orden de ejecución, por un tree shaking que borra lo que sí usabas, o por globals y variables de entorno que existen en una naturaleza y no en la otra. Aprende a preverlos antes de que te sorprendan en producción.
Si dev y build son dos estrategias distintas sobre el mismo grafo, la consecuencia es incómoda pero lógica: pueden diferir. Existe una clase entera de bugs que solo aparecen tras vite build —nunca en vite— y desconciertan porque el código “funcionaba”. No es que funcionara: es que el dev server es un intérprete permisivo que nunca ejerció las transformaciones donde el bug vive. El build es un compilador optimizador que sí las ejerce, y al hacerlo saca a la luz cualquier suposición frágil. Este nivel cataloga esas suposiciones para que dejen de emboscarte.
- Entender por qué dos tuberías sobre el mismo fuente pueden divergir en comportamiento.
- Reconocer las familias de bug: orden y efectos, tree shaking, globals y entorno.
- Ver que el dev server es permisivo y el build es agresivo, y qué implica.
- Adquirir patrones defensivos y el reflejo de reproducir en el artefacto real.
Orden y efectos secundarios
En dev, los módulos se evalúan en el orden en que el navegador los pide, siguiendo la topología natural del grafo. En build, Rollup o Rolldown hoistean y reordenan las declaraciones para concatenar módulos en un chunk: el orden de ejecución de los efectos al importar puede cambiar. Un módulo que en dev se inicializaba antes que otro quizá se inicialice después en el bundle, y todo código que dependa de un efecto secundario en tiempo de import se vuelve frágil.
El hoisting no es un capricho: es lo que permite que el bundler funda decenas de módulos en un solo ámbito, renombre lo que colisiona y elimine las barreras entre archivos. Pero al aplanar esa jerarquía, el orden temporal que en dev estaba garantizado por la secuencia de peticiones deja de estarlo, y pasa a depender del análisis del bundler, que optimiza por corrección estática, no por preservar tu orden implícito.
Los efectos que el orden del bundle puede alterar son casi siempre del mismo tipo:
- Registrar algo en un contenedor global en el momento de importar un módulo.
- Leer en el nivel superior una variable que otro módulo exporta.
- Inicializar un singleton cuyo orden de creación importa para el resto.
- Parchear un prototipo o instalar un polyfill como efecto de import.
Las dependencias circulares son el caso extremo. En dev, con módulos servidos por separado, un ciclo suele tolerarse porque el valor ya está disponible cuando se lee. En el bundle, tras el hoisting, el mismo ciclo puede exponer un binding aún sin inicializar y arrojar un undefined que en desarrollo jamás viste.
// a.ts importa de b.ts y b.ts importa de a.ts: un ciclo
// En dev el orden de peticiones lo salva; tras el hoisting del build,
// valor puede leerse antes de asignarse y quedar en undefined.
import { valor } from "./b";
export const derivado = valor * 2; // NaN si valor aun no existe
La regla defensiva es sencilla: la evaluación de un módulo debería declarar, no actuar. Registrar un singleton, mutar un global o disparar una petición en el cuerpo de nivel superior te ata a un orden de ejecución que el bundler no promete conservar. Envuelve esos efectos en una función que llames explícitamente desde tu punto de entrada, y el orden pasa a ser tuyo, no del hoisting. El campo sideEffects del package.json es la declaración formal de cuánto puede el bundler reordenar y eliminar.
Tree shaking que borra lo que sí usabas
En dev, tu código nunca se poda: se sirve entero. En build, el tree shaking analiza el grafo ESM y elimina todo export que cree muerto. El problema surge cuando algo parece muerto al análisis estático pero está vivo en tiempo de ejecución: acceso dinámico a propiedades, reflexión, un decorador con efecto, o un módulo mal declarado con sideEffects: false cuando en realidad sí los tiene. El bundler lo borra con toda la razón formal, y tu producción se rompe.
El análisis estático se equivoca cuando el uso no es visible en el texto del módulo:
- Acceso a propiedades por clave calculada en tiempo de ejecución.
- Reflexión o metaprogramación sobre un import de namespace.
- Efectos declarados como inexistentes con un
sideEffects: falseincorrecto. - Registros implícitos que dependen de que el import ocurra, no de que se use su valor.
// El analisis estatico no ve este uso: cree que los temas estan muertos
import * as temas from "./temas";
const nombre = leerDeConfig(); // valor dinamico, ilegible en build
export const activo = temas[nombre]; // en produccion puede ser undefined
La declaración de sideEffects es un contrato peligroso precisamente porque el análisis confía en ella ciegamente. Si marcas un paquete como libre de efectos para que el bundler pode agresivamente, pero uno de sus módulos registra un componente o parchea un prototipo al importarse, el bundler lo eliminará por creerlo inerte y el efecto nunca ocurrirá. En dev no lo notas porque nada se poda; en build, el registro simplemente desaparece.
{
"name": "mi-libreria",
"sideEffects": ["./src/registrar-globales.js", "*.css"]
}
La otra mitad del fenómeno es la eliminación de código muerto por sustitución. Vite reemplaza import.meta.env.DEV por el literal true o false mediante define, que es una sustitución textual, no semántica. Tras fijar el literal, el minificador poda la rama inalcanzable. Si tu lógica esperaba que esa rama existiera en runtime, desaparece. La lección: lo que en dev es una variable, en build puede ser una constante ya resuelta y una rama ya borrada.
Tree shaking
Elimina exports que el análisis estático no ve usados. Frágil ante acceso dinámico, reflexión y sideEffects mal declarado.
Dead code elimination
define sustituye tokens por literales y el minificador poda ramas inalcanzables. En dev era variable; en build es constante.
Globals, process y variables de entorno
El navegador no tiene process, ni Buffer, ni __dirname. En dev, con Vite sirviendo módulos y ciertos polyfills o fugas del entorno de Node, un código que lee process.env.ALGO a veces parece funcionar. En el build de cliente ese process no existe, y salvo que lo hayas sustituido con define, obtienes un ReferenceError en producción. La forma correcta en Vite es import.meta.env, y solo las variables con prefijo VITE_ se inyectan al cliente.
// Correcto en el cliente: solo variables con prefijo VITE_ llegan al bundle
const api = import.meta.env.VITE_API_URL; // inyectada en build
const modo = import.meta.env.MODE; // "development" o "production"
// Peligroso: process no existe en el navegador
const roto = process.env.SECRETO; // ReferenceError en produccion
Que solo se inyecten las variables con prefijo VITE_ no es un capricho, sino una defensa de seguridad. Tu entorno de servidor contiene secretos —claves de API privadas, cadenas de conexión— y si Vite inyectara todo process.env en el bundle de cliente, esos secretos viajarían al navegador de cualquier usuario. El prefijo obligatorio convierte la exposición en un acto deliberado: solo lo que marcas explícitamente como público cruza al cliente.
El desajuste de globales tiene varias caras, y conviene reconocerlas todas:
processyBuffer: existen en Node y no en el navegador.__dirnamey__filename: propios de CommonJS, ausentes en el ESM de navegador.globalfrente aglobalThis: solo el segundo es portable entre entornos.- Variables sin prefijo
VITE_: quedan fuera del bundle de cliente a propósito.
Hay un matiz que atrapa a mucha gente: import.meta.env.MODE y el valor de NODE_ENV los fija el modo, no el comando. vite build usa por defecto el modo production, pero puedes correr vite build --mode staging y obtener otro conjunto de variables. Confundir “estoy en build” con “estoy en producción” es un error sutil: son ejes independientes, y el próximo nivel sobre cliente y SSR lo hace aún más evidente.
flowchart TD S[Un mismo modulo fuente] --> DEV[Dev sirve ESM sin tocar el grafo] S --> BUILD[Build reordena poda y sustituye] DEV --> OK[Funciona porque nada se poda ni se mueve] BUILD --> R1[Cambia el orden de los efectos al importar] BUILD --> R2[Tree shaking borra lo usado por reflexion] BUILD --> R3[process y globals no existen en el bundle] style S fill:#89b4fa,color:#11111b style OK fill:#a6e3a1,color:#11111b style R1 fill:#f38ba8,color:#11111b style R2 fill:#f38ba8,color:#11111b style R3 fill:#f38ba8,color:#11111b
Todo bug de esta familia nace de la misma raíz: el dev server es un intérprete lenient que ejecuta tu código casi tal cual, y el build es un compilador optimizador que lo somete a transformaciones que dev nunca aplicó —hoisting, tree shaking, sustitución de constantes, minificación—. Cada una de esas transformaciones es correcta bajo el modelo estático de ESM, y cada una expone una suposición que tu código daba por cierta sin garantía: que el orden de import se conserva, que un export que usas dinámicamente no se considera muerto, que process existe, que una rama de dev sobrevive en runtime. Ninguna de esas suposiciones estaba escrita en el estándar; simplemente el intérprete permisivo las toleraba. Por eso la mentalidad correcta no es “el build rompió mi código”, sino “el build ejerció una suposición que yo no tenía derecho a hacer”. Y de ahí sale el único hábito que de verdad protege: dejar de tratar vite como la fuente de verdad del comportamiento. El dev server es una comodidad de iteración, no un simulador fiel de producción. La verdad del comportamiento vive en el artefacto, y el artefacto se inspecciona con vite build seguido de vite preview. Interioriza esto y la clase entera de bugs “funcionaba en dev” se colapsa: dejas de programar contra un intérprete indulgente y empiezas a programar contra el compilador que de verdad decide qué llega al usuario. La disciplina de reproducir en el artefacto real, que verás en detalle al final del nivel, es la contrapartida operativa de este principio.
- Escribe un módulo con un efecto secundario en el nivel superior del que dependa otro; comprueba si el orden sobrevive en
vite buildyvite preview. - Accede a un objeto por clave dinámica calculada en runtime y verifica que el tree shaking borró las ramas que el análisis no pudo ver.
- Lee
process.env.ALGOen un componente de cliente; observa que dev lo tolera y que el build produce un error en el navegador. - Renómbrala a
import.meta.env.VITE_ALGO, recompila y confirma que ahora sí se inyecta el valor. - Crea una dependencia circular, pruébala en dev y luego en el bundle, y explica por qué un binding queda
undefinedsolo tras el hoisting.