Claves de cache y variación: qué identifica una respuesta
La clave por defecto solo mira la URL, así que dos peticiones con idioma, plan o sesión distintos comparten entrada. Cómo construir una clave sintética que incluya las dimensiones que importan, cómo normalizarla para no fragmentar la cache, y por qué cachear una respuesta personalizada es el fallo más caro de este nivel.
Una cache es un mapa, y todo mapa vive o muere por su clave. La de Cloudflare, tanto en el pipeline automático como en la Cache API, se construye por defecto con la URL y poco más: esquema, dominio, ruta y cadena de consulta. Ni cabeceras, ni cookies, ni idioma, ni el usuario que pregunta. Esa elección es deliberada y sensata —maximiza los aciertos y evita que la cache se fragmente en polvo— pero también es la fuente de los dos errores opuestos que definen este nivel. Por un lado, servir a un usuario la respuesta que se generó para otro, porque la dimensión que los distinguía no formaba parte de la clave. Por otro, meter tantas dimensiones en la clave que ninguna entrada se reutiliza jamás y la cache se convierte en un almacén de escrituras inútiles. Acertar es elegir con precisión quirúrgica qué hace distinta a una respuesta de otra.
- Enumerar los componentes de la clave de cache por defecto y lo que deliberadamente deja fuera.
- Construir claves sintéticas con
new Requestpara incorporar idioma, plan u otras dimensiones reales. - Normalizar la clave para evitar la fragmentación por parámetros irrelevantes.
- Aplicar la disciplina que impide cachear respuestas personalizadas o autenticadas por accidente.
Qué identifica una entrada
Cuando llamas a cache.match(request), el runtime no compara objetos: extrae de esa petición una clave y busca por ella. La clave es la URL completa, y el método debe ser GET. Todo lo demás que viaja en la petición —Accept-Language, Authorization, Cookie, User-Agent— es invisible para la búsqueda. Dos peticiones a la misma ruta con cabeceras radicalmente distintas son, para la cache, la misma petición.
El mecanismo que HTTP inventó para expresar variación es la cabecera Vary, con la que una respuesta declara de qué cabeceras de la petición depende su contenido. En el edge conviene tratarla con desconfianza: el soporte es limitado y desigual, Vary: * hace que put rechace directamente, y confiar en ella para separar contenidos sensibles es apoyarse en una garantía que no controlas. La técnica robusta es otra y no depende de nadie: si una dimensión distingue dos respuestas, esa dimensión va en la URL de la clave.
| Componente | ¿Entra por defecto? | Cómo incorporarlo |
|---|---|---|
| Esquema, dominio y ruta | Sí | Ya forma parte de la clave |
| Cadena de consulta | Sí | Normalízala para no fragmentar |
| Idioma o región | No | Segmento o parámetro sintético |
| Plan o rol | No | Segmento sintético libre | pro |
| Cookie de sesión | No | No debería entrar nunca |
Construir una clave a medida
La herramienta es sencilla: fabricas una Request nueva con la URL que tú decides y la usas como clave, sin que el cliente llegue a ver jamás esa URL inventada. El segundo argumento hereda el resto de propiedades de la petición original, de modo que sigue siendo un GET legítimo.
function construirClave(request: Request): Request {
const url = new URL(request.url);
const idioma = request.headers.get("accept-language")?.slice(0, 2) ?? "es";
const clave = new URL(url.origin + url.pathname);
clave.searchParams.set("lang", idioma === "en" ? "en" : "es");
return new Request(clave.toString(), request);
}
const cache = caches.default;
const clave = construirClave(request);
const acierto = await cache.match(clave);
Observa la reducción deliberada: accept-language puede traer una lista larguísima de idiomas con pesos, y si la usáramos entera tendríamos una clave distinta por navegador. Al colapsarla a dos valores posibles, la cardinalidad de esa dimensión pasa de miles a dos. Esa operación —proyectar una entrada rica sobre el conjunto pequeño de valores que de verdad cambian la respuesta— es la esencia del diseño de claves.
La aritmética de la cardinalidad es implacable y conviene tenerla presente porque las dimensiones se multiplican, no se suman. Dos idiomas por tres planes por dos formatos son doce entradas por recurso, algo perfectamente sano. Añade el país —doscientos valores— y pasas a dos mil cuatrocientas; añade el modelo de dispositivo y ya no tienes una cache, tienes un registro de visitas con nombres largos. Cada dimensión nueva debe justificar su existencia demostrando que cambia realmente el cuerpo de la respuesta, no que podría cambiarlo en teoría.
Dimensiones legítimas
Cambian el contenido para grupos amplios y con pocos valores posibles: idioma, formato, versión de la API, tipo de dispositivo reducido a dos o tres clases.
Dimensiones caras
Correctas pero de alta cardinalidad: país, moneda, plan con muchos escalones. Úsalas solo si el contenido difiere de verdad.
Dimensiones prohibidas
Identidad del usuario, cookies de sesión, credenciales. Su presencia en la clave es una señal de que ese endpoint no debería cachearse compartido.
Nada te obliga a que la URL de la clave exista. Muchos equipos usan un dominio interno reconocible —algo como https://cache.interno/fragmento/cabecera?lang=es— para las entradas que no corresponden a una URL pública. Gana legibilidad en las trazas y elimina el riesgo de colisionar con recursos reales de la zona.
Normalizar antes de guardar
La cadena de consulta es la mayor fuente de fragmentación involuntaria que existe. Un enlace compartido en redes sociales llega con parámetros de campaña; un cliente añade una marca de tiempo para evitar la cache del navegador; el orden de los parámetros varía entre implementaciones. Cada variante genera una entrada nueva que contiene exactamente el mismo cuerpo, y el resultado es una cache llena de duplicados con una tasa de aciertos ridícula.
const IRRELEVANTES = ["utm_source", "utm_medium", "utm_campaign", "fbclid", "_t"];
function normalizar(url: URL): URL {
for (const p of IRRELEVANTES) url.searchParams.delete(p);
url.searchParams.sort();
url.hash = "";
return url;
}
Ordenar los parámetros hace que dos URL semánticamente idénticas produzcan la misma cadena; eliminar los irrelevantes colapsa todas las variantes de campaña en una sola entrada. Es una de esas optimizaciones aburridas cuyo efecto sobre la tasa de aciertos suele ser mayor que el de cualquier ajuste sofisticado de TTL.
Existe una variante más estricta y, en general, preferible: en lugar de enumerar lo que se elimina, enumerar lo que se conserva. Una lista blanca de parámetros aceptados hace que cualquier añadido futuro —un rastreador nuevo, una prueba de un equipo de marketing— no fragmente nada por omisión.
const ACEPTADOS = new Set(["page", "sort", "lang"]);
function canonica(entrada: URL): URL {
const salida = new URL(entrada.origin + entrada.pathname);
for (const [k, v] of entrada.searchParams) {
if (ACEPTADOS.has(k)) salida.searchParams.append(k, v);
}
salida.searchParams.sort();
return salida;
}
La diferencia entre las dos versiones es la dirección del riesgo, y por eso importa. Con lista negra, olvidarse de un parámetro fragmenta la cache en silencio y solo lo notas mirando métricas. Con lista blanca, olvidarse de un parámetro hace que dos contenidos distintos compartan clave, lo cual se manifiesta como un error visible y se corrige de inmediato. Prefiere siempre el fallo ruidoso al fallo caro y mudo, con una excepción categórica: si el parámetro olvidado distingue a dos usuarios, la lista blanca deja de ser una molestia y pasa a ser una fuga. Antes de adoptarla, verifica que ningún parámetro de tu API transporte identidad.
flowchart LR P[Peticion entrante] --> N[Quitar parametros de campana y ordenar] N --> D[Anadir dimensiones que si cambian la respuesta] D --> K[Clave canonica] K --> M[match] M -->|acierto| H[Respuesta compartida por muchos usuarios] style H fill:#a6e3a1,color:#11111b
El error caro: cachear lo personalizado
Todo lo anterior se vuelve peligroso en el momento en que la respuesta depende de quién pregunta. Si un endpoint devuelve el panel del usuario y lo cacheas con la clave por defecto, el primero que entre deja su panel guardado y los siguientes lo recibirán. No es un fallo de rendimiento: es una fuga de datos, y de las que se descubren tarde y mal porque el código funciona perfectamente en desarrollo, donde solo hay un usuario.
La disciplina que evita ese desastre tiene tres reglas y ninguna es opcional. La primera: si la petición trae credenciales —Authorization o una cookie de sesión— y la respuesta depende de ellas, no se cachea en una cache compartida; se marca Cache-Control: private y se deja al navegador. La segunda: si aun así necesitas cachear algo por usuario, la identidad debe formar parte explícita de la clave, con la conciencia de que la tasa de aciertos tenderá a cero salvo que ese usuario repita mucho. La tercera: separa el endpoint. Casi siempre la respuesta personalizada es un noventa por ciento de contenido común y un diez por ciento de datos propios; partirla en un fragmento público y muy cacheable más una llamada pequeña y privada da mejor rendimiento que cualquier malabarismo con la clave.
La primera regla se codifica en una guarda de tres líneas que debería estar en la puerta de cualquier Worker que cachee, y cuyo valor es que hace explícita una decisión que de otro modo queda implícita en la ausencia de código.
const autenticada =
request.headers.has("authorization") ||
(request.headers.get("cookie") ?? "").includes("sesion=");
if (autenticada) {
const privada = await servirPersonalizado(request, env);
privada.headers.set("cache-control", "private, no-store");
return privada; // ni match ni put: esta respuesta no se comparte
}
Que la guarda esté escrita, y no simplemente entendida, es lo que la hace sobrevivir al siguiente cambio del código. Un comentario que dice «esto no se cachea» desaparece en la primera refactorización; una condición que corta el flujo antes de tocar la cache permanece y falla de forma visible si alguien la mueve de sitio.
Una fuga por clave insuficiente no produce errores, ni excepciones, ni alertas. Produce una respuesta correcta entregada a la persona equivocada, y solo la detecta un usuario que ve el nombre de otro en su pantalla. Por eso la revisión de qué se cachea y con qué clave debe hacerse antes de desplegar y no después: aquí no hay red de seguridad automática que te avise.
Cuando defines una clave de cache no estás configurando una infraestructura: estás formulando una hipótesis sobre la estructura de tu dominio. Dices, con toda la fuerza de una afirmación lógica, que dos peticiones que produzcan la misma clave merecen exactamente la misma respuesta, y que cualquier diferencia entre ellas es irrelevante para el resultado. Es una definición de equivalencia, y como toda definición de equivalencia divide tu tráfico en clases. Si la definición es demasiado gruesa, metes en la misma clase cosas que deberían distinguirse y la cache filtra la respuesta de uno al otro; si es demasiado fina, partes en clases distintas cosas idénticas y la cache deja de servir para nada porque cada petición estrena su propia entrada. Entre esos dos extremos no hay un ajuste que se calibre midiendo: hay una comprensión del dominio que se gana entendiendo qué hace realmente distinta a una respuesta. Y aquí está lo que casi nadie ve a la primera: la calidad de tu clave está determinada por la calidad de tus fronteras. Un endpoint que mezcla contenido público con datos del usuario no tiene ninguna clave buena, porque la mezcla misma es el problema —cualquier clave que elijas será demasiado gruesa para los datos privados o demasiado fina para los públicos—. La solución nunca está en la cache; está en partir el endpoint en dos, uno que dependa solo de la URL y otro que dependa solo del usuario, cada uno con la política que le corresponde. Por eso quien lleva tiempo en esto sabe leer las tasas de aciertos como un diagnóstico arquitectónico: una cache que no acierta rara vez indica una cache mal configurada, y casi siempre indica un sistema cuyas responsabilidades están mal separadas. La cache no crea esas fronteras, se limita a revelarlas con una honestidad brutal.
- Toma un endpoint real de tu proyecto y escribe la lista exacta de dimensiones que cambian su respuesta; justifica cada una y descarta las que no importan.
- Implementa
construirClavecolapsandoaccept-languagea un conjunto cerrado de valores y mide cómo cambia la tasa de aciertos frente a usar la cabecera íntegra. - Añade normalización de parámetros de campaña y comprueba con tráfico real cuántas entradas duplicadas desaparecen.
- Cachea a propósito un endpoint personalizado en un entorno de pruebas con dos sesiones distintas, observa la fuga y después arréglala partiendo el endpoint en dos.
- Razona qué tasa de aciertos esperarías si incluyeras el identificador de usuario en la clave de un panel visitado una vez al día, y decide si merece la pena.