wandres.dev
JAVASCRIPT I · El coste real de un byte de JS

Un byte de JavaScript frente a un byte de imagen

La comparación directa entre dos recursos del mismo peso, con el desglose de qué hace el navegador con cada uno, cuánto hilo principal consume y qué queda después. La lección que cambia las prioridades de un equipo.

⏱ 20 min

Casi todos los equipos que empiezan a preocuparse por el rendimiento atacan primero las imágenes, porque son lo más grande de la lista de recursos y porque optimizarlas no exige tocar la arquitectura. Es una elección racional y suele estar equivocada. Un kilobyte de JavaScript y un kilobyte de imagen se parecen en el informe de red y no se parecen en nada más: uno se descodifica fuera del hilo principal y desaparece del presupuesto, el otro se parsea, se compila, se ejecuta y deja residuo permanente. Esta lección pone los dos lado a lado con números.

🎯 Al terminar esta lección sabrás
  • Desglosar el coste completo de 300 KB de imagen y de 300 KB de JavaScript en el mismo dispositivo.
  • Explicar por qué la segunda visita elimina el coste de una y casi no toca el de la otra.
  • Calcular la equivalencia práctica en milisegundos entre ambos recursos.
  • Usar la comparación para reordenar el trabajo de optimización de un equipo.

El experimento

Dos recursos del mismo peso transferido, servidos a la misma página, medidos en el mismo dispositivo. Uno es una fotografía de 1600 por 900 en AVIF que pesa 300 KB. El otro es un módulo de JavaScript minificado y comprimido con Brotli que pesa 300 KB.

El dispositivo de referencia es un Android de gama media de los que se venden por debajo de 250 euros, que es aproximadamente lo que emula el estrangulamiento de CPU a 4x sobre un portátil de desarrollo. Las cifras que siguen son órdenes de magnitud reproducibles, no constantes universales: al final de la lección tienes el procedimiento para medir las tuyas, y medirlas es el punto, porque el número de tu equipo convence infinitamente más que el mío.

El desglose lado a lado

Coste 300 KB de imagen AVIF 300 KB de JavaScript
Descarga a 1,6 Mbps ~1,5 s ~1,5 s
Bytes que procesa el motor 300 KB ~1.050 KB descomprimidos
Parseo No aplica 150-350 ms
Compilación No aplica 100-250 ms
Ejecución / decodificación 25-60 ms, fuera del hilo principal 400-900 ms, en el hilo principal
Bloqueo del hilo principal ~0 ms con decoding="async" 650-1.500 ms
Memoria residual Ancho por alto por 4 bytes, liberable 2-4 MB de bytecode, permanente
Si el recurso falla Un hueco con texto alternativo La página no funciona
Coste en la segunda visita 0 ms, sale de caché 500-1.100 ms, se vuelve a ejecutar

Las filas que importan son cuatro, y merecen desarrollarse una a una.

La relación de compresión. Una imagen en AVIF, WebP o JPEG ya está comprimida en su formato; la compresión HTTP encima no le hace nada, y por eso los servidores bien configurados ni siquiera lo intentan. Los 300 KB que viajan son los 300 KB que hay. El JavaScript, en cambio, es texto: Brotli le saca un factor de 3 a 4, así que esos mismos 300 KB en la red son más de un megabyte de texto que el motor tiene que leer carácter a carácter. Ya solo en volumen de trabajo, hay un factor de tres y medio antes de empezar.

El hilo donde ocurre. La decodificación de una imagen se hace en un hilo secundario en todos los navegadores modernos, y con decoding="async" el pintado ni siquiera espera a que termine. Su coste no aparece en el tiempo de bloqueo y no retrasa ninguna interacción. Todo el trabajo de JavaScript posterior a la descarga —parseo, compilación y ejecución— ocurre en el hilo principal, que es el mismo que responde al usuario. Son 300 KB que no cuestan nada frente a 300 KB que congelan la página durante casi un segundo.

El residuo. La imagen, una vez pintada, se puede descartar de memoria y volver a descodificar si hace falta; el navegador lo gestiona solo y libera bajo presión. El bytecode del módulo se queda vivo mientras la pestaña exista, ocupando entre dos y cuatro veces el tamaño del fuente descomprimido. Y no hay forma de pedirle al motor que lo suelte.

La segunda visita. Aquí está el argumento que cierra la discusión. En una visita repetida con la caché caliente, la imagen cuesta exactamente cero: sale del disco, se pinta, no hay nada más que hacer. El JavaScript ahorra la descarga y parte de la compilación gracias a la caché de código, pero la ejecución se repite entera, siempre. Ese medio segundo largo lo paga el usuario en cada visita, para siempre, cada vez.

La equivalencia práctica

De la tabla sale un factor de conversión mental que es útil aunque sea tosco: en términos de trabajo del hilo principal, un kilobyte de JavaScript cuesta del orden de 20 a 50 veces lo que un kilobyte de imagen. Con la segunda visita en cuenta, el factor se dispara porque el denominador tiende a cero.

Traducido a decisiones concretas:

  • Quitar 50 KB de JavaScript de la ruta crítica libera entre 100 y 250 milisegundos de hilo principal en un móvil de gama media. Cada visita.
  • Quitar 500 KB de imagen ahorra 2,5 segundos de descarga en una red lenta, mejora el LCP si esa imagen era el elemento mayor, y no libera prácticamente nada de hilo principal.

Las dos cosas hay que hacerlas, y son problemas distintos. El error es tratarlas como si fueran el mismo problema medido en la misma unidad. La imagen es un problema de ancho de banda; el JavaScript es un problema de CPU. Y el ancho de banda ha mejorado un orden de magnitud en quince años mientras que el rendimiento monohilo de los teléfonos baratos apenas se ha movido.

De ahí sale la regla de prioridad que ordena el trabajo:

  1. Si el problema es el LCP, ataca primero la imagen del elemento mayor y su descubrimiento. La red domina.
  2. Si el problema es el INP o la sensación de página congelada, ataca el JavaScript. La CPU domina, y ninguna cantidad de optimización de imágenes lo va a arreglar.
  3. Si el problema es que la página va lenta en móvil y bien en tu portátil, es JavaScript. Siempre.
La comparación se vuelve absurda cuando el JavaScript existe para cargar la imagen

Hay un patrón que aparece en la mitad de los sitios con imágenes y que convierte esta comparación en algo peor que un empate: usar una biblioteca de JavaScript para hacer lo que el navegador hace nativo.

El caso concreto. Una biblioteca de carga diferida de imágenes pesa entre 2 y 8 KB comprimidos. Parece barato: son 8 KB frente a los cientos que ahorra. Pero la cuenta real es otra.

Esos 8 KB comprimidos son unos 28 KB de texto, con un coste de parseo, compilación y ejecución de entre 40 y 120 milisegundos en un móvil de gama media. Hasta ahí, aceptable. El problema es lo que la biblioteca obliga a hacer con el HTML: las imágenes pasan a tener data-src en lugar de src, y en el momento en que haces eso, el escáner de precarga deja de verlas. El escáner es el que arranca las descargas cientos de milisegundos antes de que exista el DOM. Al desactivarlo, cada imagen arranca su descarga después de que el JavaScript se haya descargado, parseado, compilado, ejecutado y registrado sus observadores. En una red móvil eso son entre 500 y 1.500 milisegundos de retraso en todas las imágenes de la página, incluida la del LCP.

Es decir: 8 KB de JavaScript cuestan más, en tiempo real de usuario, que los 300 KB de imagen del ejemplo. Y el atributo loading="lazy" hace lo mismo, gratis, sin desactivar el escáner y sin ejecutar nada.

El mismo razonamiento se aplica a tres patrones más que vale la pena reconocer al vuelo:

  • Carruseles con biblioteca que podrían ser scroll-snap de CSS: entre 15 y 40 KB comprimidos sustituibles por diez líneas de hoja de estilos.
  • Detección de soporte de formato de imagen en JavaScript para elegir entre WebP y JPEG: lo hace <picture> con type, en el escáner de precarga, sin código.
  • Polyfills de object-fit o de aspect-ratio heredados de configuraciones de hace años, que siguen en el bundle sirviendo a navegadores que ya no existen.

La lección general: cada vez que valores meter JavaScript para optimizar recursos, calcula el coste en las cuatro fases y comprueba si desactivas alguna optimización que el navegador ya te daba. La segunda parte es la que suele decidir, y es la que nadie mira.

Medir esto en tu propia página

El argumento convence cuando los números son de tu producto. El procedimiento es de veinte minutos.

Primero, el desglose de bytes de tus scripts frente a tus imágenes, con la relación de compresión real:

const porTipo = { script: { t: 0, d: 0 }, img: { t: 0, d: 0 } };
for (const r of performance.getEntriesByType('resource')) {
  const k = r.initiatorType === 'script' ? 'script'
          : (r.initiatorType === 'img' || /\.(avif|webp|jpe?g|png)$/.test(r.name)) ? 'img'
          : null;
  if (!k) continue;
  porTipo[k].t += r.transferSize;
  porTipo[k].d += r.decodedBodySize;
}
console.table({
  script: { transferido_KB: (porTipo.script.t / 1024) | 0, procesado_KB: (porTipo.script.d / 1024) | 0 },
  imagen: { transferido_KB: (porTipo.img.t / 1024) | 0, procesado_KB: (porTipo.img.d / 1024) | 0 },
});

La columna procesado_KB de la fila de scripts es la cifra que hay que llevar a la reunión. Suele ser tres o cuatro veces mayor de lo que todo el mundo tenía en la cabeza.

Segundo, el tiempo de hilo principal. Graba un perfil de carga con la CPU estrangulada a 4x y usa el resumen por categoría: la porción de «scripting» frente a la de «pintura» y «renderizado». En una aplicación con marco es habitual ver una relación de ocho a uno a favor del scripting.

Tercero, y es el que produce el silencio en la sala: repite la medición con la caché caliente. Recarga sin vaciar caché y vuelve a mirar el resumen. Las imágenes desaparecen del perfil. El scripting se queda casi igual.

⚔️ Reto práctico

Prepara dos versiones de una página de prueba: una con 300 KB de imagen y sin JavaScript, otra con una imagen de 10 KB y 300 KB de JavaScript que solo declare funciones y llame a una. Mide en las dos, con CPU a 4x y red a 3G rápido: LCP, tiempo total de bloqueo y tiempo hasta que un botón responde a un clic. Después repite con la caché caliente. Lleva la tabla de las seis mediciones a tu equipo; es más eficaz que cualquier argumento.