Lo que realmente necesitas para cachear una API pública pequeña

Si mantienes una API REST pública pequeña y tu origen repite el mismo trabajo una y otra vez para los mismos clientes, no necesitas una base de datos nueva ni una cola. Necesitas tres mecanismos HTTP bien aplicados: una política de Cache-Control sensata, un ETag por recurso y If-None-Match (más If-Match para escrituras). Este artículo recorre cómo encajan esas piezas, dónde rinde cada una y los modos de fallo concretos que yo evitaría poner en producción.

Voy a mantenerlo aterrizado. Sin benchmarks inventados, sin promesas de que el caché te va a recortar la factura a la mitad. La ventaja es real, pero está acotada por la forma de tu tráfico; el coste de hacerlo mal es servir datos caducados o romper escrituras.

Las tres capas, en palabras claras

Piensa en el caché HTTP como tres capas concéntricas. Elige la que encaje con el perfil de riesgo de cada endpoint; no apliques una única regla a toda tu API.

  1. Ventana de frescura — Cache-Control: max-age=N. Se le dice a la clientela que la respuesta vale N segundos y que no debe revalidar durante esa ventana.
  2. Validación — ETag con If-None-Match. Pasada la ventana, la clientela pregunta al servidor si el recurso cambió. Si no, el servidor responde 304 Not Modified sin cuerpo.
  3. Seguridad de escritura — If-Match con el ETag que la clientela recibió en su última lectura. El servidor solo aplica un PUT o PATCH si el ETag de la clientela sigue coincidiendo con el actual. Evita actualizaciones perdidas cuando dos clientes editan el mismo registro.

Estas capas se componen. Puedes fijar max-age y aun así validar después de la ventana, y puedes exigir If-Match en cada escritura sin enviar jamás un max-age en la respuesta.

Configurar Cache-Control correctamente para una API

Cache-Control es la cabecera más decisiva que envías y, a la vez, la que más se suele configurar mal. Las directivas que usarás de verdad, tomadas del conjunto estándar documentado en MDN, son public, private, no-store, no-cache, max-age, s-maxage, must-revalidate y stale-while-revalidate.

Un conjunto de reglas que sigo:

  • no-store para cualquier respuesta personalizada. Si una respuesta varía según quién la pide (su identificador de usuario, su token, su locale), márcala con no-store. La cabecera private solo restringe quién puede cachear; no dice que la respuesta deba ignorarse. no-store es la única directiva que le dice de forma fiable a cada intermediario que no conserve una copia.
  • public, max-age=N para recursos compartidos que cambian poco. Una lista pública de categorías de productos, una tabla de tipos de cambio, un objeto de configuración leído por muchos clientes. Escoge N a partir de la frecuencia real con la que cambian los datos, no desde el deseo de ir rápido. Una ventana de 5 minutos sobre algo que cambia a la semana es una mentirijilla que nadie nota; una ventana de 1 hora sobre algo que cambia cada minuto es un ticket de soporte.
  • no-cache cuando quieres validación, no frescura. Esta es la directiva infravalorada. no-cache no significa “no cachees”. Significa “no reuses sin revalidar”. Combínala con un ETag y las clientes seguirán aprovechando la velocidad de un 304 cuando el recurso ya esté en su caché. Es el valor por defecto correcto para cualquier recurso que se lee mucho y cambia de vez en cuando, pero donde el coste de una lectura caducada no es cero.
  • s-maxage para separar la política entre navegadores y cachés compartidos. Si pones un CDN delante, s-maxage anula a max-age para los cachés compartidos y te permite dar al borde una ventana más larga que a las clientes individuales.
  • must-revalidate en cuanto te comprometas a una ventana de frescura. Sin ella, un caché que no alcance el origen puede servir datos caducados indefinidamente. Con ella, el caché debe descartar la entrada cuando expire.

Un patrón habitual para recursos públicos de solo lectura:

Cache-Control: public, max-age=60, must-revalidate
ETag: "a1f0c2"

Un patrón habitual para recursos por usuario:

Cache-Control: no-store

Un patrón habitual para recursos que se leen pero hay que validar y cambian de forma impredecible:

Cache-Control: no-cache
ETag: "a1f0c2"

Generar ETags que puedas defender

Un ETag es solo una cadena que el servidor entrega a la clientela como “huella” de la representación actual. Los dos sabores que distingue la especificación son los ETag fuertes y débiles.

  • Un ETag fuerte (ETag: "a1f0c2") significa que los bytes son idénticos byte a byte. Dos respuestas con el mismo ETag fuerte son intercambiables.
  • Un ETag débil (ETag: W/"a1f0c2") significa que las representaciones son semánticamente equivalentes pero pueden diferir en cosas sin importancia, como espacios o el orden de campos.

Para APIs JSON, un ETag débil suele ser la elección correcta. Es poco probable que el formato de tu respuesta sea byte-estable entre despliegues, y un hash sobre el JSON canónico es un validador débil perfectamente válido.

Formas habituales de generar el valor:

  1. Un hash del cuerpo de la respuesta. Sencillo y correcto, pero debes hashear la forma canónica, no lo que tu serializador haya decidido emitir ese día. Si cambias el serializador y los bytes cambian, el ETag cambia y se invalidan todos los cachés. Es molesto, pero no inseguro.
  2. Un número de versión que incrementas al escribir. Sube un contador en la fila de la base de datos cada vez que se actualiza. El ETag es el contador. Es barato y estable, pero solo si todos los caminos de código que mutan la fila pasan por el mismo punto de actualización.
  3. Un valor derivado de Last-Modified. Si ya guardas una marca temporal, puedes usarla. Es más débil que un hash y tiene ambigüedad a nivel de segundo, pero basta para muchas APIs públicas.

Lo que yo no haría: derivar el ETag de un hash de la fila completa, incluidos campos que la clientela no ve, y devolver luego solo un subconjunto de esos campos. El ETag y la representación tienen que describir los mismos bytes, o le acabaremos diciendo a la clientela que dos respuestas distintas son equivalentes.

GETs condicionales con If-None-Match

El flujo de lectura es el primero que implementarás, porque es la victoria más barata.

  1. La clientela envía GET /widgets/42. Sin cabeceras condicionales.
  2. El servidor responde 200 OK con el cuerpo y un ETag: "a1f0c2".
  3. La clientela guarda ambos. Más tarde, envía GET /widgets/42 con If-None-Match: "a1f0c2".
  4. El servidor compara el ETag entrante con el actual. Si coinciden, devuelve 304 Not Modified sin cuerpo y con el mismo ETag. Si no, devuelve 200 OK con el cuerpo nuevo y el ETag nuevo.

El ahorro de ancho de banda en un 304 es real: no hay cuerpo, solo cabeceras. El ahorro de latencia depende de cuánto del trabajo en el origen puedas saltarte. Como mínimo, un 304 te permite no serializar la respuesta y no enviarla por la red. Una implementación más agresiva puede atajar antes de tocar la base de datos guardando los ETags en memoria indexados por identificador de recurso.

Los frameworks ayudan. Flask, Express y FastAPI tienen soporte de primera clase o mantenido por la comunidad para ETags. Express, por ejemplo, puede autogenerar un ETag débil a partir del cuerpo de la respuesta. Para una API pequeña, yo me apoyaría en eso y lo sobrescribiría donde el valor por defecto no encaje.

Hay una cabecera condicional relacionada, If-Modified-Since, que toma una marca temporal en vez de un ETag. Es más antigua, más tosca y sigue siendo útil cuando genuinamente no quieres calcular un hash. Si envías a la vez ETag y Last-Modified en la misma respuesta, las clientes preferirán If-None-Match y está bien; las dos no entran en conflicto.

Escrituras condicionales con If-Match

El flujo de escritura es el que no deberías saltarte, porque es el que evita pérdida de datos.

  1. La clientela lee /orders/77 y recibe ETag: "b9c1".
  2. La clientela envía PATCH /orders/77 con If-Match: "b9c1" y el cuerpo parcial.
  3. El servidor compara el ETag entrante con el actual.
    • Coincide: aplica el parche, devuelve 200 OK con la nueva representación y un ETag nuevo.
    • No coincide: devuelve 412 Precondition Failed sin cambios en el cuerpo. La clientela tiene que re-leer, fusionar y reintentar.

Este es el patrón estándar de control de concurrencia optimista. El servidor no necesita mantener bloqueos; simplemente rechaza la escritura cuando la visión del cliente está caducada. If-Match es la cabecera correcta para PUT y PATCH. If-None-Match: * es una variante útil para PUT sobre un recurso que todavía no debe existir, por ejemplo, crear un registro con un identificador elegido por la clientela.

Si solo implementas una pieza de este artículo, implementa If-Match en las escrituras. Cuesta casi nada y es la diferencia entre una API que pierde ediciones y una API que le dice a la clientela “alguien más cambió esto, refresca y vuelve a intentarlo”.

Cuándo merece la pena cachear en cliente

Un marco honesto: cachea la respuesta cuando el coste de servirla actualizada sea mayor que el coste de equivocarse a veces.

  • Cachea sin miedo cuando el recurso se comparte entre usuarios, cambia con poca frecuencia y el peor escenario de una lectura caducada es una UI que se actualiza un segundo después. Ejemplos: listas de categorías públicas, feature flags, tipos de cambio, blobs de configuración.
  • Cachea con validación, no con frescura cuando el recurso se comparte pero cambia de forma impredecible. El camino del 304 es barato y seguro. Ejemplos: la lista de pedidos de un usuario, un artículo público.
  • No cachees en absoluto cuando la respuesta es por usuario, por petición, o lleva información que la solicitante no está autorizada a ver en otro momento. Ejemplos: cualquier cosa bajo sesión, cualquier cosa derivada del cuerpo de la petición, cualquier cosa que contenga un token de un solo uso.
  • Cuidado con Vary si hay un CDN delante. Vary: Authorization es la única forma segura de mantener separadas las respuestas por usuario, y muchos CDNs lo tratan como un peligro latente. La regla más simple es: si una respuesta es por usuario, envía Cache-Control: no-store y no intentes ser astuto.

Una nota sobre stale-while-revalidate. Le dice al caché que puede servir la copia caducada mientras trae una nueva en segundo plano. Para una API pública pequeña, es una forma barata de hacer que la primera petición tras la caducidad se sienta instantánea. La renuncia es que la segunda petición tras la caducidad puede seguir viendo el valor antiguo. Para la mayoría de lecturas públicas, eso es aceptable. Para cualquier cosa donde la frescura sea el producto, no lo es.

Pasos concretos de implementación

Una lista corta para recorrer, en orden, al añadir caché a una API pequeña ya existente.

  1. Inventaría tus endpoints. Marca cada uno como público-compartido, público-validar o por-usuario.
  2. Para los endpoints por usuario, pon Cache-Control: no-store y sigue. Es la victoria más fácil porque es la que evita fugas a través de cachés compartidos.
  3. Para los endpoints público-compartidos, escoge un max-age a partir de la frecuencia con la que cambian los datos de verdad, y añade must-revalidate para que un origen roto no alargue la ventana en silencio.
  4. Para los endpoints público-validar, pon Cache-Control: no-cache y genera un ETag débil para cada representación.
  5. En cada lectura, incluye tanto ETag como Last-Modified. Son baratos de añadir y dejan a las clientelas elegir.
  6. Atiende If-None-Match e If-Modified-Since en el camino de lectura. Devuelve 304 con los mismos validadores y sin cuerpo cuando coincidan.
  7. En cada escritura (PUT, PATCH, DELETE), exige If-Match con el ETag que la clientela vio por última vez. Devuelve 412 Precondition Failed cuando no coincida.
  8. Logea los 304 y los 412 aparte de los 200. La proporción te dice si la capa de caché está haciendo trabajo útil o solo añadiendo complejidad.
  9. Escribe tests para los caminos tristes: ETag ausente, ETag caducado, validador débil/fuerte que no encaja, If-Match sobre un recurso que no existe. Son los bugs que aparecen en producción y nunca en desarrollo.

Preguntas frecuentes

¿Necesito tanto ETag como Last-Modified? No, pero enviar ambos es barato y permite a las clientelas usar la que prefieran. Si tienes que elegir, quédate con ETag; no es ambiguo.

¿El ETag debe ser un hash de la fila completa o solo del cuerpo de la respuesta? Del cuerpo de la respuesta, en su forma canónica. Si hasheas la fila y la respuesta incluye un campo derivado, los dos pueden divergir.

¿no-cache es lo mismo que no-store? No. no-store prohíbe almacenar la respuesta. no-cache permite almacenarla pero exige revalidar en cada uso. Son directivas muy distintas y se confunden con frecuencia.

¿Qué significa el prefijo W/ en un ETag? Marca un validador débil. Dos respuestas con el mismo ETag débil son semánticamente equivalentes pero no necesariamente idénticas a nivel de bytes. Para APIs JSON esto es casi siempre lo que quieres.

¿Puedo usar If-Match en un POST? En general no, porque un POST crea un recurso que la clientela aún no ha visto. La forma If-None-Match: * es la relevante para “crear solo si esto no existe todavía”.

¿Cuánto se ahorra realmente con un 304? Nada de cuerpo, nada de coste de serialización, y la clientela reutiliza la representación en caché. La ganancia es ancho de banda en respuestas grandes y algo de CPU en el origen. La cifra depende del tamaño de tus respuestas y de tu backend; yo no prometería un porcentaje concreto sin medir tu propio tráfico.

Cierre

El caché para una API pública pequeña no es un proyecto de rendimiento. Es un proyecto de diseño. Escoge la política de Cache-Control adecuada por endpoint, reparte ETags débiles, valida lecturas con If-None-Match y protege escrituras con If-Match. Esos cuatro hábitos juntos bastan para la mayoría de APIs públicas y cuestan menos de mantener que la alternativa, que es una capa de Redis y una charla en un meetup sobre invalidación de cachés.

Fuentes