Por qué empaquetar en producción
Si en desarrollo Vite sirve ESM nativo sin empaquetar y funciona de maravilla, por qué molestarse en empaquetar para producción. La respuesta se sostiene sobre tres pilares: reducir el número de peticiones y matar la cascada de red que hunde una app sobre internet real; aplicar optimizaciones globales que solo el grafo completo habilita, como el tree shaking y el scope hoisting; y presentar un artefacto compatible con la maraña real de navegadores y dependencias. Por qué localhost te engaña y la red de verdad castiga.
En el nivel 16 viste la paradoja: en desarrollo, Vite sirve ESM nativo sin empaquetar —cientos de módulos diminutos, cada uno su propia petición— y va como un tiro. La pregunta incómoda es entonces obvia. Si sin empaquetar funciona, ¿por qué empaquetar para producción? La respuesta es que localhost te engaña. Sobre tu máquina, con latencia casi nula, servir mil módulos sueltos es gratis; sobre internet real, con latencia de verdad y navegadores de verdad, ese mismo enfoque se desploma. Empaquetar en producción se gana el sueldo en tres frentes: reduce las peticiones, habilita optimizaciones que exigen ver el grafo entero, y produce un artefacto compatible con el mundo tal como es, no como nos gustaría.
- Entender por qué servir ESM sin empaquetar hunde el rendimiento sobre una red real.
- Ver cómo el empaquetado reduce las peticiones y elimina la cascada de descubrimiento.
- Conocer las optimizaciones globales que solo el grafo completo habilita.
- Comprender el papel del empaquetado en la compatibilidad de navegadores y dependencias.
El problema de las mil peticiones
Con ESM nativo, cada módulo es una petición HTTP. Una app real no tiene diez módulos: tiene cientos o miles, entre tu código y sus dependencias. Pero el número no es lo peor; lo peor es la cascada. El navegador no puede pedir un módulo hasta que ha descargado y parseado a quien lo importa y ha descubierto ese import. La profundidad del grafo se traduce en rondas de ida y vuelta secuenciales: para llegar a un módulo enterrado diez niveles abajo, el navegador encadena diez viajes a la red antes de poder ejecutarlo.
En localhost ese coste es invisible porque el tiempo de ida y vuelta es casi cero. Pon la misma app sobre internet, con 50 a 100 milisegundos de latencia por viaje y un grafo de diez niveles de profundidad, y tienes medio segundo o más de pura espera en cascada antes de que corra la primera línea útil. Empaquetar colapsa esos cientos de módulos en unos pocos archivos: pocas peticiones, sin cascada de descubrimiento, porque todo lo que se necesita junto viaja junto.
El coste de servir sin empaquetar no es un solo número, sino una suma de sobrecostes que se acumulan:
- La cascada de descubrimiento, donde la profundidad del grafo se paga en rondas de red secuenciales.
- El coste por petición, con sus cabeceras y su ida y vuelta, multiplicado por cada módulo.
- Una compresión peor, porque muchos archivos diminutos comparten menos redundancia que unos pocos grandes.
- La contención, cuando un enjambre de peticiones satura los límites de conexiones concurrentes del navegador.
flowchart TD subgraph sin[Sin empaquetar] h1[HTML] --> e1[main.js] e1 --> e2[app.js] e2 --> e3[Button.js] e3 --> e4[util.js] end subgraph con[Empaquetado] h2[HTML] --> b1[bundle unico] end style e4 fill:#f38ba8,color:#11111b style b1 fill:#a6e3a1,color:#11111b
La objeción esperable es que HTTP/2 y HTTP/3 multiplexan muchas peticiones sobre una conexión, así que el coste por archivo cae. Es cierto, y por eso servir sin empaquetar es viable en dev. Pero el multiplexado resuelve la concurrencia, no la cascada: sigues sin poder pedir lo que aún no has descubierto, y la profundidad del grafo sigue costando rondas secuenciales. Además, cada petición arrastra sus cabeceras, y muchos archivos diminutos se comprimen peor que pocos archivos grandes, porque los algoritmos de compresión aprovechan mejor la redundancia cuanto más contexto ven de golpe. El protocolo nuevo mueve el techo, no lo elimina.
Optimizaciones que exigen el grafo completo
El segundo pilar es más sutil. Hay optimizaciones que son imposibles viendo un módulo aislado y triviales viendo el grafo entero, porque son globales por naturaleza. Servir módulos crudos, uno a uno, renuncia a todas ellas; empaquetar las desbloquea de golpe.
Tree shaking
Para borrar un export hay que demostrar que nadie lo importa, y eso exige ver todos sus usos a lo largo de la clausura. Un módulo suelto no puede saber si su export es código muerto; el grafo sí. Lo verás a fondo en el nivel 21.
Scope hoisting
Fundir varios módulos en un mismo ámbito elimina el envoltorio de función que cada módulo arrastraría por separado. Menos indirección, menos bytes, código que corre más rápido. Solo es posible con el grafo delante.
Minificación y compresión
Renombrar variables, plegar constantes y eliminar espacios rinde más cuanto más código se ve junto. Y pocos archivos grandes se comprimen mejor que muchos pequeños. La escala del grafo es la que da munición al minificador.
Y no se agotan ahí; tener el grafo entero delante habilita toda una familia de decisiones globales:
- Eliminación de código muerto que cruza fronteras de módulo, no solo dentro de uno.
- Concatenación en un único ámbito que borra el runtime de envoltura entre módulos.
- Elevación de constantes compartidas y plegado de expresiones ya conocidas en compilación.
- Reordenación segura de módulos cuando el análisis demuestra que no hay efectos observables.
El hilo común es que ninguna de estas optimizaciones cabe en la frontera de un solo módulo. El tree shaking necesita la clausura transitiva para razonar sobre alcanzabilidad de exports; el scope hoisting necesita saber quién importa a quién para fusionar ámbitos sin colisiones; la minificación agresiva y la compresión rinden en proporción a cuánto contexto abarcan. Empaquetar no es solo juntar archivos: es crear la condición —tener el grafo entero en la mano— bajo la cual estas transformaciones globales se vuelven posibles.
La distinción entre una optimización local y una global es la que explica por qué el dev server renuncia a casi todas. Una local mira un módulo aislado y decide —plegar sus constantes, quitar sus espacios— y puede hacerse sirviendo archivos sueltos. Una global necesita la clausura transitiva entera: para borrar un export hay que ver todos sus usos, para fundir ámbitos hay que conocer todas las aristas. En dev, donde se sirve módulo a módulo, las globales son estructuralmente imposibles; en build, donde el grafo está completo en memoria, son naturales. No es que el dev server sea perezoso: es que le falta la pieza —el grafo entero— sobre la que esas optimizaciones operan.
Un ejemplo mínimo lo hace tangible. Si tu código importa una sola función de un módulo con varias, solo el grafo completo puede demostrar que el resto no se usa en ninguna parte y borrarlo sin miedo:
// utils.js exporta dos funciones
export function usada() { return 1; }
export function jamas() { return 2; } // nadie la importa en todo el grafo
// app.js importa solo una
import { usada } from "./utils.js";
usada();
// tras el tree shaking, jamas() no existe en el bundle: era codigo muerto
Compatibilidad con el mundo real
El tercer pilar es el menos glamuroso y el más ineludible. El navegador es un entorno hostil para el código tal como lo escribes y tal como te llegan las dependencias. Empaquetar es también el acto de normalizar esa maraña en un artefacto que el navegador acepte sin protestar.
Empieza por la resolución. El navegador no tiene concepto de node_modules: si le sirves import React from "react", no sabe dónde está react, porque un especificador bare no es una URL. El bundler resuelve esos especificadores a rutas concretas —es, de hecho, por lo que Vite pre-empaqueta las dependencias incluso en dev, como viste en el nivel 13—. Sigue por el formato: media web publica sus paquetes en CommonJS, y un navegador no sabe hacer require; el bundler convierte ese CJS a ESM para que cargue. Y termina por la sintaxis y los assets: rebaja las construcciones modernas al target de navegadores que soportas, e integra CSS, imágenes y fuentes que el navegador jamás importaría como módulos.
// Lo que escribes y lo que te llega no es lo que el navegador entiende
import React from "react"; // bare specifier: el navegador no sabe resolverlo
const x = require("left-pad"); // CommonJS: el navegador no tiene require
import "./estilos.css"; // un import que el navegador no haria de un css
Normalizar, en concreto, significa hacerse cargo de todo esto de una sola vez, para que el navegador reciba un artefacto que ya no asume ningún build ni ningún resolvedor de Node:
- Resolver los especificadores bare a rutas reales, porque el navegador no conoce
node_modules. - Convertir CommonJS a ESM, porque un navegador no tiene
requirey buena parte del ecosistema aún publica en CJS. - Rebajar la sintaxis al conjunto de navegadores que soportas, guiado por tu target.
- Integrar los assets —CSS, imágenes, fuentes— que el navegador jamás importaría por sí mismo como módulos.
Esta normalización es tan ineludible que asoma incluso en desarrollo. Aunque Vite no empaquete tu código en dev, sí pre-empaqueta las dependencias con esbuild —el optimizeDeps del nivel 13— justamente para convertir su CommonJS a ESM y resolver sus especificadores bare antes de servirlas. Es decir: la parte de compatibilidad del empaquetado no es negociable ni siquiera cuando renuncias al resto. El navegador nunca sabrá qué es react ni cómo hacer require, corras en dev o en prod.
El empaquetado tiene un coste real: tiempo de build, complejidad de configuración, sourcemaps que mantener para poder depurar el output. Para un script minúsculo, una página estática sin dependencias o un experimento, empaquetar puede ser sobreingeniería, y servir un par de módulos ESM directamente es más simple y honesto. La regla no es empaquetar siempre, sino empaquetar cuando los tres pilares lo justifican: cuando hay grafo suficiente para que la cascada duela, optimizaciones que ganar, o dependencias que normalizar. Saber cuándo NO empaquetar es parte de entender por qué se empaqueta.
Menos peticiones
Colapsar cientos de módulos en pocos archivos mata la cascada de descubrimiento que la latencia real convierte en segundos de espera.
Optimizaciones globales
Con el grafo completo en la mano, el tree shaking, el scope hoisting y la minificación operan sobre la clausura entera, no módulo a módulo.
Compatibilidad
Resolver especificadores bare, convertir CommonJS a ESM, rebajar sintaxis e integrar assets: un artefacto que el navegador acepta sin protestar.
La lección estratégica de este nivel es que desarrollo y producción viven en universos con física distinta, y confundirlos es la raíz de casi todos los malentendidos sobre bundlers. En desarrollo, la latencia es cero, el navegador es uno solo y moderno, y las dependencias ya están resueltas en tu disco: en ese mundo idílico, servir ESM crudo es rápido, simple y directo, y por eso Vite lo hace. Pero ese mundo no existe para tus usuarios. Ellos cargan tu app sobre redes con latencia real, desde navegadores variados, y no tienen tu node_modules ni tu resolvedor. Empaquetar es, en el fondo, el acto de traducir tu código del mundo ideal en el que lo escribes al mundo real en el que se ejecuta, y esa traducción tiene tres capítulos que ahora sabes nombrar. Reduce las peticiones y mata la cascada de descubrimiento, porque la profundidad del grafo, gratis en localhost, se cobra en rondas de red que se apilan. Aplica optimizaciones que solo existen a escala del grafo completo —tree shaking, scope hoisting, minificación, compresión—, imposibles de ver módulo a módulo. Y normaliza la compatibilidad, resolviendo especificadores que el navegador no entiende, convirtiendo CommonJS a ESM, rebajando sintaxis e integrando assets. Quien interioriza esto deja de ver el build de producción como un ritual opaco y empieza a verlo como lo que es: el puente entre dos físicas. Y quien sabe que ese puente tiene un coste aprende también su reverso —cuándo el puente es innecesario porque el abismo entre dev y prod, en su caso concreto, es lo bastante estrecho como para cruzarlo a pie—. No empaquetas por costumbre; empaquetas porque el mundo real castiga lo que localhost perdona.
- Abre la pestaña de red del navegador en modo dev y cuenta cuántas peticiones de módulos hace tu app al cargar; observa la cascada en el waterfall.
- Activa la limitación de red (throttling) a una latencia realista y recarga: mide cuánto tarda la cascada que en localhost era invisible.
- Construye la versión de producción y repite la medición; cuenta ahora las peticiones y compara el tiempo hasta interactivo.
- Inspecciona el bundle de producción y localiza una dependencia que en su origen era CommonJS: verás que llega convertida a ESM.
- Busca en tu código un export que no uses en ningún sitio y comprueba si aparece en el bundle de producción o si el tree shaking lo eliminó.