wandres.dev
CACHE API · la cache del edge

Purgado e invalidación: expirar, purgar o versionar

Tres formas de dejar de servir algo viejo y ninguna es equivalente. Expirar por TTL cuesta cero pero tarda; purgar por URL o por etiqueta es inmediato pero exige coordinación y tiene sus filos; la clave versionada por hash disuelve el problema convirtiendo la invalidación en un despliegue. Con el matiz de que `cache.delete` solo borra en un centro de datos.

⏱ 18 min

Queda el problema difícil, el que la tradición del oficio coloca junto a nombrar cosas en la lista de las dos cosas verdaderamente duras de la informática: decidir cuándo lo que guardaste deja de ser válido. No es difícil por falta de herramientas —Cloudflare ofrece expiración por tiempo, purgado por URL, purgado por etiqueta y borrado programático— sino porque cada herramienta compra inmediatez a cambio de coordinación, y la coordinación en un sistema con cientos de cachés independientes es cara y frágil. Esta lección recorre las tres estrategias reales, señala exactamente dónde falla cada una, y termina en la que los sistemas maduros acaban prefiriendo casi siempre: no invalidar nada, y en su lugar cambiar la pregunta.

🎯 Al terminar esta lección sabrás
  • Usar la expiración por TTL como mecanismo de invalidación por defecto y saber cuándo no basta.
  • Distinguir purgado total, por URL y por etiqueta con Cache-Tag, y sus consecuencias operativas.
  • Entender por qué cache.delete solo actúa en el centro de datos que ejecuta el Worker.
  • Aplicar el patrón de clave versionada por hash para invalidar sin coordinar nada.

Expirar: la invalidación que no cuesta nada

El TTL es la invalidación por omisión y también la más barata: no requiere ninguna acción, ninguna llamada a una API, ninguna coordinación entre nodos. Cada entrada lleva escrito su propio vencimiento en las cabeceras con las que se guardó, y cada centro de datos lo aplica localmente sin hablar con nadie. Su virtud es la ausencia total de partes móviles; su límite es evidente: no puedes invalidar antes de tiempo, así que el TTL define exactamente cuánto tiempo estás dispuesto a servir algo viejo en el peor caso.

De ahí que elegir un TTL sea en realidad responder a una pregunta de producto, no de infraestructura. ¿Cuánto puede tardar un cambio de precio en verse? ¿Y una corrección de un texto legal? ¿Y una publicación nueva en el listado? Si la respuesta es «minutos», el TTL basta y no necesitas nada más de esta lección. Si es «al instante», entras en el territorio de las dos secciones siguientes.

Merece la pena escribir esa política en la propia respuesta, con las tres cifras separadas, porque cada una habla a un consumidor distinto y mezclarlas es la causa habitual de los diagnósticos imposibles.

respuesta.headers.set(
  "cache-control",
  "public, max-age=0, s-maxage=300, stale-while-revalidate=600",
);

Traducido: el navegador no guarda nada por su cuenta, el edge sirve la copia durante cinco minutos, y durante diez minutos más puede seguir sirviéndola caducada mientras la refresca por detrás. La ventana efectiva de obsolescencia en el peor caso es la suma, quince minutos, y ese número debería aparecer en la conversación de producto tal cual, sin envolverlo en jerga.

💡
Servir caducado mientras se revalida suaviza el compromiso

Con stale-while-revalidate puedes servir una copia vencida de inmediato y refrescarla en segundo plano. Eso desacopla dos cosas que solemos confundir: la frescura que el usuario percibe y la latencia que sufre. Un TTL corto acompañado de una ventana de caducidad tolerada te da actualización rápida sin que nadie espere jamás a que se recalcule.

Purgar: el botón y sus filos

Cuando la inmediatez es obligatoria, hay que purgar. El purgado total vacía la cache de la zona entera y es la opción más tentadora y más peligrosa: funciona siempre, y por eso se usa para todo, pero deja tu origen desnudo frente a la totalidad del tráfico durante los segundos o minutos que tarda la red en recalentarse. En un sitio con carga real, un purgado total en hora punta puede provocar exactamente la caída que la cache existía para evitar.

El purgado por URL es la herramienta quirúrgica: invalida entradas concretas en toda la red. Su trampa es que la URL que envías debe coincidir con la clave de cache, cadena de consulta incluida; si tus entradas están fragmentadas por parámetros de campaña, purgar la URL limpia solo purga una de las muchas variantes. Es la factura tardía de no haber normalizado la clave, y explica por qué la lección anterior venía antes que esta.

🧨

Purgado total

Vacía la zona entera. Infalible y brutal: deja el origen expuesto a todo el tráfico mientras la red se recalienta. Último recurso, nunca rutina.

🎯

Por URL

Quirúrgico y disponible en todos los planes. Exige que la URL coincida exactamente con la clave de cache, parámetros incluidos.

🏷️

Por etiqueta

Invalidación por entidad de dominio. Escala a miles de páginas afectadas por un solo cambio de dato, sin llevar listas de URL.

El purgado por etiqueta invierte la relación. En lugar de enumerar URL, marcas cada respuesta con una cabecera Cache-Tag que declara a qué entidades pertenece, y luego purgas por esas etiquetas. Una página de producto puede llevar la etiqueta del producto, la de su categoría y la de la plantilla que la renderiza; cuando el precio cambia, purgas la etiqueta del producto y desaparecen todas las páginas afectadas sin que nadie haya tenido que llevar una lista.

const respuesta = new Response(html, {
  headers: {
    "content-type": "text/html; charset=utf-8",
    "cache-control": "public, s-maxage=86400",
    "cache-tag": "producto-8412,categoria-teclados,plantilla-ficha",
  },
});
ℹ️
Las etiquetas se declaran al escribir, no al purgar

Cache-Tag viaja en la respuesta y se fija en el momento de guardar. Eso significa que solo puedes purgar por etiquetas que ya estabas emitiendo: añadir una etiqueta hoy no alcanza a lo cacheado ayer. Diseñar el esquema de etiquetas antes de necesitarlo es la diferencia entre una invalidación de una línea y un purgado total de emergencia.

cache.delete no es un purgado

Dentro de un Worker existe cache.delete(clave), y su nombre invita a un malentendido serio. Borra la entrada, sí, pero solo en la cache del centro de datos donde ese Worker se está ejecutando. Los otros trescientos siguen sirviendo su copia como si nada. No es una operación global y no puede serlo: la red no propaga borrados entre nodos por iniciativa de tu código.

Eso lo convierte en una herramienta útil para casos locales —descartar una entrada que acabas de detectar como corrupta, o limpiar tras un experimento— e inútil como mecanismo de invalidación de producto. Para lo segundo, lo correcto es llamar a la API de purgado de Cloudflare, que sí coordina la red entera.

await fetch(`https://api.cloudflare.com/client/v4/zones/${env.ZONE_ID}/purge_cache`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${env.CF_API_TOKEN}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({ tags: ["producto-8412"] }),
});

Esa llamada tiene tres implicaciones que suelen descubrirse tarde. Necesita un token con permisos de purgado, y ese token es un secreto de zona que no debe vivir en el mismo Worker que sirve tráfico público sin más cuidado. Está sujeta a límites de frecuencia, así que un bucle que purgue una etiqueta por cada fila modificada en una importación masiva chocará contra ellos; lo correcto es agrupar. Y no es instantánea: se propaga en segundos por toda la red, tiempo durante el cual convivirán las dos versiones. Un purgado no es una transacción.

Conviene recordar aquí el detalle de la primera lección: lo que guardaste en una cache abierta con caches.open queda además fuera del alcance de ese purgado, así que en un espacio con nombre tus únicas armas son el TTL y la versión.

flowchart TD
N[Necesito dejar de servir algo viejo] --> Q[Tolero minutos de retraso]
Q -->|si| T[TTL y stale-while-revalidate]
Q -->|no| C[Controlo la clave]
C -->|si| V[Clave versionada por hash]
C -->|no| P[Purgado por etiqueta o por URL]
P --> R[Cuidado con el purgado total]
style V fill:#a6e3a1,color:#11111b
style R fill:#f38ba8,color:#11111b

La clave versionada: invalidar sin invalidar

Queda la estrategia que los sistemas maduros terminan adoptando, y su elegancia consiste en negarse a resolver el problema. Si la clave de cache incluye un identificador de versión —el hash del contenido, el identificador del despliegue, la marca de la última escritura en la base de datos—, entonces publicar algo nuevo genera claves nuevas. Las viejas no se invalidan: simplemente dejan de consultarse y se apagan solas cuando expiran. La invalidación deja de ser una operación distribuida y se convierte en un cambio de nombre.

const version = env.DEPLOY_ID; // o el hash del contenido, o una marca de tiempo
const clave = new Request(`https://cache.interno/v/${version}/articulos`, request);

const acierto = await cache.match(clave);
if (acierto) return acierto;

El coste está a la vista y hay que aceptarlo sin autoengaño: cada versión nueva empieza con la cache fría, y si versionas con demasiada granularidad pagas un camino frío permanente. Por eso el patrón brilla cuando la versión cambia con poca frecuencia y afecta a mucho contenido —un despliegue, un cambio de plantilla, una migración de esquema— y encaja mal cuando lo que cambia es un dato concreto entre millones; ahí la etiqueta es la herramienta adecuada.

La granularidad del identificador de versión es, por tanto, la única decisión de diseño que hay aquí, y se resuelve preguntando qué conjunto de entradas debe caer junto. Un identificador de despliegue invalida absolutamente todo con cada publicación, lo cual es correcto para fragmentos de plantilla y desastroso para agregaciones costosas que no dependen del código. Una marca de la última escritura en una tabla invalida solo lo derivado de esa tabla y sobrevive a los despliegues. Un hash del contenido invalida exactamente lo que cambió y nada más, al precio de tener que calcularlo antes de construir la clave.

const sello = await env.KV.get("version:catalogo"); // se actualiza al escribir
const clave = new Request(`https://cache.interno/${sello}/catalogo`, request);

Ese patrón —un sello barato de leer que se actualiza cuando cambian los datos y que forma parte de la clave— es la versión práctica de la idea, y encaja especialmente bien en el edge porque la lectura del sello es rapidísima y la invalidación consiste en una sola escritura, sin coordinación con la red de cachés.

La combinación habitual de un sistema serio usa las tres estrategias a la vez, cada una en su capa: TTL como suelo que garantiza que nada vive eternamente, etiquetas para los cambios de datos que deben verse ya, y versión en la clave para los cambios de forma que afectan a todo. No compiten entre sí; cubren regímenes distintos del mismo problema.

La invalidación es difícil porque intenta imponer consistencia donde elegiste no tenerla

Merece la pena detenerse en por qué exactamente este problema tiene fama de intratable, porque la respuesta no es técnica sino conceptual. Cachear es, por definición, aceptar copias que pueden divergir del original: ese es el trato completo, no un efecto secundario. Invalidar es intentar deshacer ese trato a posteriori, exigir que todas las copias vuelvan a coincidir con la verdad en un instante determinado, en un sistema que se construyó precisamente sobre la renuncia a esa coincidencia. Estás pidiendo consistencia fuerte a una arquitectura que compró su velocidad vendiendo consistencia fuerte. Que sea difícil no es un defecto de las herramientas; es el precio saliendo a cobrar. Vista así, la jerarquía de estrategias de esta lección deja de parecer un catálogo de opciones y se ordena sola por cuánta coordinación exige cada una. El TTL no exige ninguna: cada nodo decide por su cuenta y el sistema converge sin hablar. El purgado exige la máxima: un mensaje que debe alcanzar todos los nodos, con su latencia, sus límites de frecuencia y su posibilidad de fallo parcial. Y la clave versionada hace algo más astuto que ambas, que es cambiar la pregunta hasta que desaparece: si el nombre del recurso depende de su contenido, dos contenidos distintos nunca compiten por el mismo nombre y no hay nada que invalidar. Es exactamente el mismo movimiento que hace un hash de contenido en un empaquetador, que un commit en Git, que un almacén direccionado por contenido: convertir la identidad en una función del valor para que la mutación deje de existir como categoría. Cuando lo ves, empiezas a reconocer que muchos problemas duros de sistemas distribuidos no se resuelven con mejor coordinación sino con mejores nombres, y que la pregunta más productiva ante una invalidación complicada no es «¿cómo la propago rápido?» sino «¿por qué estas dos cosas distintas comparten nombre?».

⚔️ Elige la estrategia por sus costes, no por su comodidad
  1. Toma tres tipos de contenido de tu proyecto —un listado, una ficha y un asset— y asigna a cada uno una estrategia de invalidación justificando el retraso máximo tolerable.
  2. Añade Cache-Tag con etiquetas de entidad, categoría y plantilla a una respuesta, y purga una sola etiqueta comprobando qué páginas caducan.
  3. Ejecuta cache.delete desde un Worker y verifica desde otra región que la copia sigue sirviéndose; explica el resultado sin recurrir a la idea de fallo.
  4. Introduce el identificador de despliegue en la clave de un fragmento costoso y mide el impacto del camino frío inmediatamente después de publicar.
  5. Argumenta en cinco líneas por qué un purgado total en hora punta puede tumbar un origen que soportaba perfectamente el mismo tráfico un minuto antes.