Caché HTTP: ETag, Cache-Control y 304, medidos
Cache-Control, ETag y Last-Modified en un servidor php -S: 304 medidos, un ETag que nunca coincide y un 304 desactualizado en menos de un segundo.
Serie: curso de HTTP 4/6 (intermedio). Para quien ya sabe leer un intercambio de curl -v y poner headers, y ahora tiene que decidir cuánto tiempo pueden guardar una respuesta los navegadores y los CDN. Al terminar vas a configurar bien Cache-Control, ETag y Last-Modified, vas a responder peticiones condicionales con 304 Not Modified y vas a reconocer los tres bugs que hacen que la caché sea inútil o peligrosamente vieja.
Esta clase es para quien recibe uno de dos tickets: "la app descarga el mismo catálogo de 180 KB cada vez que se abre en el celular" o "después del deploy los usuarios siguen viendo el archivo de ayer". Le pasa al freelancer que mantiene la API de una tienda online y al dev junior en su primer trabajo en una startup. Los dos son problemas de caché, y los dos se reducen a un puñado de headers de respuesta. Todo lo de abajo se ejecutó de verdad en una Mac con Apple Silicon (M1 Pro) con curl 8.7.1, PHP 8.5.6 (php -S) y Python 3.9.6 (http.server), todo en 127.0.0.1, más unas pocas peticiones de solo lectura a example.com y a este sitio.
Las dos preguntas que se hace una caché
Una caché es cualquier copia de una respuesta guardada para después: la caché en disco del navegador, un edge de un CDN, un reverse proxy o el propio cliente HTTP de tu app. Antes de reutilizar una respuesta guardada, se hace dos preguntas.
- ¿Sigue fresca? El servidor dice cuánto tiempo se puede reutilizar una respuesta sin preguntar, casi siempre con
Cache-Control: max-age=<segundos>. Mientras la copia sea más joven que eso, la caché responde sola y el servidor nunca ve la petición. - Si ya no está fresca, ¿cambió? Una copia vieja no se tira. La caché envía una petición condicional: el mismo GET más
If-None-Match: <el ETag que guardó>oIf-Modified-Since: <el Last-Modified que guardó>. Si nada cambió, el servidor responde304 Not Modifiedsin cuerpo, y la caché reutiliza su copia.
Los valores que entrega el servidor para esa segunda pregunta se llaman validadores. Un ETag ("entity tag") es un string opaco entre comillas dobles, como "41455fbfcdb6b7a0", que cambia cada vez que cambia el contenido. Last-Modified es una fecha con resolución de un segundo. Ten presente ese último detalle: causa uno de los bugs de más abajo.
Lo que envía un servidor estático simple
Empieza con los servidores que ya tienes. Generamos un archivo JSON de 179,500 bytes (1,500 productos, random.seed(42)) y lo servimos con el servidor integrado de Python:
cd ~/demo/cache/site
python3 -m http.server 18150 --bind 127.0.0.1
curl -sI http://127.0.0.1:18150/products.json
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:41 GMT
Content-type: application/json
Content-Length: 179500
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT
Hay un Last-Modified, pero no hay ETag ni Cache-Control. El servidor de Python sí responde a If-Modified-Since, así que una revalidación funciona:
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H "If-Modified-Since: Thu, 12 Nov 2026 14:28:40 GMT" http://127.0.0.1:18150/products.json
304 0
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H 'If-None-Match: "abc"' http://127.0.0.1:18150/products.json
200 179500
El servidor integrado de PHP envía todavía menos para un archivo estático: solo Content-Type y Content-Length, sin ningún validador, así que nunca se puede revalidar nada.
Sin Cache-Control, una caché tiene permitido adivinar. La RFC 9111 llama a esto frescura heurística y sugiere, como valor típico, el 10 % del tiempo transcurrido desde Last-Modified. Un archivo que cambió por última vez hace diez días se puede reutilizar entonces durante más o menos un día sin preguntar. Por eso "nunca pusimos ningún header de caché" no significa "no se cachea nada". (El comportamiento del navegador se describe aquí, no se ejecutó: curl no tiene caché propia.)
ETag e If-None-Match con un router de php -S
Ahora escribimos nosotros el lado del servidor. Guarda esto como router.php junto a la carpeta site y arráncalo con cd site && php -S 127.0.0.1:18151 ../router.php:
<?php
// php -S 127.0.0.1:18151 router.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = __DIR__ . '/site/products.json';
if ($path === '/good.json') {
$body = file_get_contents($file);
$etag = '"' . substr(hash('sha256', $body), 0, 16) . '"';
$lastMod = gmdate('D, d M Y H:i:s', filemtime($file)) . ' GMT';
header('Content-Type: application/json');
header('Cache-Control: no-cache'); // may store, must revalidate
header("ETag: $etag");
header("Last-Modified: $lastMod");
$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ($inm !== '' && in_array($etag, array_map('trim', explode(',', $inm)), true)) {
http_response_code(304); // no body
exit;
}
echo $body;
exit;
}
if ($path === '/bad.json') { // common mistake: ETag changes every time
$body = file_get_contents($file);
header('Content-Type: application/json');
header('ETag: "' . md5($body . microtime()) . '"');
$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ($inm === md5($body)) { http_response_code(304); exit; }
echo $body;
exit;
}
return false; // let php -S serve other files as-is
El ETag es un hash del cuerpo, así que cambia exactamente cuando cambia el contenido y es idéntico en todos los servidores que tengan el mismo archivo. La primera petición recibe la respuesta completa:
HTTP/1.1 200 OK
Host: 127.0.0.1:18151
Date: Thu, 12 Nov 2026 14:28:53 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: application/json
Cache-Control: no-cache
ETag: "41455fbfcdb6b7a0"
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT
Devuelve el ETag, con comillas incluidas, y el servidor responde solo con headers:
$ curl -si -H 'If-None-Match: "41455fbfcdb6b7a0"' http://127.0.0.1:18151/good.json
HTTP/1.1 304 Not Modified
Host: 127.0.0.1:18151
Date: Thu, 12 Nov 2026 14:28:54 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: application/json
Cache-Control: no-cache
ETag: "41455fbfcdb6b7a0"
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT
Envíalo sin comillas (If-None-Match: 41455fbfcdb6b7a0) y recibes 200 179500: las comillas son parte del valor.
Fíjate en lo que hace no-cache aquí. No prohíbe guardar. Dice "guárdalo, pero pregúntame cada vez antes de usarlo". Junto con un ETag, es la configuración más segura para datos que pueden cambiar en cualquier momento, porque cada reutilización cuesta un viaje de ida y vuelta chico en vez de una descarga completa.
Cómo elegir Cache-Control: tabla de decisión
| Contenido | Header | Qué pasa |
|---|---|---|
| Contraseñas, páginas del banco, tokens de un solo uso | Cache-Control: no-store |
No se escribe nada en ninguna caché. |
| Datos de API que pueden cambiar en cualquier momento | Cache-Control: no-cache + ETag |
Se guarda y se revalida en cada uso; 304 si no cambió. |
| Páginas HTML de un blog | Cache-Control: public, max-age=600 + ETag |
Se reutiliza 10 minutos y después se revalida. |
| Respuestas por usuario (perfil, carrito) | Cache-Control: private, no-cache |
El navegador puede guardarla; las cachés compartidas (CDN) no. |
CSS/JS con un hash en el nombre (app.3f9c1e.css) |
Cache-Control: public, max-age=31536000, immutable |
Se reutiliza un año; un deploy nuevo usa un nombre nuevo. |
Mismo nombre de archivo que cambia en cada deploy (app.css) |
max-age corto o no-cache + ETag |
Un max-age largo aquí es el bug de "los usuarios ven el CSS viejo". |
| Regla distinta para el CDN que para los navegadores | agrega s-maxage=<segundos> |
Las cachés compartidas usan s-maxage, los navegadores max-age. |
Medido: lo que ahorra un 304
Cronometramos el endpoint good.json con un proceso de curl por petición: 50 descargas completas y después 50 revalidaciones con el If-None-Match correcto, repetido en tres rondas. Después hicimos 10 + 10 peticiones con --limit-rate 1M de curl para imitar una conexión móvil lenta (dos rondas).
| Caso | Estado | Bytes de cuerpo por petición | Tiempo mediano |
|---|---|---|---|
| Loopback, descarga completa (3 × 50) | 200 | 179,500 | 1.57 / 1.43 / 1.47 ms |
| Loopback, revalidación (3 × 50) | 304 | 0 | 1.38 / 1.40 / 1.38 ms |
| Límite de 1 MB/s, descarga completa (2 × 10) | 200 | 179,500 | 173.29 / 173.01 ms |
| Límite de 1 MB/s, revalidación (2 × 10) | 304 | 0 | 1.43 / 1.46 ms |
Los headers de respuesta ocuparon 252 bytes en el 200 y 262 bytes en el 304 (el 304 no es más chico en headers). Cincuenta descargas completas movieron 8,975,000 bytes de cuerpo; cincuenta revalidaciones, ninguno.
Dos lecturas honestas de esta tabla. Primero, en una conexión rápida un 304 casi no ahorra tiempo, porque dominan el viaje de ida y vuelta y el arranque del proceso; la ganancia es ancho de banda y el plan de datos del usuario. Segundo, el servidor igual hizo todo el trabajo: nuestro router leyó el archivo y calculó el hash para cada 304. Si producir el cuerpo es caro (una consulta a la base de datos), calcula el ETag a partir de algo más barato, como una columna de versión o el hash del contenido guardado en el momento del deploy.
Errores comunes: lo que pasa en realidad y la solución
1. Un ETag que cambia en cada respuesta. bad.json mezcla microtime() en el hash. Le pasamos el ETag de cada respuesta a la petición siguiente, diez veces:
bad.json x10: 200 200 200 200 200 200 200 200 200 200
Cada revalidación es una descarga completa, y los headers de caché son puro overhead. Lo mismo pasa en la vida real con ETags armados a partir de números de inode o timestamps cuando dos servidores detrás de un balanceador de carga tienen el mismo archivo. Solución: deriva el ETag del contenido (un hash) o de un número de versión que tú controles.
2. Last-Modified tiene resolución de un segundo. Escribimos un archivo, leímos su Last-Modified, escribimos otra versión dentro del mismo segundo y revalidamos contra el servidor de Python:
Last-Modified seen: Thu, 12 Nov 2026 14:29:52 GMT
-> 304
actual file: {"version": 2}
El servidor nos dijo "no modificado" mientras servía un archivo distinto; el cliente se queda con version 1. Los scripts de build que escriben un archivo y lo reescriben enseguida caen exactamente aquí. Solución: envía un ETag; cuando están los dos, If-None-Match tiene prioridad sobre If-Modified-Since (RFC 9110).
3. Comparar ETags como strings simples. Nuestro good.json compara en forma exacta, así que un cliente que envíe W/"41455fbfcdb6b7a0" recibiría un 200. W/ marca un ETag débil: "semánticamente igual, quizás no idéntico byte a byte", típicamente después de una compresión. If-None-Match usa comparación débil, así que un servidor correcto ignora el prefijo W/. El ejercicio 1 de abajo arregla el router.
4. Leer no-cache como "no guardar en caché". Hay equipos que ponen no-cache en una página de login creyendo que no se guarda nada. La respuesta sí se guarda. Para secretos, usa no-store.
Headers reales: example.com y este sitio
Dos peticiones de solo lectura el día en que se escribió esto. example.com se sirve a través de un CDN:
HTTP/2 200
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Thu, 05 Nov 2026 21:17:41 GMT
age: 8757
cf-cache-status: HIT
No hay Cache-Control ni ETag. age: 8757 dice que el edge tenía su copia desde hacía unas 2.4 horas. Una revalidación con If-Modified-Since devolvió 304 con un cuerpo de 0 bytes. Con solo Last-Modified, se aplica la heurística: la página tenía 580,303 segundos de antigüedad, así que el 10 % son unas 16 horas de posible reutilización.
Este sitio (sin los headers propios del proveedor):
HTTP/2 200
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
last-modified: Thu, 12 Nov 2026 04:13:31 GMT
etag: W/"22d0-6af53ceb-1d219362a7cb89c4;gz"
cache-control: public, max-age=600
Un detalle interesante: sin Accept-Encoding el ETag era débil (W/"…;gz"); con curl --compressed la misma página volvió comprimida con gzip y un ETag fuerte "…;gz". Devolver el valor débil como If-None-Match igual produjo 304 con un cuerpo de 0 bytes, que es la comparación débil funcionando según la especificación.
Trampas con un CDN
Un CDN es una caché compartida, así que todo lo anterior aplica, más tres trampas:
- Cachea lo que no querías cachear. Las redirecciones y las respuestas de API se pueden guardar en el edge, y un arreglo en el origen no se ve hasta que vence la copia. Nos pasó con 301; la historia completa está en el CDN de tu hosting puede estar cacheando respuestas de API y 301 (en inglés).
Varydecide la clave de la caché. Una respuesta que cambia segúnAccept-EncodinguOrigintiene que decirlo enVary, o la versión de un cliente se le sirve a otro.- El purge no es instantáneo en todos lados. Para los assets, prefiere nombres de archivo con versión; así nunca necesitas purgarlos.
Límites honestos y notas de versión
- Ejecutado: curl 8.7.1, servidor integrado de PHP 8.5.6,
http.serverde Python 3.9.6, solo en 127.0.0.1, Apple M1 Pro. Los tiempos en loopback incluyen arrancar un proceso de curl por petición.--limit-rateimita el ancho de banda, no la latencia ni la pérdida de paquetes. - No ejecutado: ninguna caché de navegador, service workers, ni el manejo de
stale-while-revalidateen un CDN. El comportamiento del navegador se describe a partir de las especificaciones. - Especificaciones: la caché HTTP es la RFC 9111 (junio de 2022), que reemplazó a la RFC 7234; ETag,
Last-Modifiedy las peticiones condicionales están en la RFC 9110.immutablees la RFC 8246, ystale-while-revalidate/stale-if-errorson la RFC 5861. La heurística del 10 % es una sugerencia de la RFC 9111, no una regla que sigan todas las cachés. - Las dos observaciones de sitios reales son una foto de un solo día; los valores de los headers cambian con la configuración.
Ejercicios
1. Cambia good.json para que If-None-Match use comparación débil: W/"x" tiene que coincidir con "x", una lista como "old", "x" tiene que coincidir, y * tiene que coincidir.
2. Un archivo se modificó por última vez hace diez días y se sirve con Last-Modified pero sin Cache-Control. Con la heurística típica del 10 % de la RFC 9111, ¿cuánto tiempo puede reutilizarlo una caché sin preguntar, y qué header elimina las adivinanzas?
Respuestas
1. Reemplaza la prueba con in_array por esta función:
function etag_matches(string $etag, string $header): bool {
if (trim($header) === '*') return true;
$strip = fn(string $t): string => preg_replace('#^W/#', '', trim($t));
foreach (explode(',', $header) as $t) {
if ($strip($t) === $strip($etag)) return true; // weak comparison
}
return false;
}
La ejecutamos con cinco valores de header:
"41455fbfcdb6b7a0" => 304
W/"41455fbfcdb6b7a0" => 304
"old", "41455fbfcdb6b7a0" => 304
41455fbfcdb6b7a0 => 200
* => 304
El valor sin comillas sigue recibiendo 200, y eso es correcto: no es un entity tag válido.
2. Más o menos un día (el 10 % de diez días). Envía un Cache-Control explícito, por ejemplo max-age=600 o no-cache, y la caché deja de adivinar.
Lo que sigue
- Anterior: curso de HTTP 3/6: headers, cookies y sesiones
- Siguiente: curso de HTTP 5/6: HTTPS, TLS y certificados (en inglés)
- Más adelante: curso de HTTP 6/6: keep-alive, compresión, HTTP/2 y CORS (en inglés)
- Desde el principio: clase 1/6: peticiones, respuestas y curl, clase 2/6: códigos de estado y redirecciones
- El servidor php -S que usamos aquí: funciones de PHP, formularios y el servidor integrado (en inglés)
- Todas las series en orden: la hoja de ruta para aprender a programar
Preguntas frecuentes
¿Cache-Control: no-cache significa que la respuesta no se guarda en caché?
No. no-cache significa que una caché puede guardar la respuesta, pero tiene que revalidarla con el servidor antes de cada reutilización, normalmente con If-None-Match. Si no se debe guardar nada, usa no-store. En nuestra prueba, una respuesta no-cache con ETag recibió 304 Not Modified con un cuerpo de 0 bytes en cada revalidación.
¿Cuánto ahorra de verdad un 304 Not Modified?
El cuerpo, no el viaje de ida y vuelta. Para un archivo JSON de 179,500 bytes, 50 descargas completas movieron 8,975,000 bytes de cuerpo y 50 revalidaciones movieron 0. En loopback el tiempo casi no cambió (mediana de unos 1.4 a 1.6 ms contra 1.4 ms), pero con curl limitado a 1 MB/s una descarga completa tardó una mediana de 173 ms y un 304 tardó 1.4 ms.
¿Por qué mi servidor nunca devuelve 304?
La causa más común es un ETag que cambia en cada respuesta, por ejemplo uno armado con un timestamp o con el inode de cada servidor. Armamos uno con microtime(): diez revalidaciones seguidas devolvieron 200. Otra causa es comparar el header If-None-Match sin sus comillas o sin aceptar el prefijo débil W/.