wandres.dev
CACHÉ HTTP · Las cabeceras que importan

immutable, stale-while-revalidate y stale-if-error

Las tres directivas que convierten una caché correcta en una caché excelente: eliminar la revalidación en las recargas, servir rancio sin esperar, y sobrevivir a la caída del origen.

⏱ 17 min

Tres directivas resuelven tres problemas que una política de caché básica no puede tocar: la tormenta de revalidaciones que provoca una recarga, la espera del usuario cuando una entrada acaba de caducar, y la caída del origen. Ninguna de las tres es imprescindible y las tres tienen una relación entre coste y beneficio difícil de igualar.

🎯 Al terminar esta lección sabrás
  • Explicar qué problema resuelve immutable y en qué situación exacta actúa.
  • Configurar stale-while-revalidate eligiendo su ventana con criterio.
  • Usar stale-if-error como mecanismo de resiliencia y conocer sus límites.
  • Combinar las tres en una política coherente por tipo de recurso.

immutable: el problema de la recarga

Imagina la situación ideal: todos tus estáticos llevan un hash en el nombre y max-age=31536000. El usuario navega por tu sitio y ningún estático toca la red. Perfecto.

Entonces el usuario recarga la página. Y ahí ocurre algo que no esperabas: el navegador dispara peticiones condicionales para todos esos recursos, aunque estén perfectamente frescos y les queden trescientos sesenta días de vida.

No es un error. Es un comportamiento deliberado: la recarga es la forma que tiene el usuario de decir “creo que estoy viendo algo desactualizado”, y el navegador la interpreta como una petición de comprobar. Con cincuenta subrecursos, eso son cincuenta peticiones condicionales, cada una con su viaje de ida y vuelta, para recibir cincuenta respuestas 304 que no aportan nada.

La directiva immutable es la respuesta a este problema concreto:

Cache-Control: public, max-age=31536000, immutable

Significa: mientras esta respuesta esté fresca, su cuerpo no va a cambiar bajo ninguna circunstancia, así que no revalides ni siquiera en una recarga.

La condición para usarla es estricta y no admite matices: la URL tiene que ser única por contenido. Si el archivo se llama app.a1b2c3d4.js y ese sufijo es el hash de su contenido, la promesa es cierta por construcción, porque cambiar el contenido cambia la URL. Si el archivo se llama app.js a secas y lo sobrescribes en cada despliegue, immutable es una mentira y vas a servir código viejo durante un año sin ninguna forma de arreglarlo.

🛑
immutable sobre una URL no versionada es irreversible

No hay forma de purgar la caché del navegador de un usuario desde el servidor. Si publicas un recurso con immutable y un año de frescura sobre una URL estable, y luego cambias su contenido, los usuarios que lo tengan cacheado verán la versión antigua hasta que expire, cambien de dispositivo o borren la caché a mano. La única salida es cambiar la URL, lo cual implica cambiar el HTML que la referencia, que a su vez tiene que no estar cacheado. Por eso el HTML nunca lleva immutable.

stale-while-revalidate: nadie espera

El problema que resuelve es distinto. Con una política normal, cuando una entrada caduca, la siguiente petición espera a que se revalide. Ese usuario concreto paga el viaje de ida y vuelta completo, aunque el contenido no haya cambiado.

En un recurso muy solicitado, ese coste no lo paga uno sino muchos: durante el tiempo que tarda la revalidación, todas las peticiones que llegan pueden encontrarse la entrada caducada.

Cache-Control: public, max-age=600, stale-while-revalidate=86400

Esto dice: durante diez minutos la respuesta es fresca; durante las veinticuatro horas siguientes, si alguien la pide, sírvele la copia guardada inmediatamente y lanza la revalidación en segundo plano.

El resultado es que ningún usuario espera nunca por una revalidación, y el contenido está como mucho a diez minutos de retraso más el tiempo que tarde la primera petición en disparar el refresco.

Cómo elegir las dos cifras:

  • max-age es cuánto estás dispuesto a que el contenido esté desactualizado en el caso normal. Ponlo según la frecuencia real de cambio del contenido.
  • stale-while-revalidate es cuánto tiempo prefieres servir algo viejo antes que hacer esperar a alguien. Puede ser mucho mayor que max-age: horas o días son valores razonables para contenido que tolera desactualización.

La combinación tiene una propiedad valiosa: desacopla la frescura de la latencia. Con una política clásica, para que el contenido esté más fresco hay que bajar max-age, y bajarlo significa que más usuarios esperan revalidaciones. Con esta directiva, puedes bajar max-age todo lo que quieras sin que nadie pague por ello.

stale-if-error: sobrevivir a la caída

La tercera directiva ataca la resiliencia, no la velocidad:

Cache-Control: public, max-age=600, stale-if-error=86400

Si al revalidar se produce un error de red o el servidor responde con un error, la caché puede servir la copia rancia durante las veinticuatro horas siguientes en lugar de propagar el fallo.

Los errores que activan este comportamiento son los de servidor y los de red: 500, 502, 503, 504, y los fallos de conexión. Un 404 o un 403 no lo activan, porque son respuestas válidas del servidor y no fallos.

El valor está en el escenario que evita: tu origen cae, y en lugar de que todos tus usuarios vean una página de error, siguen viendo el contenido de hace un rato. Para un sitio de contenido es la diferencia entre una caída visible y una caída invisible.

Sus límites hay que conocerlos. Solo protege a usuarios cuya caché ya tiene el recurso; un visitante nuevo verá el error igualmente. Y solo funciona mientras dure la ventana declarada. Es una red de seguridad, no un plan de alta disponibilidad.

La política combinada

Las tres directivas se combinan con max-age y s-maxage en políticas por tipo de recurso. Esta es una configuración completa y realista.

# Estaticos con hash en el nombre: nunca cambian.
location ~* \.[0-9a-f]{8,}\.(js|css|woff2|avif|webp|png|jpg|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# Estaticos sin hash: dia de vida, tolerancia amplia.
location ~* \.(js|css|woff2|avif|webp|png|jpg|svg)$ {
    add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=604800";
}

# Documentos HTML: el navegador revalida siempre, el borde cachea.
location / {
    add_header Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400, stale-if-error=86400";
}

Y la versión equivalente en una función del borde, que es donde cada vez más gente configura esto:

// Worker del borde: politica por tipo de recurso.
const POLITICAS = [
  [/\.[0-9a-f]{8,}\.\w+$/, 'public, max-age=31536000, immutable'],
  [/\.(js|css|woff2|avif|webp|png|jpg|svg)$/,
    'public, max-age=86400, stale-while-revalidate=604800, stale-if-error=604800'],
];

const POR_DEFECTO =
  'public, max-age=0, s-maxage=600, stale-while-revalidate=86400, stale-if-error=86400';

function politicaPara(ruta) {
  for (const [patron, valor] of POLITICAS) {
    if (patron.test(ruta)) return valor;
  }
  return POR_DEFECTO;
}

Fíjate en que la regla del hash va primera: el orden importa, porque un archivo con hash también encaja en el segundo patrón.

Soporte y degradación

Las tres directivas tienen soporte desigual, y esa es exactamente la razón por la que se pueden usar sin miedo: una caché que no entiende una directiva la ignora y aplica el resto de la cabecera.

Si un navegador no entiende immutable, revalidará en la recarga, que es el comportamiento de siempre. Si una CDN no entiende stale-while-revalidate, hará esperar al usuario, que es el comportamiento de siempre. En ningún caso se rompe nada ni se sirve contenido incorrecto.

Eso convierte a las tres en mejoras progresivas puras: el coste de añadirlas es cero y el beneficio depende de lo que entienda cada caché intermedia.

stale-while-revalidate en la CDN es la palanca que más reduce la carga de tu origen, y casi nadie la usa para eso

Se presenta siempre como una mejora de latencia para el usuario, y lo es. El efecto grande, sin embargo, está en el otro lado. Sin esta directiva, cuando una entrada muy solicitada caduca en el borde, todas las peticiones que llegan en ese instante encuentran la entrada caducada y pueden ir al origen a la vez. Ese es el patrón que tumba servidores: una avalancha de peticiones idénticas contra una URL que acaba de expirar, en un sitio que llevaba diez minutos sin recibir tráfico de origen. Con stale-while-revalidate, la primera petición que llega después de caducar dispara una sola revalidación en segundo plano y todas las demás reciben la copia guardada al instante. Pasas de un pico de miles de peticiones simultáneas a una. He usado esto para bajar la carga de origen de un sitio de noticias en más de un orden de magnitud sin tocar una línea de código de la aplicación, solo ajustando dos números en una cabecera. Si tu origen sufre picos coincidentes con la expiración de contenido popular, esta directiva es la respuesta, y el beneficio de latencia para el usuario es casi un efecto secundario.