Leer las cabeceras de caché en el panel
Las seis cabeceras que deciden si un recurso se guarda, cuánto vive y cómo se revalida, y cómo leer en el panel de red qué caché sirvió cada respuesta.
La caché del navegador no es una función que se active: es el resultado de un contrato escrito en cabeceras HTTP que el servidor emite y el navegador interpreta con reglas estrictas. Cuando alguien dice que “la caché no funciona”, casi siempre lo que ocurre es que el contrato dice exactamente lo que se está observando y nadie lo ha leído. El panel de red muestra ese contrato entero para cada respuesta, y saber leerlo convierte un problema difuso en una línea concreta de configuración.
- Interpretar
Cache-Control,ETag,Last-Modified,Vary,AgeyExpiresen una respuesta real. - Distinguir en el panel qué respuestas vinieron de memoria, de disco, de un service worker o de la red.
- Diferenciar una revalidación con 304 de una descarga completa y saber cuál cuesta más.
- Auditar desde la consola las cabeceras de caché de todos los recursos de una página.
El contrato en seis cabeceras
Abre el panel de red, selecciona una petición y ve a su pestaña de cabeceras. La sección de cabeceras de respuesta contiene el contrato completo. Seis campos deciden todo lo demás.
Cache-Control es la cabecera principal y la única que conviene emitir siempre. Sus directivas se combinan y algunas parecen sinónimas sin serlo. max-age=N declara cuántos segundos la respuesta se considera fresca; mientras lo esté, el navegador la sirve sin preguntar nada. no-cache no significa que no se guarde: significa que se guarda pero se revalida siempre antes de usarla. no-store es la que de verdad prohíbe guardar, y es la que hay que poner en respuestas con datos personales. must-revalidate prohíbe servir la copia caducada aunque la red falle. immutable promete que el contenido de esa URL no cambiará jamás, y hace que el navegador ni siquiera revalide al recargar. private prohíbe a las cachés compartidas guardar la respuesta y public la autoriza explícitamente incluso en casos donde por defecto no se guardaría.
ETag es un identificador opaco del contenido. En la siguiente petición el navegador lo devuelve en If-None-Match y el servidor responde 304 Not Modified si sigue siendo válido.
Last-Modified es el validador débil equivalente, con resolución de un segundo. El navegador lo devuelve en If-Modified-Since. Si hay ETag, manda el ETag.
Vary declara de qué cabeceras de petición depende la respuesta. Una respuesta con Vary: Accept-Encoding se guarda por separado para cada codificación. Es la cabecera que más silenciosamente destruye tasas de acierto: Vary: User-Agent fragmenta la caché en miles de variantes y Vary: * la desactiva por completo.
Age dice cuántos segundos lleva la respuesta guardada en una caché intermedia. Restado de max-age da la frescura que le queda de verdad. Un Age cercano a max-age explica por qué una respuesta que crees cacheada vuelve a viajar.
Expires es el mecanismo antiguo, con fecha absoluta. Si hay Cache-Control: max-age, se ignora. Su único valor hoy es documental: verla sola en una respuesta indica una configuración de servidor que nadie ha tocado en muchos años.
La columna de tamaño del panel es el primer indicador de caché y no hace falta abrir nada para leerla. Cuando muestra un texto en lugar de un número —memoria, disco, service worker, prefetch— la respuesta no vino de la red. Cuando muestra dos líneas, la de arriba es lo que viajó por el cable y la de abajo el tamaño descomprimido.
Qué caché sirvió cada respuesta
Chrome tiene varias cachés apiladas y el panel las distingue. Merece la pena saber cuál es cuál porque se vacían de formas distintas.
| Lo que muestra el panel | Qué caché es | Cuándo se pierde |
|---|---|---|
| Un número de bytes | Vino de la red | Siempre viaja |
(memory cache) |
Caché en memoria del proceso de render | Al cerrar la pestaña |
(disk cache) |
Caché HTTP en disco | Al vaciar datos de navegación |
(service worker) |
La respondió un worker | Según lo que haga el worker |
(prefetch cache) |
Se pidió por adelantado | Tras unos minutos sin usarse |
Estado 304 con tamaño pequeño |
Se revalidó y se reusó la copia | Es el camino de la revalidación |
La distinción entre memoria y disco explica un comportamiento que confunde mucho: la misma imagen puede aparecer como servida desde memoria durante una navegación y desde disco después de recargar. No es una inconsistencia, son dos capas. La caché en memoria vive dentro del proceso de la pestaña, ignora buena parte de las directivas y desaparece al cerrar. La de disco es la caché HTTP de verdad, la que respeta el contrato completo.
El estado 304 merece atención aparte porque la gente lo lee como un acierto de caché y solo lo es a medias. Un 304 significa que la petición viajó: hubo resolución de nombres si tocaba, conexión si no había ninguna abierta, y un viaje de ida y vuelta completo hasta el servidor. Lo único que se ahorró fue el cuerpo. En una conexión con doscientos milisegundos de latencia, un 304 de un fichero de dos kilobytes cuesta prácticamente lo mismo que descargarlo entero. Por eso una estrategia basada solo en revalidación es mucho peor de lo que sugiere el número de aciertos: la forma correcta para recursos versionados es max-age largo con immutable, para que no haya ni siquiera revalidación.
Auditar toda la página de una vez
Mirar cabeceras petición a petición sirve para investigar una; para diagnosticar una página entera hace falta una vista agregada. Este fragmento vuelve a pedir cada recurso con el método HEAD y tabula el contrato de todos.
// Auditoria de cabeceras de cache de todos los recursos de la pagina
(async () => {
const urls = [...new Set(
performance.getEntriesByType('resource')
.map(e => e.name)
.filter(u => u.startsWith('http'))
)].slice(0, 60);
const filas = await Promise.all(urls.map(async url => {
try {
const r = await fetch(url, { method: 'HEAD', cache: 'no-store' });
const cc = r.headers.get('cache-control') || '';
const maxAge = /max-age=(\d+)/.exec(cc)?.[1];
return {
recurso: url.split('/').pop().split('?')[0].slice(0, 34) || url,
cacheControl: cc || '(ninguna)',
dias: maxAge ? +(maxAge / 86400).toFixed(1) : null,
etag: r.headers.get('etag') ? 'si' : 'no',
lastModified: r.headers.get('last-modified') ? 'si' : 'no',
vary: r.headers.get('vary') || '',
edad: r.headers.get('age') || ''
};
} catch {
return { recurso: url.slice(0, 34), cacheControl: '(error o CORS)' };
}
}));
const sinCache = filas.filter(f => f.cacheControl === '(ninguna)');
const cortos = filas.filter(f => f.dias !== null && f.dias < 1);
console.table(filas);
console.log('Sin Cache-Control:', sinCache.length, 'de', filas.length);
console.log('Con menos de un dia de frescura:', cortos.length);
})();
Dos advertencias sobre este fragmento, porque las dos son fuentes de conclusiones falsas. La primera es que HEAD puede devolver cabeceras distintas de GET en servidores mal configurados; si un resultado te sorprende, confírmalo en el panel. La segunda es que los recursos de otros orígenes sin cabeceras CORS aparecerán como error, lo cual no dice nada sobre su caché: simplemente no puedes leer sus cabeceras desde JavaScript. Para esos, el panel sí las muestra, porque el panel no está sujeto a la política de mismo origen.
Los dos regímenes de caché
Todo el diseño de una política de caché se reduce a clasificar cada URL en uno de dos regímenes, y casi todos los problemas vienen de aplicar el régimen equivocado.
Régimen de contenido inmutable. La URL incluye un identificador derivado del contenido, típicamente un hash en el nombre del fichero. Si el contenido cambia, cambia la URL. Estos recursos se sirven con Cache-Control: public, max-age=31536000, immutable: un año y sin revalidación. No hay riesgo de servir contenido viejo porque una URL vieja apunta a un contenido que efectivamente no ha cambiado.
Régimen de contenido mutable. La URL es estable y el contenido cambia: el documento HTML, un punto de entrada de API, un fichero de configuración. Estos se sirven con Cache-Control: no-cache más un ETag fuerte, o con un max-age muy corto más stale-while-revalidate si se tolera un poco de retraso a cambio de respuestas instantáneas.
El error clásico es el híbrido: un fichero con URL estable y max-age de un día. Durante veinticuatro horas los usuarios que ya lo tienen no verán el cambio, y no hay forma de forzarlos porque el navegador no va a preguntar. Es el bug que produce la frase “a mí ya me sale bien, a él no” durante justo un día después de cada despliegue.
Hay una propiedad de las cabeceras de caché que explica por qué los errores de caché son tan caros y por qué se corrigen tan mal, y es que actúan hacia el futuro sobre copias que ya están fuera de tu alcance. Si desplegaste un HTML con max-age=86400 el lunes, el martes puedes cambiar la configuración del servidor todo lo que quieras: los navegadores que se llevaron la copia del lunes no van a preguntar nada hasta el martes a la misma hora. No hay purga, no hay invalidación, no hay panel de administración. La caché del navegador no es tuya, está en el disco de otra persona y solo obedece a lo que le dijiste cuando se la llevó. La consecuencia operativa es que cualquier despliegue con caché mal configurada tiene un coste que se paga durante todo el periodo de frescura que declaraste, y por eso la regla práctica en producción es asimétrica: subir un max-age es una decisión reversible en un día y bajarlo es reversible solo después de que expire el anterior. Esto tiene dos corolarios que conviene aplicar desde el primer día de un proyecto. El primero: empieza corto y sube. Un recurso mutable con no-cache y ETag es siempre seguro; si mides que la revalidación duele, entonces subes. Al revés, un año de immutable en una URL sin hash es un error del que no se sale. El segundo: la única defensa real contra un HTML mal cacheado es no cachearlo nunca en el navegador. El documento es el recurso que orquesta todos los demás; si se queda viejo, apunta a URLs de scripts viejas y toda la versión nueva es invisible. Ponle no-cache, deja que la caché de disco lo guarde y que un ETag decida en un viaje de ida y vuelta si sirve. Ese viaje cuesta unos milisegundos y compra la capacidad de desplegar. Y si eso te parece caro, la respuesta correcta no es alargar el max-age del documento sino poner una CDN delante con s-maxage alto e invalidación en el despliegue, porque la caché compartida sí es tuya y sí se puede purgar.