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

La caché de código: por qué la segunda visita es más rápida y la tercera más todavía

Los tres estados de la caché de código de V8, qué se guarda exactamente y qué no, las condiciones que la invalidan sin que te enteres, y qué parte del coste no se cachea nunca.

⏱ 17 min

La caché HTTP evita volver a descargar. La caché de código evita volver a compilar. Son dos mecanismos independientes, con condiciones de invalidación distintas, y confundirlos lleva a conclusiones erróneas sobre por qué una visita repetida sigue tardando. Lo importante es saber exactamente qué se ahorra y qué no: la caché de código elimina una parte considerable del arranque, y no toca en absoluto la parte que suele dominar.

🎯 Al terminar esta lección sabrás
  • Describir los tres estados de la caché de código y cuándo se produce cada uno.
  • Identificar qué cambios invalidan la caché de código sin invalidar la caché HTTP.
  • Explicar por qué la ejecución nunca se cachea y qué significa eso para el presupuesto.
  • Medir el beneficio real de la caché en tu propia aplicación.

Los tres estados: frío, templado y caliente

La implementación de V8 en Chrome funciona en tres pasadas, y el vocabulario ayuda a pensar.

Primera visita, caché fría. El navegador descarga el script, lo parsea, lo compila y lo ejecuta. Al terminar la ejecución, V8 conserva en memoria el código compilado. No lo guarda en disco todavía, porque muchos scripts se cargan una sola vez y serializar el bytecode de todos ellos sería peor que el ahorro.

Segunda visita dentro de un plazo, caché templada. El navegador reconoce que ya ha visto ese script. Lo compila de nuevo, y esta vez sí serializa el bytecode a disco junto a la entrada de caché HTTP. Esta visita no ahorra tiempo; es la que paga el coste de crear la caché.

Tercera visita y siguientes, caché caliente. El navegador encuentra el bytecode serializado, lo deserializa y se salta el parseo y la compilación por completo. Aquí es donde se ve el ahorro.

Que el beneficio empiece en la tercera visita y no en la segunda sorprende a casi todo el mundo, y explica muchas mediciones confusas. Si estás comparando primera visita contra segunda, no estás midiendo la caché de código. Hay que llegar a la tercera.

La ventana de tiempo también importa: la caché de código está ligada a la entrada de caché HTTP del recurso, y si el recurso se expulsa de la caché HTTP, el bytecode se va con él. En la práctica el plazo útil ronda las 72 horas para scripts que no se vuelven a visitar, aunque los detalles son de implementación y han cambiado.

Qué invalida la caché sin que te enteres

Este es el apartado que produce regresiones silenciosas.

El propio contenido del script. Obvio: si el fichero cambia, la caché no vale. Por eso los nombres con hash del contenido son buenos aquí, igual que para la caché HTTP: cada despliegue invalida solo los ficheros que de verdad cambiaron.

La versión de V8. Cada actualización del navegador invalida todas las cachés de código, porque el formato del bytecode no es estable entre versiones. Con Chrome actualizándose cada cuatro semanas, esto significa que una fracción no despreciable de tus usuarios está siempre en caché fría. No es algo que puedas controlar; es algo que debes tener en cuenta al estimar el beneficio agregado.

Las banderas del motor. Cambian el formato y lo invalidan. Solo relevante para pruebas locales, donde explica resultados incoherentes.

Y el que de verdad muerde: la forma en que se carga el script. El bytecode se cachea para scripts cargados desde una URL con caché HTTP válida. Un script inyectado como texto y evaluado dinámicamente, o un módulo servido desde una URL de blob, o código construido en tiempo de ejecución, no tiene entrada de caché y no puede cachearse nunca. Esto afecta a algunos patrones de carga diferida hechos a mano y a ciertos entornos de micro frontends que serializan módulos.

Hay además una diferencia entre scripts clásicos y módulos que conviene conocer: la caché de código para módulos ES existe y funciona, pero el grafo de dependencias añade su propia resolución, que no se cachea del mismo modo. En bundles con muchos módulos pequeños se nota; en un bundle empaquetado en pocos ficheros, no.

Lo que la caché no ahorra nunca

Aquí está la conclusión que hay que llevarse.

La caché de código elimina el parseo y la compilación. No elimina la ejecución. Y en una aplicación con marco, la ejecución es típicamente entre el 50 y el 75 por ciento del coste total del script.

Vuelve al desglose de las cuatro fases con esto en mente. Un bundle que cuesta 1.100 milisegundos de hilo principal en caché fría, repartidos en 250 de parseo, 200 de compilación y 650 de ejecución, cuesta 650 milisegundos en caché caliente. Ha mejorado un 41 por ciento, lo cual está muy bien, y sigue siendo más de medio segundo de página congelada en cada visita, para siempre.

Esto tiene dos consecuencias operativas.

Primera: no uses la caché como excusa para no reducir el bundle. «Solo es lento la primera vez» es falso. Es menos lento a partir de la tercera, y la parte que queda es la más grande.

Segunda: si tu problema es la ejecución, la solución es estructural. Menos código que corra en el arranque, más trabajo diferido a la primera interacción, más troceado. La compresión no ayuda, la caché ayuda a medias, y solo enviar menos ayuda del todo.

Un service worker puede darte caché caliente en la primera visita, y casi nadie lo aprovecha

La secuencia frío, templado, caliente es la de la caché HTTP normal. Hay una forma de saltársela y es de las cosas que separan a una aplicación bien construida de una normal.

Cuando un service worker guarda una respuesta en el almacén de cachés y ese recurso se sirve después desde ahí, el bytecode compilado se asocia a esa entrada del almacén igual que se asociaría a una entrada de caché HTTP. Es decir, un service worker que precarga tus scripts durante la instalación puede tener el bytecode listo antes de que el usuario haya navegado por segunda vez.

El patrón concreto: en el evento de instalación, pides los scripts críticos y los guardas. La primera navegación después de la instalación ya los sirve desde caché, y para la segunda el bytecode está serializado. Efectivamente adelantas un escalón de la escalera.

// sw.js
const CACHE = 'app-v7';
const CRITICOS = ['/assets/app.js', '/assets/vendor.js', '/assets/rutas.js'];

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(CRITICOS)));
});

self.addEventListener('fetch', (e) => {
  if (e.request.destination !== 'script') return;
  e.respondWith(
    caches.match(e.request).then((r) => r ?? fetch(e.request))
  );
});

Dos matices que hay que respetar o el efecto se pierde.

El primero: el nombre de la caché tiene que cambiar cuando cambian los ficheros, y los ficheros tienen que llevar hash en el nombre. Si sirves /assets/app.js sin hash y actualizas su contenido, tienes usuarios con bytecode compilado de una versión y fuente de otra, y el navegador lo detectará e invalidará, pero habrás pagado el coste sin el beneficio.

El segundo, y este es serio: un service worker mal desplegado es la forma más rápida de romper un sitio para siempre, porque los clientes se quedan sirviendo una versión vieja de todo. No introduzcas uno solo para ganar esto. Si ya tienes uno por otras razones, añadir la precarga de scripts es casi gratis y rinde.

Y un tercer detalle que casi nadie conoce: la API de compilación en flujo hace que V8 empiece a parsear el script mientras se está descargando, en un hilo de fondo. Funciona automáticamente para scripts servidos con Content-Type: text/javascript cargados de forma normal, y se pierde si sirves el script con un tipo MIME incorrecto o lo inyectas desde JavaScript. Otro argumento a favor de que los scripts estén en el HTML y no se inyecten.

Medirlo bien

El procedimiento honesto tiene una trampa que hay que evitar: recargar con las herramientas de desarrollo abiertas y la caché desactivada mide siempre caché fría, y es la configuración por defecto de mucha gente.

Los pasos:

  1. Ventana de incógnito nueva. Carga la página. Visita fría.
  2. Cierra la pestaña, abre otra en la misma ventana, carga otra vez. Visita templada.
  3. Repite. Visita caliente.
  4. En cada una, mide en el panel de rendimiento el tiempo agregado de las categorías de compilación y de evaluación de script.

Lo que debes ver es que el tiempo de compilación cae drásticamente entre la segunda y la tercera, y que el de evaluación se queda plano. Si el de compilación no cae, algo está impidiendo el cacheo: revisa cómo se cargan los scripts y qué cabeceras llevan.

Un indicador rápido y sin perfilador, útil para comprobar que el recurso viene de caché:

for (const r of performance.getEntriesByType('resource')) {
  if (r.initiatorType !== 'script') continue;
  const deCache = r.transferSize === 0 && r.decodedBodySize > 0;
  console.log(r.name.split('/').pop(), deCache ? 'de cache' : 'de red');
}

transferSize a cero con decodedBodySize mayor que cero es la firma de una respuesta servida desde caché sin tocar la red. Es condición necesaria para que haya caché de código, aunque no suficiente.

⚔️ Reto práctico

Ejecuta el protocolo de las tres visitas sobre tu aplicación con la CPU a 4x y anota, para cada una, el tiempo de compilación y el de evaluación por separado. Calcula qué porcentaje del coste total desaparece en la visita caliente. Después estima cuántos de tus usuarios reales llegan a esa tercera visita dentro del plazo útil, usando la distribución de visitas por usuario de tu analítica: ese porcentaje multiplicado por el ahorro es el beneficio real de la caché en tu producto, y suele ser bastante menor de lo que la gente supone.