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.

  1. ¿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.
  2. 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ó> o If-Modified-Since: <el Last-Modified que guardó>. Si nada cambió, el servidor responde 304 Not Modified sin 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.

Diagrama de flujo. Una petición llega a la caché, que revisa si su copia guardada sigue fresca según max-age. Si está fresca, responde desde la caché y el servidor no ve nada. Si está vieja, envía una petición condicional con If-None-Match y el ETag guardado. Si el ETag todavía coincide, el servidor devuelve 304 Not Modified sin cuerpo y la caché reutiliza su copia; si no, el servidor devuelve 200 con el cuerpo nuevo y un ETag nuevo.
Cómo decide una caché: reutiliza una copia fresca sin preguntar, o revalida una vieja y recibe 304 (sin cuerpo) o 200 (cuerpo nuevo).

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.
Diagrama de decisión. Primera pregunta: ¿la respuesta es secreta, como una página de contraseñas o un token de un solo uso? Si es así, usa no-store. Si no, ¿es propia de un solo usuario? Si es así, usa private, no-cache con un ETag. Si no, ¿el contenido cambia sin una URL nueva? Si nunca cambia porque el nombre del archivo lleva un hash, usa public, max-age=31536000, immutable. Si puede cambiar bajo la misma URL, usa un max-age corto o no-cache junto con un ETag.
Cómo elegir un valor de Cache-Control. Parte de si la respuesta se puede guardar o no, y después de si puede cambiar sin una URL nueva.

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.

Gráfico de barras de los tiempos medianos por petición. En loopback, una descarga completa 200 del JSON de 179,500 bytes tardó 1.57 ms y una revalidación 304, 1.38 ms. Con curl limitado a 1 MB/s, la descarga completa tardó 173.29 ms y el 304, 1.43 ms.
Tiempo mediano por petición, ronda 1 (50 peticiones en loopback, 10 con curl limitado a 1 MB/s). Medido en un Apple M1 Pro con curl 8.7.1 y php -S.

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).
  • Vary decide la clave de la caché. Una respuesta que cambia según Accept-Encoding u Origin tiene que decirlo en Vary, 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.server de 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-rate imita 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-revalidate en 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-Modified y las peticiones condicionales están en la RFC 9110. immutable es la RFC 8246, y stale-while-revalidate/stale-if-error son 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

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/.

  • #http
  • #intermedio
  • #caché
  • #etag
  • #cache-control
  • #304
  • #curl
  • #php

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑