Saltar al contenido

Caché HTTP: Cache-Control, ETags y estrategias de CDN

Una guía práctica de las cabeceras de caché HTTP — qué hace realmente cada directiva, cuándo usar ETags y cómo dejar de pelearte con tu CDN.

5 min de lectura
Diagrama que muestra las capas de caché HTTP desde el navegador hasta el servidor de origen pasando por la CDN

La mayoría de aplicaciones tratan el caché HTTP como algo secundario: le espolvorean una cabecera Cache-Control: max-age=3600 a los recursos estáticos y dan el tema por cerrado. Luego se despliega un bug, los usuarios ven datos obsoletos durante una hora y, de repente, el caché es el enemigo. El problema no es el caché. Es que la mayoría de ingenieros nunca llegan a entender del todo qué controlan realmente las cabeceras, a qué capas afectan y cómo se combinan.

Hacer bien el caché no requiere ser un mago de las CDN. Requiere entender bien cuatro conceptos: directivas, validadores, frescura y alcance. Cuando esos encajan, dejas de pelearte con el caché y empiezas a usarlo.

Las directivas de Cache-Control que de verdad importan

Cache-Control es una lista de directivas separadas por comas, y la mayoría de tutoriales solo cubren dos o tres de las ocho que importan en producción. Esta es la tabla de decisión realista:

DirectivaControlaQuién la respeta
max-age=NTiempo de vida de la frescura en segundosNavegadores, CDNs, proxies
s-maxage=NTiempo de frescura solo para las CDNsCDNs, cachés compartidos
no-cacheRevalidar siempre antes de servirTodos
no-storeNo cachear nuncaTodos
privateSolo el navegador, la CDN noCDNs (obedecen y se saltan)
publicLa CDN y el navegador pueden cachearCDNs
must-revalidateNo servir contenido obsoleto si ha expiradoTodos
stale-while-revalidateServir lo obsoleto y refrescar en segundo planoNavegadores modernos, CDNs

El par que más se confunde es no-cache frente a no-store. no-cache no significa «no cachees»: significa «cachéalo, pero valida antes de servirlo». no-store es la directiva de «no cachear» de verdad.

tstypescript
// ❌ Misused — this still caches the response
res.setHeader("Cache-Control", "no-cache");
 
// ✅ Correct usage — revalidate on every request
res.setHeader("Cache-Control", "no-cache, must-revalidate");
 
// ✅ Correct usage — truly no caching for sensitive data
res.setHeader("Cache-Control", "no-store, private");

Para respuestas de API específicas de cada usuario, private, no-cache suele ser la elección correcta. Para datos públicos que cambian poco, como un catálogo de productos, public, s-maxage=3600, stale-while-revalidate=86400 resulta mucho más útil.

ETags y peticiones condicionales

max-age resuelve la frescura cuando conoces el TTL. ¿Pero qué pasa con el contenido que cambia de forma impredecible? Los ETags dan al cliente una huella con la que validar contra el origen sin volver a descargar contenido que no ha cambiado.

El flujo funciona en dos idas y vueltas. En la primera petición, el servidor envía una cabecera ETag. En las siguientes, el cliente envía If-None-Match con ese valor. El servidor responde o bien con 200 OK y un cuerpo nuevo, o bien con 304 Not Modified y un cuerpo vacío.

tstypescript
import { createHash } from "crypto";
 
function generateETag(data: string): string {
  return `"${createHash("sha256").update(data).digest("hex").slice(0, 16)}"`;
}
 
export async function GET(req: Request) {
  const data = await fetchProductCatalog();
  const body = JSON.stringify(data);
  const etag = generateETag(body);
 
  // Check conditional request header
  const ifNoneMatch = req.headers.get("if-none-match");
  if (ifNoneMatch === etag) {
    return new Response(null, {
      status: 304,
      headers: { ETag: etag },
    });
  }
 
  return new Response(body, {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": "public, max-age=0, must-revalidate",
      ETag: etag,
    },
  });
}

Una respuesta 304 no lleva cuerpo: sobre un payload JSON de 100 KB en una conexión lenta, eso es mucho. Los ETags son especialmente valiosos en endpoints donde no puedes predecir cada cuánto cambian los datos pero quieres evitar transferencias innecesarias cuando no lo han hecho.

~

Usa ETags débiles (W/"hash") cuando la identidad byte a byte no importe — por ejemplo, cuando la codificación gzip pueda variar entre peticiones. Las peticiones por rangos exigen ETags fuertes.

Stale-while-revalidate: la directiva más útil que nadie usa

stale-while-revalidate es lo más parecido a un regalo que existe en el caché HTTP. Le dice al caché: sirve la respuesta obsoleta de inmediato (latencia cero) y luego refréscala en segundo plano.

tstypescript
// ❌ Binary choice — fresh or wait for origin
res.setHeader("Cache-Control", "public, max-age=60");
 
// ✅ Serve immediately, refresh in the background
res.setHeader(
  "Cache-Control",
  "public, max-age=60, stale-while-revalidate=600"
);

Con la segunda cabecera, pasados 60 segundos la respuesta está «obsoleta» pero se sigue sirviendo al instante. El caché revalida en segundo plano. Solo a partir de los 660 segundos (60 + 600) bloqueará realmente una petición para refrescarse. Para la mayoría del contenido —dashboards, feeds de blog, resultados de búsqueda— los usuarios nunca perciben esa breve ventana de obsolescencia.

CDNs como Cloudflare y la Edge Network de Vercel respetan esta directiva. Los navegadores se están poniendo al día: Chrome y Firefox ya la soportan. Combínala siempre con must-revalidate si necesitas garantías estrictas de expiración.

Separar el caché de la CDN del caché del navegador

Una de las funcionalidades más potentes e infrautilizadas de Cache-Control es la distinción entre max-age y s-maxage. Se parecen, pero controlan capas distintas.

tstypescript
function getHeadersForResource(type: "user-data" | "public-api" | "asset") {
  switch (type) {
    case "user-data":
      // Browser caches, CDN skips
      return "private, max-age=300";
 
    case "public-api":
      // CDN holds for 5 minutes, browser revalidates every 60 seconds
      return "public, s-maxage=300, max-age=60, stale-while-revalidate=600";
 
    case "asset":
      // Both cache forever — content-addressed URLs handle invalidation
      return "public, max-age=31536000, immutable";
  }
}

La directiva immutable merece mención aparte. Le dice a los cachés que la respuesta no cambiará nunca: no hacen falta peticiones condicionales, jamás. Úsala solo en recursos direccionados por contenido (archivos con un hash en la URL). Cuando reconstruyes tus recursos, la URL cambia, así que las respuestas cacheadas antiguas nunca son un problema.

Invalidación y la cabecera Vary

La invalidación de cachés es famosa por lo difícil que resulta. La respuesta práctica en la mayoría de arquitecturas es diseñar teniéndola en cuenta en lugar de pelearse con ella: URLs direccionadas por contenido para los recursos (de eso se encarga tu bundler), TTLs cortos o no-cache con ETags para los datos mutables, y etiquetado con surrogate-key para las purgas en la CDN.

La cabecera Vary le dice a los cachés que almacenen respuestas separadas según las cabeceras de la petición. Esto es crítico cuando sirves contenido distinto en función de Accept-Encoding, Accept-Language o Authorization.

tstypescript
// ❌ CDN may serve gzipped content to a client that sent no Accept-Encoding
res.setHeader("Cache-Control", "public, max-age=3600");
 
// ✅ Vary on encoding so CDN stores separate versions
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Vary", "Accept-Encoding");

Ojo con Vary: *: en la práctica desactiva el caché compartido, porque no hay dos peticiones que se consideren equivalentes. Algunas CDNs ignoran por completo la cabecera Vary y normalizan la codificación por su cuenta. Consulta la documentación de tu CDN antes de confiar en Vary para algo que no sea la codificación.

!

Nunca uses Vary: Authorization con una CDN pública. Si la CDN la respeta, cada token único crea una entrada de caché distinta y hace explotar su tamaño. Si no la respeta, te arriesgas a servirle a un usuario los datos de otro.

Puntos clave

  1. no-cache no significa «no cachear» — significa revalidar cada vez. Usa no-store cuando de verdad no quieras caché.
  2. Separa los TTL de la CDN y del navegador con s-maxage y max-age: son mandos independientes para capas independientes.
  3. Los ETags rinden en respuestas grandes que cambian de forma impredecible — un 304 sin cuerpo siempre es más rápido que un 200 con el payload completo.
  4. stale-while-revalidate elimina el compromiso entre frescura y latencia para casi todo el contenido no sensible.
  5. Diseña para la invalidación, no contra ella — las URLs direccionadas por contenido, las surrogate keys y los TTLs cortos le ganan siempre a las purgas manuales.
  6. Prueba el comportamiento real de tu CDN — las directivas se interpretan de forma distinta en Cloudflare, Fastly, AWS CloudFront y Vercel. Lo que dice la especificación y lo que hace tu CDN pueden divergir.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX