Nombres con hash, Vary y la estrategia completa
La técnica que permite cachear para siempre sin miedo, cómo Vary amplía la clave de caché y cuándo la destroza, y el mapa final de política por tipo de recurso.
Toda la teoría de caché converge en una técnica concreta que hace posible lo que parecía contradictorio: cachear un recurso durante un año y a la vez poder cambiarlo en cualquier momento. La idea es poner el contenido en la identidad del recurso. A su lado, Vary es la cabecera que permite tener varias representaciones de la misma URL y la que más silenciosamente puede arruinar tu tasa de aciertos.
- Aplicar el versionado por hash de contenido y elegir dónde ponerlo.
- Diseñar la cadena de invalidación desde el documento hasta el último recurso.
- Configurar
Varysin multiplicar la fragmentación de la caché. - Redactar la política completa de un sitio como una tabla accionable.
El versionado por hash de contenido
La idea cabe en una frase: si la URL incluye un resumen del contenido, entonces dos contenidos distintos tienen URL distintas y una URL nunca cambia de contenido.
Con esa propiedad, cachear para siempre deja de tener riesgo. No hay forma de servir contenido obsoleto, porque el HTML apuntará a la URL nueva en cuanto el contenido cambie.
app.js -> problema: misma URL, distinto contenido
app.js?v=3 -> funciona, pero hay que acordarse de subir la version
app.a1b2c3d4.js -> el hash lo genera el build; imposible olvidarse
Hay dos formas de aplicarlo y no son equivalentes.
En el nombre del archivo, como app.a1b2c3d4.js. Es la opción robusta. Todas las cachés e intermediarios la tratan como una URL distinta, sin excepciones.
En un parámetro de consulta, como app.js?v=a1b2c3d4. Funciona en los navegadores, porque los parámetros forman parte de la clave de caché. Es menos fiable en la cadena completa: algunas configuraciones de CDN normalizan o eliminan parámetros para mejorar su tasa de aciertos, y con eso se pierde el versionado. Si controlas la configuración de tu CDN y sabes que respeta los parámetros, es aceptable; si no, usa el nombre del archivo.
Cualquier empaquetador moderno genera estos nombres. Lo importante es lo que va con ellos:
Cache-Control: public, max-age=31536000, immutable
Un año de frescura, sin revalidación ni siquiera en recargas.
La cadena de invalidación
El sistema completo tiene una estructura de árbol y hay que verla entera para entender por qué funciona.
Documento HTML Cache-Control: max-age=0, s-maxage=600
|
+-- app.a1b2c3.css Cache-Control: max-age=31536000, immutable
| |
| +-- fuente.d4e5f6.woff2 max-age=31536000, immutable
|
+-- app.9f8e7d.js Cache-Control: max-age=31536000, immutable
|
+-- vendor.4c3b2a.js max-age=31536000, immutable
Solo el documento HTML se revalida. Todo lo demás es inmutable. Cuando despliegas un cambio en el CSS, su hash cambia, el HTML pasa a referenciar la URL nueva, y como el HTML se revalida en cada visita, el usuario recibe la referencia nueva de inmediato.
Hay un efecto en cascada que conviene tener presente: si cambia un archivo del que otros dependen, cambian todos los hash de la cadena. Modificar una fuente cambia su hash, lo cual cambia el contenido del CSS que la referencia, lo cual cambia el hash del CSS, lo cual cambia el HTML. Es correcto y es la razón por la que conviene separar lo que cambia con frecuencia de lo que no: si metes todo tu CSS en un solo archivo, cambiar un color invalida los doscientos kilobytes enteros.
Vary
Vary amplía la clave de caché con cabeceras de la petición. Sirve para cuando la misma URL devuelve representaciones distintas según lo que el cliente pida.
HTTP/1.1 200 OK
Content-Type: image/avif
Vary: Accept
Eso dice: esta respuesta depende de la cabecera Accept del cliente, así que no la sirvas a un cliente cuyo Accept sea distinto sin comprobar.
Los usos legítimos son pocos y concretos:
| Valor | Cuándo |
|---|---|
Vary: Accept-Encoding |
El servidor comprime y sirve distintos formatos según lo que el cliente acepte. Casi siempre necesario |
Vary: Accept |
Se negocia el formato de imagen o el tipo de respuesta por contenido |
Vary: Accept-Language |
Se sirve contenido en distintos idiomas bajo la misma URL |
Vary: Sec-CH-Prefers-Color-Scheme |
Se sirve contenido distinto según el esquema de color preferido |
Y aquí está el peligro: cada valor de la cabecera indicada crea una entrada de caché distinta. Si el valor de esa cabecera tiene muchas variantes posibles, tu caché se fragmenta y la tasa de aciertos se hunde.
El caso patológico es Vary: User-Agent. Hay millones de cadenas de agente de usuario distintas. Con esa configuración, cada combinación de navegador, versión y sistema operativo genera su propia entrada, y la caché deja de servir para nada. Nunca uses User-Agent en Vary.
Y el caso terminal es Vary: *, que significa que la respuesta no se puede cachear en absoluto, porque depende de algo no expresable en una cabecera.
Sin él, una caché compartida podría servir una respuesta comprimida con Brotli a un cliente que solo acepta gzip. Con él, se crean tantas entradas como valores distintos de Accept-Encoding reciba, y esa cabecera tiene bastante variedad. Muchas CDN lo resuelven normalizando el valor antes de usarlo como clave: reducen las decenas de variantes reales a tres o cuatro categorías. Si tu CDN lo ofrece, actívalo; multiplica la tasa de aciertos sin ningún riesgo.
La política completa
El mapa final, listo para copiar y adaptar.
| Recurso | Cache-Control |
Validador | Vary |
|---|---|---|---|
| Estáticos con hash | public, max-age=31536000, immutable |
Innecesario | Accept-Encoding |
| Estáticos sin hash | public, max-age=86400, stale-while-revalidate=604800 |
ETag de contenido |
Accept-Encoding |
| HTML público | public, max-age=0, s-maxage=600, stale-while-revalidate=86400 |
ETag |
Accept-Encoding |
| HTML personalizado | private, no-cache |
ETag |
Accept-Encoding, Cookie |
| API pública | public, max-age=60, stale-while-revalidate=600, stale-if-error=86400 |
ETag de versión |
Accept-Encoding |
| API con datos del usuario | private, no-cache |
ETag |
Accept-Encoding |
| Datos sensibles | no-store |
— | — |
| Imágenes con negociación de formato | public, max-age=31536000, immutable |
— | Accept |
| Fuentes con hash | public, max-age=31536000, immutable |
— | — |
Dos notas sobre la tabla. Las fuentes no llevan Vary: Accept-Encoding porque el formato WOFF2 ya está comprimido y no se recomprime, así que no hay variantes. Y la fila de HTML personalizado lleva Cookie en Vary como red de seguridad adicional, aunque private ya debería impedir su almacenamiento compartido.
La lista de verificación
Antes de dar por buena una política de caché, seis comprobaciones:
- ¿Los estáticos llevan hash en el nombre? Si no, no puedes usar frescura larga con seguridad.
- ¿El HTML tiene
max-age=0o muy corto en el navegador? Si no, no podrás desplegar arreglos con rapidez. - ¿Las respuestas personalizadas llevan
privateexplícito? Comprueba una por una las rutas autenticadas. - ¿
Varyincluye solo cabeceras con pocos valores posibles? NingúnUser-Agent, ningún*salvo intención deliberada. - ¿La segunda carga no toca la red? Verifica con los tiempos de recurso que
transferSizees cero para los estáticos. - ¿El
ETages estable entre servidores? Despliega, cambia de instancia, y comprueba que sigue devolviendo 304.
La cadena de invalidación funciona a la perfección salvo en un instante concreto que casi nadie planifica: el momento del despliegue. Si publicas el HTML nuevo antes de que los estáticos con hash nuevo estén disponibles en todos los nodos del borde, los usuarios que carguen en esa ventana recibirán un HTML que referencia archivos que devuelven 404. Y como los 404 son cacheables, esos errores pueden quedarse en el borde durante horas después de que el archivo exista. He visto la versión inversa también, más sutil: un usuario que ya tenía el HTML viejo cacheado hace una navegación dentro de la aplicación y pide un fragmento de JavaScript con el hash viejo que el despliegue acaba de borrar, con lo que la aplicación se rompe en mitad de la sesión sin ningún error de servidor. Las dos se resuelven con la misma disciplina: publica primero los estáticos, espera a que se propaguen, y solo después el HTML; y no borres los estáticos de la versión anterior hasta pasado bastante más tiempo que la vida de tu HTML en el borde. Mantener dos o tres versiones de estáticos cuesta unos megabytes de almacenamiento y elimina toda una clase de errores que solo ocurren durante cinco minutos y son imposibles de reproducir después.