wandres.dev
NETWORK III · Cabeceras, caché y seguridad

Por qué esto no se cachea: las nueve causas

El procedimiento de diagnóstico cuando un recurso vuelve a viajar en cada carga, ordenado por frecuencia real y con la comprobación concreta de cada causa.

⏱ 17 min

Una petición que debería salir de la caché y sale de la red es un síntoma con nueve causas posibles, y solo dos de ellas tienen que ver con las cabeceras que emite el servidor. Las otras siete están repartidas entre la propia configuración de las DevTools, la forma en que el código pide el recurso, la URL, y decisiones del navegador que nadie documenta en la respuesta. Diagnosticarlas en orden de frecuencia ahorra la mitad del tiempo.

🎯 Al terminar esta lección sabrás
  • Aplicar el orden de comprobación correcto ante un recurso que no se cachea.
  • Identificar las causas que están en el cliente y no en las cabeceras del servidor.
  • Reconocer cuándo el fallo es de la URL y no de la política.
  • Verificar una hipótesis de caché sin depender de recargar a mano.

El orden de comprobación

El orden no es arbitrario: está ordenado por coste de comprobación creciente y por frecuencia decreciente, que es el mismo criterio que gobierna todo diagnóstico. Las tres primeras causas se comprueban en menos de diez segundos y cubren más de la mitad de los casos.

Causa 1: tienes la casilla de desactivar caché marcada. Es la primera por una razón estadística incómoda: quien está mirando el panel de red suele tenerla activada de una sesión anterior, y con ella marcada nada se cachea, por diseño. Antes de investigar cualquier otra cosa, mira el estado de esa casilla. Recuerda además que solo hace efecto mientras las DevTools están abiertas, así que el comportamiento que estás observando ni siquiera es el que tienen tus usuarios. Esto se trató a fondo en desactivar la caché con criterio y sigue siendo la causa número uno.

Causa 2: la URL cambia en cada carga. Un recurso pedido con un parámetro de invalidación generado en tiempo de ejecución, con una marca de tiempo o con un identificador aleatorio, es una URL distinta cada vez y por tanto una entrada de caché distinta cada vez. Se ve de un vistazo comparando la columna de nombre entre dos cargas: si la parte de consulta cambia, no hay más que investigar. Ocurre mucho con recursos añadidos por librerías de terceros y con imágenes que llevan un parámetro de tamaño calculado.

Causa 3: el código pide explícitamente saltarse la caché. La opción cache de fetch tiene valores que anulan el contrato entero. no-store prohíbe guardar y consultar; reload obliga a ir a la red aunque haya copia fresca; no-cache fuerza revalidación siempre. Muchas capas de acceso a datos ponen cache: 'no-store' por defecto para evitar respuestas rancias en llamadas de API, y esa configuración se acaba aplicando a peticiones donde no tenía sentido. La comprobación es buscar en el código o poner un breakpoint de red sobre la URL y leer las opciones en el marco que la origina.

Las causas que están en la respuesta

Causa 4: la respuesta lleva no-store. Es la comprobación obvia y va la cuarta precisamente porque es la que todo el mundo mira primero, y rara vez es la respuesta. Cuando lo es, el arreglo está en el servidor.

Causa 5: la respuesta no lleva Cache-Control ni Expires ni validadores. Este caso es más sutil que el anterior porque no hay ninguna directiva prohibiendo nada; simplemente no hay contrato. Sin información de frescura, la mayoría de las respuestas no se guardan, y las que se guardan lo hacen con una heurística basada en Last-Modified que ni es predecible ni se puede documentar. Una respuesta sin Cache-Control no está “cacheada por defecto”: está a merced de una heurística.

Causa 6: Vary fragmenta la caché. La respuesta se guarda perfectamente, pero la entrada guardada no coincide con la petición siguiente porque alguna de las cabeceras listadas en Vary cambió. Vary: User-Agent es el caso patológico clásico. Vary: Accept-Encoding es correcto y necesario. Vary: Cookie en un recurso estático es un error que convierte cada usuario en un espacio de caché propio. Vary: * desactiva la caché sin decirlo.

Causa 7: hay una caché intermedia que ya agotó la frescura. La respuesta llega con max-age=600 y Age: 597. Le quedan tres segundos de vida. La copia es legítima, el contrato es correcto, y aun así vuelve a viajar casi inmediatamente. Se ve solo mirando Age, y es la causa que más veces se diagnostica mal como “el servidor no está mandando bien las cabeceras”.

Las causas que no están en ninguna cabecera

Causa 8: el método o el estado no son cacheables. Solo las respuestas a peticiones seguras se guardan en la práctica. Una llamada POST no se cachea aunque su respuesta lleve max-age de un año. Es la causa de que una API que devuelve datos idénticos vuelva a viajar siempre: no es la caché, es el verbo. La solución cuando importa es convertir la consulta en GET con los parámetros en la URL, con el límite de longitud y de privacidad que eso impone.

Causa 9: la caché de disco desalojó la entrada. La caché HTTP tiene un tamaño finito y una política de desalojo que nadie controla desde la aplicación. Un recurso grande, poco usado, en un perfil con la caché llena, se descarta. Esta causa no se puede comprobar ni descartar directamente, y por eso está la última: es lo que queda cuando las ocho anteriores se han descartado. Su consecuencia práctica es de diseño, no de diagnóstico: la caché del navegador es una optimización oportunista, no un almacén. Si necesitas garantías, el sitio es la caché de un service worker, que sí es tuya.

⚠️
Cuidado

Una respuesta que ves cacheada en tu navegador puede no estarlo en el de tus usuarios por una razón que no aparece en ninguna cabecera: la caché HTTP está particionada por sitio de primer nivel. Un recurso de un CDN compartido que tu página usa no reutiliza la copia que otro sitio ya descargó. La vieja idea de servir librerías desde un CDN público para aprovechar la caché de otros dejó de funcionar cuando los navegadores particionaron la caché por motivos de privacidad, y hoy solo añade un dominio más que resolver y conectar.

Verificar la hipótesis sin recargar a mano

Recargar y mirar la columna de tamaño es una comprobación válida pero lenta y fácil de contaminar. Esta comprobación programática pide el mismo recurso dos veces y compara: la segunda vez, si la caché funciona, la duración cae a casi cero y el tamaño transferido a cero.

// Comprueba si una URL concreta se sirve realmente desde cache
(async (url) => {
  const medir = async (etiqueta, init) => {
    const t0 = performance.now();
    const r = await fetch(url, init);
    await r.arrayBuffer();
    const ms = performance.now() - t0;
    const e = performance.getEntriesByType('resource').filter(x => x.name === r.url).pop();
    return {
      intento: etiqueta,
      estado: r.status,
      ms: Math.round(ms),
      transferido: e ? e.transferSize : null,
      descomprimido: e ? e.decodedBodySize : null,
      desdeCache: e ? e.transferSize === 0 && e.decodedBodySize > 0 : null
    };
  };

  const a = await medir('primera, forzando red', { cache: 'reload' });
  const b = await medir('segunda, por defecto', {});
  const c = await medir('tercera, por defecto', {});
  console.table([a, b, c]);
  console.log(b.desdeCache
    ? 'La cache funciona: la segunda peticion no transfirio bytes.'
    : 'No se esta cacheando. Revisa las nueve causas en orden.');
})('/favicon.ico');

La señal decisiva es transferSize igual a cero con decodedBodySize mayor que cero: eso solo ocurre cuando la respuesta salió de una caché local. Un transferSize pequeño pero distinto de cero, del orden de unos cientos de bytes, corresponde a una revalidación con 304: viajó la petición y volvieron solo cabeceras. Esa distinción entre cero y “casi cero” es la que separa un acierto de caché real de una revalidación, y en una red con latencia alta son dos cosas completamente distintas.

La pregunta correcta casi nunca es por qué no se cachea sino por qué esa petición existe

Hay una trampa de encuadre en todo este diagnóstico que consume tardes enteras, y es que la pregunta “por qué este recurso no se cachea” da por buena una premisa que muchas veces es falsa: que el recurso tenía que pedirse. La caché es la segunda línea de defensa; la primera es no hacer la petición. Antes de recorrer las nueve causas conviene gastar treinta segundos en una pregunta previa: ¿esta petición debería estar ocurriendo? Hay cuatro patrones que producen tráfico que ninguna política de caché puede arreglar, porque el problema no es que se pida dos veces sino que se pide. El primero es la petición duplicada por montaje doble: dos componentes independientes que piden el mismo dato porque nadie coordinó, y que en la mayoría de los casos ni siquiera se cachean entre sí porque salen simultáneamente y la respuesta de la primera todavía no ha llegado cuando sale la segunda. La caché no ayuda ahí: hace falta deduplicación en vuelo, un mapa de promesas por clave. El segundo es el sondeo periódico que sigue vivo en una pestaña de fondo, pidiendo cada cinco segundos algo que nadie está mirando. El tercero es el recurso que se pide y se descarta, típicamente una imagen a resolución completa que después se sustituye por otra, o una fuente que se carga y no se usa porque el texto que la necesitaba no llegó a renderizarse. El cuarto es la cadena de descubrimiento tardío: un recurso que solo se puede pedir después de que otro se haya descargado y ejecutado, y que por tanto llega tarde independientemente de si estaba en caché o no. Los cuatro se ven en el panel de red mirando la lista completa con ojos de auditor en lugar de mirar una fila con ojos de depurador, y los cuatro tienen soluciones que están en el código y no en la configuración del servidor. La regla que resume todo esto es que una petición cacheada sigue costando: una entrada en la cola, un hueco en el límite de conexiones, memoria, y trabajo de decodificación. Cero peticiones es siempre más rápido que cien peticiones cacheadas, y ese margen no lo da ninguna cabecera.