rendimiento de apis · cache http · etag · cache-control · peticiones condicionales · rest api · diseno de apis
Caché HTTP para APIs públicas pequeñas: ETags, peticiones condicionales y Cache-Control sin pisar minas
Guía práctica y opinada para implementadores independientes: cómo añadir ETags, If-None-Match, If-Match y Cache-Control en una API REST pública pequeña, con sus renuncias y trampas.
Publicado:
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.
- 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. - Validación —
ETagconIf-None-Match. Pasada la ventana, la clientela pregunta al servidor si el recurso cambió. Si no, el servidor responde304 Not Modifiedsin cuerpo. - Seguridad de escritura —
If-Matchcon el ETag que la clientela recibió en su última lectura. El servidor solo aplica unPUToPATCHsi 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-storepara cualquier respuesta personalizada. Si una respuesta varía según quién la pide (su identificador de usuario, su token, su locale), márcala conno-store. La cabeceraprivatesolo restringe quién puede cachear; no dice que la respuesta deba ignorarse.no-storees la única directiva que le dice de forma fiable a cada intermediario que no conserve una copia.public, max-age=Npara 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-cachecuando quieres validación, no frescura. Esta es la directiva infravalorada.no-cacheno significa “no cachees”. Significa “no reuses sin revalidar”. Combínala con unETagy 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-maxagepara separar la política entre navegadores y cachés compartidos. Si pones un CDN delante,s-maxageanula amax-agepara los cachés compartidos y te permite dar al borde una ventana más larga que a las clientes individuales.must-revalidateen 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:
- 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.
- 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.
- 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.
- La clientela envía
GET /widgets/42. Sin cabeceras condicionales. - El servidor responde
200 OKcon el cuerpo y unETag: "a1f0c2". - La clientela guarda ambos. Más tarde, envía
GET /widgets/42conIf-None-Match: "a1f0c2". - El servidor compara el ETag entrante con el actual. Si coinciden, devuelve
304 Not Modifiedsin cuerpo y con el mismoETag. Si no, devuelve200 OKcon el cuerpo nuevo y elETagnuevo.
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.
- La clientela lee
/orders/77y recibeETag: "b9c1". - La clientela envía
PATCH /orders/77conIf-Match: "b9c1"y el cuerpo parcial. - El servidor compara el ETag entrante con el actual.
- Coincide: aplica el parche, devuelve
200 OKcon la nueva representación y un ETag nuevo. - No coincide: devuelve
412 Precondition Failedsin cambios en el cuerpo. La clientela tiene que re-leer, fusionar y reintentar.
- Coincide: aplica el parche, devuelve
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
Varysi hay un CDN delante.Vary: Authorizationes 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íaCache-Control: no-storey 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.
- Inventaría tus endpoints. Marca cada uno como público-compartido, público-validar o por-usuario.
- Para los endpoints por usuario, pon
Cache-Control: no-storey sigue. Es la victoria más fácil porque es la que evita fugas a través de cachés compartidos. - Para los endpoints público-compartidos, escoge un
max-agea partir de la frecuencia con la que cambian los datos de verdad, y añademust-revalidatepara que un origen roto no alargue la ventana en silencio. - Para los endpoints público-validar, pon
Cache-Control: no-cachey genera un ETag débil para cada representación. - En cada lectura, incluye tanto
ETagcomoLast-Modified. Son baratos de añadir y dejan a las clientelas elegir. - Atiende
If-None-MatcheIf-Modified-Sinceen el camino de lectura. Devuelve304con los mismos validadores y sin cuerpo cuando coincidan. - En cada escritura (
PUT,PATCH,DELETE), exigeIf-Matchcon el ETag que la clientela vio por última vez. Devuelve412 Precondition Failedcuando no coincida. - 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.
- Escribe tests para los caminos tristes: ETag ausente, ETag caducado, validador débil/fuerte que no encaja,
If-Matchsobre 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
- https://zuplo.com/learning-center/optimizing-rest-apis-with-conditional-requests-and-etags
- https://requestly.com/blog/etag-header-api
- https://www.speakeasy.com/api-design/caching
- https://schweizerischebundesbahnen.github.io/api-principles/restful/best-practices
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://www.rfc-editor.org/info/rfc9111
- https://www.apyflux.com/blogs/api-development/how-to-enable-partial-updates-apis-with-patch-etags
