StackPractices
beginner Por Mathias Paulenko

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-maxage permite 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 devolviendo 304 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; usa private, no-cache o no-store para 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-store es la única respuesta honesta.
  • Documentos HTML que deben reflejar cada despliegue — prefiere no-cache con ETag antes que un max-age largo, 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

flowchart diagram: Petición del cliente

Las directivas principales y cuándo aplicar cada una:

Combinación de directivasSe aplica aQué hace
public, max-age=31536000, immutableAssets estáticos hasheadosCaché de un año en todas partes; nunca revalida
public, max-age=60, stale-while-revalidate=300Lecturas de API públicasFresca 60s, luego obsoleta hasta 300s mientras refresca
s-maxage=3600, max-age=60Páginas SSR tras un CDNEl edge cachea 1 hora; el navegador revalida cada minuto
private, no-cacheDatos autenticadosEl navegador puede guardarla pero debe revalidar; las cachés compartidas la omiten
no-storeFacturación, tokens, datos personalesNinguna caché la almacena
  1. max-age — los segundos que la respuesta se considera fresca. El navegador la sirve desde caché sin revalidar durante ese periodo.
  2. public / private — public permite que las cachés compartidas (CDNs, proxies) almacenen la respuesta; private limita el almacenamiento al navegador.
  3. immutable — le dice al navegador que la respuesta no cambiará durante su vida útil, omitiendo la revalidación condicional por completo.
  4. ETag — una huella del contenido. El cliente envía If-None-Match en las siguientes peticiones; una coincidencia devuelve 304 Not Modified.
  5. stale-while-revalidate — al expirar max-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é immutable con max-age=31536000.
  • Usa no-store para datos sensibles — facturación, tokens de autenticación, información personal.
  • Configura Vary correctamente — omitir Accept-Encoding hace que respuestas comprimidas y sin comprimir colisionen en la caché.
  • Usa stale-while-revalidate en 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=0 sin no-cache — max-age=0 fuerza la revalidación pero sigue almacenando la respuesta; no-store impide el almacenamiento.
  • Olvidar Vary: Accept-Encoding — una respuesta gzip cacheada se sirve a clientes que no soportan gzip y produce errores.
  • Usar Expires en lugar de Cache-Control — Expires es HTTP/1.0 y menos flexible; prefiere Cache-Control.

Solución de Problemas

  • El navegador nunca cachea aunque max-age esté bien puesto: la petición lleva Authorization o la respuesta establece Set-Cookie — ambos hacen que navegadores y la mayoría de CDNs se nieguen a almacenar salvo que public o s-maxage lo permita explícitamente. Revisa la columna “Disk cache” en la pestaña Network de DevTools.
  • 304 Not Modified nunca 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-age del origen era demasiado largo y no hay separación s-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ó public sin Vary: Authorization y una caché compartida la almacenó. Cambia a private, no-cache y audita qué rutas ponen public.

Lectura Adicional

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.