Las cuatro fases de un byte de JavaScript: descarga, parseo, compilación, ejecución
El recorrido completo de un script desde que sale del servidor hasta que produce efecto, en qué hilo ocurre cada fase, y cuál domina el coste según el tipo de código.
El presupuesto de JavaScript de casi todos los equipos se expresa en kilobytes transferidos, porque es lo único que la herramienta de compilación mide sin esfuerzo. Es una métrica engañosa: los bytes son solo la primera de cuatro fases, y en un dispositivo real no suele ser la más cara. Un fichero que se descarga en 200 milisegundos puede costar 900 milisegundos de hilo principal después, y esos 900 milisegundos son los que el usuario siente como una página que no responde.
- Enumerar las cuatro fases del coste de un script y en qué hilo transcurre cada una.
- Explicar por qué la compresión reduce una fase y no toca las otras tres.
- Identificar qué fase domina según el tipo de código que envías.
- Medir las cuatro fases por separado en un perfil real.
Las cuatro fases
Un <script> con defer pasa por esto:
Fase 1: descarga. Los bytes viajan por la red. Cuesta el tamaño comprimido dividido entre el ancho de banda efectivo, más al menos un viaje de ida y vuelta. Ocurre en el hilo de red, en paralelo con todo lo demás, y es la única fase que la compresión mejora.
Fase 2: parseo. El motor lee el texto y construye un árbol sintáctico. Trabaja sobre el tamaño descomprimido, que para JavaScript minificado suele ser entre 3 y 4 veces el comprimido con Brotli. Ocurre parcialmente en un hilo secundario en los motores modernos, pero la parte que no puede paralelizarse cae en el hilo principal.
Fase 3: compilación. El árbol se convierte en bytecode ejecutable. Después, si una función se ejecuta muchas veces, el compilador optimizador la vuelve a compilar a código máquina, y si sus suposiciones fallan la desoptimiza y vuelve a empezar. Esta fase es la que la caché de código puede saltarse en visitas posteriores.
Fase 4: ejecución. El bytecode corre. Aquí es donde el módulo se evalúa, se registran los observadores, se construyen los componentes, se llama a las APIs del navegador. En una aplicación con marco, esta fase suele ser la más cara de las cuatro, con diferencia.
flowchart TB R[Bytes en la red] -->|hilo de red| D[Fase 1 descarga] D -->|tamano comprimido| P[Fase 2 parseo] P -->|tamano descomprimido| C[Fase 3 compilacion] C --> E[Fase 4 ejecucion] E --> DOM[Efectos en el DOM y en el estado] P -.-> HP[Hilo principal bloqueado] C -.-> HP E -.-> HP style D fill:#89b4fa,color:#11111b style P fill:#f9e2af,color:#11111b style C fill:#f9e2af,color:#11111b style E fill:#f38ba8,color:#11111b style HP fill:#f38ba8,color:#11111b style DOM fill:#a6e3a1,color:#11111b
La lectura importante del diagrama es la línea de puntos: tres de las cuatro fases compiten por el hilo principal, y el hilo principal es el mismo que pinta, el mismo que responde a los clics y el mismo que ejecuta las animaciones. La descarga no molesta a nadie; las otras tres bloquean la página.
La compresión solo toca la primera fase
Este es el malentendido operativo más caro del tema. Cuando pasas de gzip a Brotli y tu bundle baja de 210 KB a 175 KB, has mejorado la fase 1 en un 17 por ciento y no has cambiado absolutamente nada de las fases 2, 3 y 4, porque el motor descomprime antes de parsear y trabaja siempre sobre el mismo texto.
La consecuencia práctica: si tu problema es que la página tarda dos segundos en responder después de que todo haya llegado, la compresión no es la respuesta. Es un problema de cantidad de código, y la única solución es enviar menos.
Las magnitudes ayudan a interiorizarlo. Un bundle típico de aplicación con marco:
| Medida | Valor |
|---|---|
| Transferido con Brotli | 180 KB |
| Descomprimido (lo que parsea el motor) | 640 KB |
| Relación de compresión | ~3,5x |
Ese 3,5x es estable para JavaScript minificado y es el número que hay que tener en la cabeza. Cuando alguien dice «solo son 180 KB de JavaScript», el motor está viendo 640 KB de texto. Y el trabajo de parseo, compilación y ejecución escala con esos 640, no con los 180.
Es exactamente lo contrario de lo que pasa con una imagen, y ese contraste es el tema de la comparación byte a byte.
Qué fase domina según el tipo de código
No todo el JavaScript cuesta igual. La distribución del coste entre fases cambia mucho con la forma del código, y saber en cuál estás determina qué optimización sirve.
Código con mucha declaración y poca ejecución inicial. Una biblioteca de utilidades, un conjunto grande de funciones de las que se usan tres. Aquí domina el parseo, y el motor tiene una defensa: la compilación perezosa, que ahorra la mayor parte del trabajo si el código está bien estructurado. Ver parseo y compilación perezosa.
Código con mucho trabajo en el nivel superior. Módulos que construyen tablas, registran componentes, crean instancias, ejecutan expresiones inmediatas al importarse. Aquí domina la ejecución y la compilación perezosa no ayuda, porque todo se ejecuta sí o sí. Es el patrón más caro y el más frecuente en aplicaciones con marcos que usan decoradores o registros globales.
Código generado y con mucha redundancia. Salida de un transpilador con objetivo antiguo, mucha función auxiliar repetida, mucho polyfill. Domina el parseo por volumen puro. Es el caso donde cambiar el objetivo de compilación produce mejoras de dos cifras porcentuales sin tocar una línea de código propio.
Código que corre en bucle sobre datos. Cálculo, ordenación, transformación de listas grandes. Domina la ejecución, y el compilador optimizador entra en juego. Aquí las fases 1 a 3 son ruido y toda la conversación es sobre algoritmos y sobre sacar el trabajo del hilo principal.
Medir las cuatro por separado
Se pueden medir todas, y conviene hacerlo una vez sobre tu bundle real en lugar de razonar con medias del sector.
La descarga sale del registro de recursos, que da el desglose exacto de la petición:
const scripts = performance.getEntriesByType('resource')
.filter(r => r.initiatorType === 'script');
for (const s of scripts) {
console.log(s.name.split('/').pop(), {
kbTransferidos: Math.round(s.transferSize / 1024),
kbDescomprimidos: Math.round(s.decodedBodySize / 1024),
msDescarga: Math.round(s.responseEnd - s.requestStart),
});
}
decodedBodySize es la cifra que casi nadie mira y es la que importa para las fases 2 y 3. Compárala con transferSize y tendrás tu relación de compresión real.
Parseo y compilación aparecen en el panel de rendimiento del navegador como eventos propios dentro del árbol de llamadas del script: hay entradas específicas para la compilación de script y para la compilación de módulo. Si activas la agrupación por actividad en el resumen inferior, salen agregadas.
La ejecución es el resto: el tiempo dentro de la evaluación del módulo, con su árbol de llamadas completo.
Y para un número único que sirva de barómetro, la métrica agregada más útil es el tiempo total de bloqueo, que es lo que verás en detalle en el nivel del hilo principal. Un bundle que suma más de 300 milisegundos de bloqueo en un dispositivo de gama media tiene un problema de cantidad, no de compresión.
Las cuatro fases explican el coste de arranque. Hay una quinta que no aparece en ninguna gráfica de carga y que en aplicaciones de larga sesión acaba siendo la dominante: el código compilado ocupa memoria y esa memoria no se libera.
Los números concretos: el bytecode que genera un motor moderno ocupa aproximadamente entre 2 y 4 veces el tamaño del fuente descomprimido, y el código optimizado de las funciones calientes se suma encima. Un bundle de 640 KB descomprimidos puede dejar entre 1,5 y 2,5 MB de estructuras del motor vivas mientras la pestaña esté abierta. En un móvil de gama baja con 3 GB de RAM y ocho pestañas, esa cifra es la diferencia entre que tu pestaña sobreviva en segundo plano o el sistema la mate y el usuario tenga que recargar la página entera al volver.
Hay dos consecuencias prácticas que casi nadie conecta con esto.
La primera: la recarga de pestaña descartada es un evento de rendimiento invisible en tus métricas. El usuario vuelve a tu pestaña, el navegador la recarga, y esa recarga se contabiliza como una visita nueva con sus propias métricas de carga, no como una regresión. Si tienes una tasa alta de sesiones muy cortas seguidas de otra sesión al mismo destino, sospecha de esto.
La segunda: el coste de memoria escala con el código cargado, no con el código ejecutado. Un bundle que carga cincuenta rutas por si acaso paga la memoria de las cincuenta aunque el usuario visite una. Esto le da al troceado por rutas un argumento adicional al de los milisegundos, y es el que suele convencer en móvil.
La forma de medirlo, en navegadores que la exponen y con el sitio aislado entre orígenes, es la API de memoria específica del agente de usuario, que devuelve un desglose por tipo:
if (crossOriginIsolated && performance.measureUserAgentSpecificMemory) {
const r = await performance.measureUserAgentSpecificMemory();
console.log('bytes totales', r.bytes);
console.table(r.breakdown.filter(b => b.bytes > 0));
}Requiere aislamiento entre orígenes, es decir, las mismas cabeceras que hacen falta para memoria compartida. Si no puedes ponerlas, el sustituto aproximado es el perfilador de montículo del navegador, comparando el tamaño retenido antes y después de cargar un módulo.
Sobre tu página real, saca la tabla de las cuatro fases para tu bundle principal: transferido, descomprimido, milisegundos de descarga, milisegundos de compilación de script y milisegundos de evaluación, todo con la CPU estrangulada a 4x. Después calcula qué porcentaje del total se lleva la descarga. Si es menos del 25 por ciento, cualquier trabajo que hagas sobre compresión o sobre la CDN tiene un techo del 25 por ciento, y el trabajo útil está en otra parte.