Configurar headers HTTP Cache-Control para APIs y assets
Establece headers Cache-Control, ETag y Last-Modified para controlar la caché de navegadores y CDN en respuestas de API y assets estáticos
Resumen
Los headers de caché HTTP le dicen a navegadores y CDNs cuánto tiempo guardar una respuesta, cuándo revalidarla y si una caché compartida puede almacenarla. Un Cache-Control bien configurado reduce la latencia, baja la carga del origen y mejora las Core Web Vitals; uno ausente o mal puesto, o bien filtra datos privados a través de cachés compartidas o desactiva la caché por completo. Esta receta cubre Cache-Control, ETag, Last-Modified y stale-while-revalidate para respuestas de API y assets estáticos, con ejemplos funcionales en nginx, Express y FastAPI.
Cuándo Usarlo
- Al servir assets estáticos (JS, CSS, imágenes, fuentes) con hash de contenido en el nombre de archivo — pueden cachearse un año con
immutable. - En respuestas de API públicas, idénticas para todos los usuarios o que cambian en intervalos predecibles.
- En páginas SSR servidas a través de un CDN, donde
s-maxagepermite que el edge cachee más tiempo que el navegador. - En respuestas grandes o costosas de generar, donde las peticiones condicionales (
ETag,Last-Modified) ahorran ancho de banda devolviendo304 Not Modified. - Para caché de objetos en servidor en lugar de caché HTTP, mira Caché en Node.js con Redis: Cache-Aside y patrones de TTL.
Cuándo NO Usarlo
- Respuestas autenticadas o personalizadas — nunca las marques
public; usaprivate, no-cacheono-storepara que una caché compartida no sirva los datos de un usuario a otro. - Respuestas que cambian en cada petición — feeds en tiempo real, tokens CSRF, nonces.
no-storees la única respuesta honesta. - Documentos HTML que deben reflejar cada despliegue — prefiere
no-cacheconETagantes que unmax-agelargo, o los usuarios retendrán una shell obsoleta que referencia assets hasheados ya eliminados.
Requisitos Previos
- Un servidor web o framework que permita establecer headers de respuesta (los ejemplos usan nginx, Express y FastAPI).
- Conocimiento básico del ciclo de petición/respuesta HTTP.
Solución
1. Assets estáticos — caché larga con immutable
Los assets estáticos con hash de contenido en el nombre pueden cachearse de forma agresiva:
# nginx.conf
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
// Express.js
app.use(express.static("public", {
maxAge: "1y",
setHeaders: (res, path) => {
if (path.endsWith(".js") || path.endsWith(".css")) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
}
},
}));
El flag immutable le dice al navegador que nunca revalide — el nombre del archivo cambia cuando cambia el contenido (ej. app.abc123.js).
2. Respuestas de API — caché corta con revalidación
// Express.js — cachear respuestas de API por 60 segundos con revalidación
app.get("/api/products", async (req, res) => {
const products = await getProducts();
res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
res.json(products);
});
// Sin caché para datos específicos de usuario
app.get("/api/users/me", authMiddleware, async (req, res) => {
const user = await getUser(req.userId);
res.setHeader("Cache-Control", "private, no-cache");
res.json(user);
});
3. ETag para peticiones condicionales
import crypto from "crypto";
app.get("/api/products", async (req, res) => {
const products = await getProducts();
const etag = `"${crypto.createHash("sha256").update(JSON.stringify(products)).digest("hex").slice(0, 16)}"`;
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.setHeader("ETag", etag);
res.setHeader("Cache-Control", "public, max-age=60");
res.json(products);
});
El cliente envía If-None-Match: "<etag>" en las peticiones siguientes. Si el ETag coincide, el servidor devuelve 304 Not Modified sin body — el cliente usa su copia cacheada. Asegúrate de que el payload hasheado sea estable: timestamps o campos aleatorios dentro de la respuesta cambian el ETag en cada petición y matan la optimización.
4. Last-Modified para peticiones condicionales
app.get("/api/articles/:id", async (req, res) => {
const article = await getArticle(req.params.id);
const lastModified = new Date(article.updatedAt);
const ifModifiedSince = req.headers["if-modified-since"];
if (ifModifiedSince && new Date(ifModifiedSince) >= lastModified) {
return res.status(304).end();
}
res.setHeader("Last-Modified", lastModified.toUTCString());
res.setHeader("Cache-Control", "public, max-age=300");
res.json(article);
});
If-Modified-Since es un umbral, no una igualdad exacta — compara fechas parseadas, porque un cliente o una caché intermedia puede enviar legítimamente un timestamp posterior. Cuando existen ambos validadores, ETag/If-None-Match gana y Last-Modified se ignora.
5. stale-while-revalidate para refresco en segundo plano
app.get("/api/trending", async (req, res) => {
const data = await getTrending();
// Cachear 60s, luego servir obsoleto hasta 300s mientras revalida
res.setHeader(
"Cache-Control",
"public, max-age=60, stale-while-revalidate=300"
);
res.json(data);
});
El CDN sirve la respuesta cacheada durante 60 segundos. Entre los 60 y los 360 segundos, sirve la respuesta obsoleta mientras obtiene una copia fresca en segundo plano.
6. Ejemplo con Python / FastAPI
StaticFiles no acepta el argumento max_age, así que móntalo con normalidad y añade el header con un middleware:
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
import hashlib
import json
app = FastAPI()
app.mount("/static", StaticFiles(directory="public"), name="static")
@app.middleware("http")
async def cache_headers(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
@app.get("/api/products")
async def get_products(request: Request):
products = await fetch_products()
body = json.dumps(products, default=str)
etag = f'"{hashlib.sha256(body.encode()).hexdigest()[:16]}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
return Response(
content=body,
media_type="application/json",
headers={
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
"ETag": etag,
},
)
Las versiones ejecutables de estas configuraciones — el nginx.conf completo, la app Express y la app FastAPI — están en el repositorio complementario.
Cómo Funciona
Las directivas principales y cuándo aplicar cada una:
| Combinación de directivas | Se aplica a | Qué hace |
|---|---|---|
public, max-age=31536000, immutable | Assets estáticos hasheados | Caché de un año en todas partes; nunca revalida |
public, max-age=60, stale-while-revalidate=300 | Lecturas de API públicas | Fresca 60s, luego obsoleta hasta 300s mientras refresca |
s-maxage=3600, max-age=60 | Páginas SSR tras un CDN | El edge cachea 1 hora; el navegador revalida cada minuto |
private, no-cache | Datos autenticados | El navegador puede guardarla pero debe revalidar; las cachés compartidas la omiten |
no-store | Facturación, tokens, datos personales | Ninguna caché la almacena |
max-age— los segundos que la respuesta se considera fresca. El navegador la sirve desde caché sin revalidar durante ese periodo.public/private—publicpermite que las cachés compartidas (CDNs, proxies) almacenen la respuesta;privatelimita el almacenamiento al navegador.immutable— le dice al navegador que la respuesta no cambiará durante su vida útil, omitiendo la revalidación condicional por completo.ETag— una huella del contenido. El cliente envíaIf-None-Matchen las siguientes peticiones; una coincidencia devuelve304 Not Modified.stale-while-revalidate— al expirarmax-age, el CDN sirve contenido obsoleto mientras obtiene una copia fresca de forma asíncrona, eliminando la latencia para el usuario.
Variantes
No-Store para datos sensibles
app.get("/api/user/billing", authMiddleware, async (req, res) => {
res.setHeader("Cache-Control", "no-store");
res.json(billingData);
});
no-store evita que cualquier caché — navegador, CDN o proxy — almacene la respuesta.
Header Vary para negociación de contenido
app.get("/api/products", (req, res) => {
res.setHeader("Vary", "Accept-Encoding, Accept-Language");
res.setHeader("Cache-Control", "public, max-age=300");
// La respuesta varía por codificación (gzip, br) e idioma
res.json(products);
});
Surrogate-Control para caché específica de CDN
res.setHeader("Surrogate-Control", "max-age=3600");
res.setHeader("Cache-Control", "max-age=60");
Los CDN usan el TTL más largo de Surrogate-Control, mientras que los navegadores usan el TTL más corto de Cache-Control.
Buenas Prácticas
- Hashea los nombres de los assets estáticos — habilita la caché
immutableconmax-age=31536000. - Usa
no-storepara datos sensibles — facturación, tokens de autenticación, información personal. - Configura
Varycorrectamente — omitirAccept-Encodinghace que respuestas comprimidas y sin comprimir colisionen en la caché. - Usa
stale-while-revalidateen APIs — elimina la latencia visible durante la revalidación. - Para estrategias de caché específicas de GraphQL, mira la Guía completa de caché en GraphQL.
Errores Comunes
- Cachear respuestas de usuario con
public— filtra datos entre usuarios a través del CDN. - Poner
max-age=0sinno-cache—max-age=0fuerza la revalidación pero sigue almacenando la respuesta;no-storeimpide el almacenamiento. - Olvidar
Vary: Accept-Encoding— una respuesta gzip cacheada se sirve a clientes que no soportan gzip y produce errores. - Usar
Expiresen lugar deCache-Control—Expireses HTTP/1.0 y menos flexible; prefiereCache-Control.
Solución de Problemas
- El navegador nunca cachea aunque
max-ageesté bien puesto: la petición llevaAuthorizationo la respuesta estableceSet-Cookie— ambos hacen que navegadores y la mayoría de CDNs se nieguen a almacenar salvo quepublicos-maxagelo permita explícitamente. Revisa la columna “Disk cache” en la pestaña Network de DevTools. 304 Not Modifiednunca ocurre: el ETag cambia en cada petición porque el payload hasheado incluye campos inestables (timestamps, request IDs). Hashea solo los datos que definen la respuesta.- El CDN sigue sirviendo contenido viejo tras un despliegue: el
max-agedel origen era demasiado largo y no hay separacións-maxage/Surrogate-Control, o nunca se purgó el CDN. Acorta el TTL compartido o purga por cache tag. - Safari sigue esperando la revalidación: ignora
stale-while-revalidate— trata la directiva como una optimización de latencia para los navegadores y CDNs que la soportan, nunca como garantía de corrección. - El cliente recibe un body gzip que no puede decodificar: falta
Vary: Accept-Encoding, así que una respuesta comprimida cacheada una vez se reenvía a todos los clientes. - Un usuario ve datos de otro: una respuesta se marcó
publicsinVary: Authorizationy una caché compartida la almacenó. Cambia aprivate, no-cachey audita qué rutas ponenpublic.
Lectura Adicional
- HTTP caching — MDN — la referencia canónica de la semántica de caché y el comportamiento de las directivas.
- RFC 9111: HTTP Caching — la especificación que define
Cache-Control, validadores y cachés compartidas. - HTTP caching — web.dev — guía práctica de
Cache-Control,ETagy patrones de invalidación. - Cache concepts — Cloudflare docs — cómo un CDN real interpreta estos headers, incluidos
s-maxagey la purga.
Preguntas frecuentes
¿Cuál es la diferencia entre no-cache y no-store?
no-cache almacena la respuesta pero exige revalidación antes de reutilizarla; no-store impide el almacenamiento por completo. Usa no-store para datos sensibles como tokens o facturación.
¿Debo usar ETag o Last-Modified?
ETag es más preciso — un hash de contenido supera a un timestamp con resolución de un segundo. Envía ambos: los clientes que soportan If-None-Match usan el ETag y los antiguos recurren a If-Modified-Since.
¿Cuánto tiempo debo cachear los assets estáticos?
Un año (max-age=31536000) con immutable, pero solo si los nombres de archivo llevan hash de contenido. Sin nombres hasheados, usa un TTL más corto con revalidación o los despliegues servirán archivos obsoletos.
¿stale-while-revalidate funciona en los navegadores?
Funciona en Chrome y Firefox. Safari lo ignora. CDNs como Cloudflare y Fastly lo soportan independientemente del navegador.
¿Cuál es la diferencia entre max-age y s-maxage?
max-age aplica a todas las cachés; s-maxage aplica solo a las compartidas (CDNs, proxies) y sobreescribe max-age para ellas. s-maxage=600, max-age=60 permite que un CDN cachee 10 minutos mientras los navegadores revalidan cada minuto.
¿Cómo invalido respuestas cacheadas?
En el navegador, no-cache más ETag fuerza la revalidación y un ETag nuevo fuerza la descarga. En un CDN, usa su API de purga — la purga por tags invalida grupos de URLs por cache tag. Mira Estrategias de invalidación de caché en CDN para surrogate keys y patrones de purga.
¿Cómo cacheo peticiones autenticadas?
Pon Cache-Control: private para que las cachés compartidas nunca almacenen respuestas de usuario, y Vary: Authorization si la respuesta cambia según las credenciales. Nunca dejes en una caché compartida respuestas que contengan tokens o IDs de sesión.
¿Cómo implemento cache busting en assets hasheados?
Pon el hash de contenido en el nombre del archivo (app.a1b2c3d4.js) y sírvelo con public, max-age=31536000, immutable. Cuando el contenido cambia, el hash cambia y la nueva URL se descarga fresca. Mantén el HTML que lo referencia con no-cache para que los navegadores siempre recojan los nombres nuevos.
¿Cómo depuro problemas de caché?
Ejecuta curl -I para inspeccionar Cache-Control, ETag, Last-Modified, Age y X-Cache. Age muestra cuántos segundos lleva la respuesta en una caché compartida — si supera max-age, la copia está obsoleta. La pestaña Network de DevTools muestra hits y misses, y una petición con Cache-Control: no-cache fuerza la revalidación.
¿Cómo afectan los headers Vary a la caché?
Vary enumera los headers de la petición que cambian la respuesta: Vary: Accept-Encoding mantiene separadas las copias gzip y brotli, Vary: Accept separa JSON de HTML y Vary: Origin separa las respuestas CORS por origen. Nunca uses Vary: * — desactiva la caché — y evita Vary: User-Agent, que fragmenta la caché en miles de claves.
Recursos Relacionados
Implementar el patron Cache-Aside con Redis
Usa el patron cache-aside para leer y escribir datos a traves de Redis, manejando cache misses, lecturas obsoletas e invalidacion write-through
PatternPatrón Cache-Aside
Carga datos en el caché bajo demanda desde el almacenamiento principal. Un patrón de caché que da a la aplicación control total sobre qué y cuándo cachear.
RecipeEstrategias y patrones de invalidación de caché CDN
Implementa invalidación de caché CDN usando purge APIs, surrogate keys, invalidación por tags y URLs versionadas para mantener el contenido fresco