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.

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:
| Directiva | Controla | Quién la respeta |
|---|---|---|
max-age=N | Tiempo de vida de la frescura en segundos | Navegadores, CDNs, proxies |
s-maxage=N | Tiempo de frescura solo para las CDNs | CDNs, cachés compartidos |
no-cache | Revalidar siempre antes de servir | Todos |
no-store | No cachear nunca | Todos |
private | Solo el navegador, la CDN no | CDNs (obedecen y se saltan) |
public | La CDN y el navegador pueden cachear | CDNs |
must-revalidate | No servir contenido obsoleto si ha expirado | Todos |
stale-while-revalidate | Servir lo obsoleto y refrescar en segundo plano | Navegadores 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.
// ❌ 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.
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.
// ❌ 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.
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.
// ❌ 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
no-cacheno significa «no cachear» — significa revalidar cada vez. Usano-storecuando de verdad no quieras caché.- Separa los TTL de la CDN y del navegador con
s-maxageymax-age: son mandos independientes para capas independientes. - 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.
stale-while-revalidateelimina el compromiso entre frescura y latencia para casi todo el contenido no sensible.- 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.
- 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.


