Volver al blog

Síguenos y suscríbete

Limpiar la caché en el navegador

Andrew Betts

Principal Developer Advocate, Fastly

Si eres desarrollador web, es probable que en algún momento de tu carrera profesional hayas enviado sin querer una versión deficiente de un activo del frontend. A mí también me ha pasado. Y que, además, le asignaras una vida útil de caché de 30 años. Malas noticias: el error afectará a los usuarios hasta que borren a mano su memoria caché. ¿O no? Hay muchas formas de abordar este problema sin tener que asignar un TTL corto a los activos. Además, lo bueno es que te permiten planificar la actualización de estos activos con rapidez, incluso aunque no haya ninguna versión deficiente ni ningún problema. En cualquier caso, todas las soluciones presuponen que conoces la URL de los activos que hay que purgar y que, a pesar de todo, la aplicación va a enviar como mínimo una petición al servidor para cualquier elemento en el que podamos insertar código ejecutable de JavaScript: un script o una página HTML. ## location.reload(true) La primera solución a la que puedes recurrir es la que plantearon Steve Souders y Stoyan Stefanov en 2012 (https://www.stevesouders.com/blog/2012/05/22/self-updating-scripts/). En este caso, se aprovecha que el método «reload()» del objeto «location» adopta un párametro booleano «forcedReload», como indica MDN: > se trata de un indicador booleano que, cuando es verdadero, provoca que la página se recargue siempre desde el servidor. ¿Cargará todos los recursos de la página desde el servidor sin importar si están actualmente en la caché, o solo el documento principal? Dado que no interesa interferir con las actividades del usuario mediante la recarga visible del documento principal, probablemente te convenga utilizar un iframe con este fin. En un fragmento de script del documento principal, puedes hacer lo siguiente: ``` const ifr = document.createElement(‘iframe’); ifr.src = “/forcereload?path=/thing/stuck/in/cache”; ifr.classList.add(“hidden-iframe”); document.body.appendChild(ifr); ``` Y, a continuación, en la respuesta «/forcereload», esto otro: ``` ``` Para que funcione, tienes que crear un iframe, cargar un documento HTML que no guarde relación con el elemento que quieres invalidar y, a continuación, volverlo a cargar. Además de todo eso, debes cargarlo dos veces (aunque la primera será a partir de la caché) y esto no es bueno. Además, te quedas con un iframe adjunto a un documento que deberás limpiar de algún modo, probablemente con un PostMessage procedente del marco y dirigido al elemento primario para indicarle que puede retirarlo. Y, como señala Philip Tellis, cualquier versión antigua de Firefox que no esté sujeta a actualizaciones automáticas entra a menudo en un bucle de recarga infinito (http://www.lognormal.com/blog/2012/06/05/updating-cached-boomerang/). En cualquier caso, esta solución quizá no se termina de comportar como imaginabas (https://maddening-museum.glitch.me/?grep=location.reload). Como documenta MDN, el argumento «forcedReload» no parte del punto de vista técnico de las especificaciones correspondientes a la interfaz de ubicaciones (https://www.w3.org/TR/html5/browsers.html#location). Tampoco cambia ningún navegador cuando realiza una captura de red (al menos, en relación con los subrecursos) a partir del valor de ese argumento. Sin embargo, los navegadores sí que actúan de forma diferente respecto del propio «reload()». Chrome siempre carga el subrecurso a partir de la caché, mientras que Firefox, Edge y Safari siempre lo cargan a partir de la red. Los únicos efectos que tiene el argumento «forcedReload» parecen ser los siguientes: 1. En relación con el documento en sí (el iframe que en nuestra técnica «se encarga de recargar»), «forcedReload» solicita su captura a través de la red con Firefox si por cualquier otra razón hubiera sido capturado a partir de la caché. Todos los demás navegadores recargan el documento siempre a partir de la red. 2. En relación con los subrecursos (como el script que estamos intentando actualizar), si cualquier navegador (salvo Chrome) presentara una petición de red para la recarga, la definición de «forcedReload» evitaría que se hiciesen peticiones condicionales en caso de que alguno de los recursos objeto de recarga presenten encabezados «ETag» o «Last-Modified». Con Chrome, «forcedReload» no se ve afectado; por lo que, en cualquier caso, no se realiza captura de red. Otro inconveniente de esta técnica es que, en realidad, no hay forma de evitar que se añadan entradas falsas al historial del navegador. Esta es la solución que utiliza Steve Souders, y el caso de prueba que creó en 2012 hoy en día ya no funciona con Chrome, con lo que se confirman las conclusiones que extraje de mis pruebas. Al parecer, se debe a un cambio en el comportamiento de Chrome. Puesto que este argumento no figura entre las especificaciones, no se trata de un error desde el punto de vista técnico; sin embargo, no es difícil imaginar implementaciones de esta técnica desarrolladas en la vida real. Es una pena que haya dejado de funcionar. ## Vary + fetch Pasemos a considerar otra opción, que posiblemente sea mejor. Me interesa mucho el encabezado Vary, que creo que podemos aplicar a nuestro caso. Todos los navegadores lo implementan, y lo utilizan a modo de validador, no como clave de caché. Esto quiere decir que, si cambia un valor de encabezado sujeto a variación, el objeto existente almacenado en caché será inválido para la nueva petición, y todo objeto nuevo que se descargue reemplazará al objeto que ya está en la caché (este comportamiento difiere del de las CDN y otras cachés «compartidas», que suelen almacenar múltiples variantes de la misma URL). Te propongo definir un encabezado «Vary» en todas las respuestas del servidor, introduciendo así variaciones respecto de elementos que no existen: ``` Vary: Forced-Revalidate ``` Esto no tendrá consecuencias, porque los navegadores no envían el encabezado «Forced-Revalidate». Sin embargo, «fetch» sí puede tenerlas: ``` await fetch(“/thing/stuck/in/cache”, { headers: { “Forced-Revalidate”: 1 }, credentials: “include” }); ``` Así pues, ¿qué sucede en este ejemplo? 1. Haces una petición de «/thing/stuck/in/cache» y se encuentra un resultado en la caché, pero el objeto almacenado en caché está sujeto a una variación por parte de «Forced-Revalidate» con una clave de «“”» (cadena vacía). La nueva petición es portadora de un valor de 1 para «Forced-Revalidate», de modo que no coinciden. También incluyes credenciales en la petición para garantizar que la respuesta pueda ser utilizada para cualquier petición de navegación habitual. 2. La petición se envía a la red. El servidor devuelve la nueva versión del archivo, que sigue incluyendo «Vary: Forced-Revalidate». 3. El navegador sobrescribe el elemento existente de la caché con el nuevo, que ahora es válido únicamente para peticiones que tengan un encabezado «Forced-Revalidate: 1». ¡Espera un momento! Ahora el elemento que hay en la caché solamente coincidirá con futuras peticiones que tengan un encabezado «Forced-Revalidate». La próxima vez que el navegador tenga un motivo para cargar este archivo, ya sea como navegación o como subrecurso, no enviará el encabezado especial y volverás a perder la caché. Sin embargo, en esta ocasión la respuesta que se descargue tendrá una clave de Vary del tipo «“”» (cadena vacía) y volverá a ser útil. Y esto es aún mejor (https://maddening-museum.glitch.me/?grep=Vary): ahora Edge, Chrome, Firefox y Safari se comportan todos correctamente respecto de recursos del mismo origen. Firefox divide la caché entre capturas de orígenes cruzados y navegaciones de modo que no se borre la caché de navegación. Y es posible que en el futuro los navegadores comiencen a almacenar múltiples variantes, con lo que esta técnica perdería su eficacia. Aun así, con una línea de JavaScript y unos pocos metadatos HTTP de aspecto ligeramente extraño acabas teniendo que cargar el elemento dos veces. Eso sí, no haría falta un iframe y el código sería fácil de mantener. Aunque, claro, lo ideal sería que hubiera algo que pudieras incluir en vez de encabezados del tipo «{ “Forced-Revalidate”: 1 }» para indicar a la captura que pase por alto la caché directamente… ## fetch + cache:reload Y por fin llega la hora de hablar de la propiedad caché (https://developer.mozilla.org/en-US/docs/Web/API/Request/cache) del objeto de la petición de la API de Fetch (https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). Se trata del mejor método (y el más simple) para resolver el problema: ``` await fetch(‘/thing/stuck/in/cache’, {cache: ‘reload’, credentials: ‘include’}); ``` El modo de caché reload ordena a la captura (fetch) que ignore la caché y que vaya directamente a la red pero que guarde cualquier respuesta nueva en la caché. Al igual que antes, incluyes las credenciales de forma que la captura reciba (supuestamente) el mismo tratamiento que la navegación habitual con fines de almacenamiento en caché. La respuesta nueva puede utilizarse inmediatamente para cualquier futura petición, y no necesitas encabezados inverosímiles, iframes ni nada. Suena bien, ¿verdad? En realidad, a día de hoy funciona con Edge, Firefox y Safari (https://maddening-museum.glitch.me/?grep=FetchOptions), y está a punto de aplicarse en Chrome (funciona a la perfección en Canary, pero aún no se ha conseguido en versiones estables). La compatibilidad de esta solución con recursos procedentes del mismo origen es, en realidad, mucho mejor de lo que cabría esperar. Además, la tabla de compatibilidad de MDN no estaba actualizada, de modo que probablemente navegadores como Safari y Edge ya la tengan desde hace muy poco. Y, sin embargo, con Safari esto únicamente borrará caché de capturas y, aunque las navegaciones puedan rellenar esta caché, lo contrario no se cumple. Además, Edge es el único navegador que admite estos dominios cruzados. ## fetch + POST Ha llegado la hora de sacar el armamento pesado. Las peticiones POST invalidan el contenido almacenado en caché correspondiente a esa URL (http://httpwg.org/specs/rfc7234.html#rfc.section.4): > Toda caché debe invalidar sí o sí el URI de petición real (sección 5.5 de la norma [RFC 7230]), así como los URI que figuren en los campos de encabezado de respuesta Location y Content-Location (si los hubiera) cada vez que se reciba un código de estado sin errores como respuesta a un método de petición no seguro. Pero ¿lo respetan los navegadores? ¿Almacenan la respuesta en caché? Veamos (https://maddening-museum.glitch.me/?grep=POST+request); vamos a utilizar una captura para generar una petición POST programática para la URL bloqueada: ``` await fetch(‘/thing/stuck/in/cache’, {method:’POST’, credentials:’include’}); ``` Tendrás que tolerar una petición preparatoria: se trata de un método no seguro e incluiremos credenciales. Además, resulta que ningún navegador almacena en caché el resultado de POST, ni siquiera cuando se anuncia como susceptible de almacenar en caché (o, si lo hacen, no lo utilizan para satisfacer cualquier GET subsiguiente). Así que, incluso si vemos una invalidación, necesitarás como mínimo de tres peticiones para volver llenar la caché. Con esa salvedad, Chrome y Edge obtienen buenos resultados en este sentido: su vista única de la caché genera una invalidación para contenidos del mismo origen y para los de orígenes cruzados, respecto tanto de capturas como de navegaciones. Firefox y Safari siguen el mismo patrón que hemos constatado antes —dividen las navegaciones y las capturas en cachés distintas—, de modo que POST borra la caché de capturas; sin embargo, si el objeto bloqueado es un subrecurso, mala suerte. ## POST en un iframe Bueno, de perdidos, al río. Añadamos un FORM en un IFRAME y sazonémoslo con un POST. Ya lo sé, pero a grandes males… ``` const ifr = document.createElement('iframe'); ifr.name = ifr.id = 'ifr_'+Date.now(); document.body.appendChild(ifr); const form = document.createElement('form'); form.method = "POST"; form.target = ifr.name; form.action = ‘/thing/stuck/in/cache’; document.body.appendChild(form); form.submit(); ``` Evidentemente, habrá efectos secundarios: se generará una entrada en el historial del navegador y surgirán los mismos problemas derivados de la ausencia de almacenamiento en caché de la respuesta. Sin embargo, evita los requisitos preparatorios que existen para las capturas (fetch) y, como se trata de una navegación, los navegadores que dividen las cachés limpiarán la caché del navegador adecuada. Esta solución es casi perfecta (https://maddening-museum.glitch.me/?grep=POST+into+an+IFRAME). Firefox suele conservar el objeto bloqueado en relación con recursos de orígenes cruzados, pero solo para capturas posteriores. Los navegadores tienden a invalidar la caché de navegación propia del objeto respecto tanto de recursos del mismo origen como de los provenientes de orígenes cruzados. ## Clear-Site-Data Empezamos en los bajos fondos, descubrimos la perfección y entonces nos dimos cuenta de que no es como dicen, así que acabamos volviendo a los bajos fondos. Por tanto, debemos acabar esta historia analizando una opción que podríamos llamar «opción nuclear a una distancia segura». Te presentamos Clear-Site-Data (https://www.w3.org/TR/clear-site-data/), la nueva arma de destrucción masiva a disposición de los desarrolladores web. No importa qué URL quieras purgar: solo tenemos que devolver este encabezado de respuesta como contestación a cualquier petición en el origen de destino: ``` Clear-Site-Data: “cache” ``` Y… ¡bum!: la caché queda pulverizada. Y no solo el elemento que querías purgar: toda la caché correspondiente al origen habrá desaparecido también. Lo cual quizás nos salve de algún apuro. Otra ventaja de este método es que no es necesario ejecutar código JavaScript del lado del cliente, de forma que incluso puedes enviarlo como respuesta a una petición de imágenes o de hojas de estilo. La falta de sofisticación y su gran eficacia hacen de este método algo maravilloso. Hace ya varios años que se oye hablar de esta característica, pero ahora empieza a verse en Chrome. Sin embargo, en el momento de redactar este artículo, la característica se ha deshabilitado de forma provisional (https://twitter.com/mikewest/status/933277769559142400). Eso quiere decir que por ahora no funciona en ningún navegador (https://maddening-museum.glitch.me/?grep=Clear-Site-Data). ¡Pues vaya chasco! ## Conclusión En resumen, ¿en qué situaciones hacen los navegadores peticiones de red que invalidan la caché que utilizan los subrecursos?
Técnica Firefox Safari Edge Chrome
location.reload doc, forceReload, mismo origen
doc, normal, mismo origen No
doc, forceReload, orígenes cruzados
doc, normal, orígenes cruzados No
resource, forceReload, mismo origen No
resource, normal, mismo origen Varía [1] No
resource, forceReload, orígenes cruzados No
resource, normal, orígenes cruzados Varía [1] No
Vary + fetch mismo origen Sí [3]
orígenes cruzados No [2] Sí [3]
cache:reload mismo origen No [4] Sí [5]
orígenes cruzados No [2] No [4] Sí [5]
Fetch + POST mismo origen No [4]
orígenes cruzados No [2] No [4]
Iframe + POST mismo origen
orígenes cruzados Sí [6]
Clear-Site-Data No No No No
[1] Obtiene resultados de la red, a menos que el recurso posea Cache-Control: inmutable.
[2] Respecto a orígenes foráneos, divide las cachés entre captura y navegación, de modo que no se borre la caché de navegación.
[3] La captura (fetch) tiende a invalidar las cachés de navegación y de capturas, pero las capturas subsiguientes no volverán a llenar la caché de navegación.
[4] No logra limpiar la caché del navegador, solo la caché de capturas.
[5] Hoy en día, se admite en la versión Canary de Chrome.
[6] No borrará la caché de capturas.
Aunque en este artículo no abordo otras cachés y funciones de almacenamiento del navegador, como la API de la caché de trabajos de servicio, me he centrado en la caché a la que te diriges con encabezados HTTP del tipo «Cache-Control». Limpiar la caché del navegador de otros tipos de almacenamiento merece un artículo aparte. En definitiva, para invalidar un script u otro subrecurso debes aplicar la técnica Iframe + POST, que funciona en todos los navegadores respecto del mismo origen y de orígenes cruzados. La forma adecuada es, en realidad, cache:reload, así que esperemos que Safari y Firefox cambien su comportamiento en el futuro de modo que permitan que esa técnica gane en utilidad práctica.