Guía completa de caching en GraphQL
Cachear respuestas GraphQL en cada capa: CDN, gateway, DataLoader, persisted queries y cliente. Cubre cache keys, invalidación, directivas HTTP caching y Apollo Client cache.
Introducción
El caching en GraphQL es más difícil que en REST porque cada request va a la
misma URL (/graphql) con un body POST diferente. REST puede cachear a nivel de
URL; GraphQL necesita cache keys basadas en el contenido de la query. A pesar de
esto, hay varias capas donde podés cachear datos de GraphQL de forma efectiva.
Esta guía recorre cada capa desde el CDN hasta el cliente, con ejemplos de código
y tradeoffs.
Aprendí casi todo por las malas. Hace unos años shippeé una API GraphQL para un catálogo de e-commerce. La primera versión no tenía caching: cada page load pegaba a la base de datos por datos de productos, árboles de categorías y pricing. Los tiempos de respuesta eran 300-500ms para queries simples. Después de añadir DataLoader, CDN caching con persisted queries y una capa de Redis, las mismas queries bajaron a 20-40ms para cache hits. La parte difícil no fue implementar ninguna capa individual. Fue entender cómo interactúan y dónde los datos pueden quedar stale.
Esta guía recorre cada capa de caching en orden, desde el cliente hasta la base de datos. Vas a ver dónde cachear, qué cachear, qué evitar y cómo manejar invalidación cuando los datos cambian. Al final vas a tener un checklist de producción para cachear cualquier API GraphQL.
Capas de Caching
Cada capa cumple un propósito diferente:
- Client cache: evita requests de red redundantes para los mismos datos.
- CDN/edge cache: sirve respuestas cerca de los usuarios geográficamente.
- Gateway cache: cachea respuestas de subgrafos para reducir la carga.
- DataLoader: agrupa y cachea dentro de una sola request para prevenir N+1.
- Database cache: cachea resultados de queries a nivel de ORM o base de datos.
HTTP Caching con GET Requests
Cambiar de POST a GET
Por defecto, los clientes GraphQL envían POST requests. Las respuestas POST no son cacheables por CDNs ni navegadores. Cambiá a GET para queries cacheables.
// Apollo Client: usar GET para queries
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
const client = new ApolloClient({
link: new HttpLink({
uri: "/graphql",
useGETForQueries: true,
}),
cache: new InMemoryCache(),
});
El servidor debe soportar GET requests con la query en la URL:
// Express server
app.get("/graphql", (req, res) => {
const { query, variables, operationName } = req.query;
// Ejecutar y retornar
});
Directivas Cache-Control
Usá la directiva @cacheControl para definir max-age y scope en tipos y
campos.
type Query {
product(id: ID!): Product @cacheControl(maxAge: 3600)
products: [Product!]! @cacheControl(maxAge: 600)
currentUser: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Product @cacheControl(maxAge: 3600) {
id: ID!
name: String!
price: Float!
}
type User @cacheControl(maxAge: 0, scope: PRIVATE) {
id: ID!
email: String!
}
El servidor calcula la política de cache para cada query en base a los campos
solicitados. Si una query incluye algún campo PRIVATE, toda la respuesta es
privada. El max-age final resulta del valor más chico entre todos los campos que la
query solicita.
import { ApolloServerPluginCacheControl } from "@apollo/server/plugin/cacheControl";
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 0 })],
});
El plugin setea headers Cache-Control: max-age=3600, public o
Cache-Control: max-age=0, private en las respuestas.
CDN Caching
Para un vistazo más amplio a estrategias de edge caching, ver la guía de estrategias de CDN caching.
Cómo funciona el CDN Caching para GraphQL
Cuando usás GET requests con headers cache-control, los CDNs (Cloudflare,
Fastly, CloudFront) cachean respuestas en base a la URL completa, incluyendo el
query string.
GET /graphql?query={product(id:1){id name price}}&variables={}
El CDN almacena la respuesta y la sirve directamente para URLs idénticas. Esto funciona bien para datos públicos y no específicos de usuario.
Consideraciones de Cache Key
La cache key es la URL completa. Dos queries que difieren solo en whitespace producen cache keys diferentes. Usá persisted queries para normalizarlas.
Persisted Queries para CDN Caching
Con persisted queries, el cliente envía un hash en lugar de la query completa:
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123","version":1}}
Todos los clientes que usan la misma query producen la misma URL, maximizando los hits de cache del CDN.
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
import { sha256 } from "crypto-hash";
const persistedQueryLink = createPersistedQueryLink({ sha256 });
const httpLink = new HttpLink({ uri: "/graphql", useGETForQueries: true });
const client = new ApolloClient({
link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache(),
});
Purge del CDN en Cambios de Datos
Cuando los datos cambian, purgá el cache del CDN. Usá webhooks o llamadas API al proveedor de CDN.
// Después de actualizar un producto
async function purgeProductCache(productId) {
await fetch("https://api.fastly.com/purge/abc123", {
method: "POST",
headers: { "Fastly-Key": process.env.FASTLY_KEY },
body: JSON.stringify({ surrogates: [`product-${productId}`] }),
});
}
Usá surrogate keys en el header de respuesta Surrogate-Key para etiquetar
respuestas y hacer purging dirigido:
res.setHeader("Surrogate-Key", `product-${productId} products`);
Caching a Nivel de Gateway
Apollo Router Cache
Apollo Router puede cachear respuestas de subgrafos. Esto reduce la carga en subgrafos para queries repetidas.
# router.yaml
supergraph:
cache:
enabled: true
ttl: 300s
Entity Cache
Cacheá los resultados de resolver entidades. De esta forma, las referencias repetidas no vuelven a pegarle al subgrafo.
# router.yaml
apq:
router:
cache:
in_memory:
limit: 1000
DataLoader: Caching Por-Request
DataLoader agrupa y cachea dentro de una sola request GraphQL. Previene queries N+1 al juntar cargas individuales en un solo batch. Para un análisis más profundo, ver el patrón DataLoader.
import DataLoader from "dataloader";
const resolvers = {
Query: {
products: async (_root, { ids }, ctx) => {
const products = await ctx.db.products.findMany({ where: { id: { in: ids } } });
return products;
},
},
Product: {
category: (product, _args, ctx) => ctx.loaders.categoryLoader.load(product.categoryId),
},
};
// Fábrica de contexto: crear DataLoaders nuevos por request
function createContext(db) {
return {
db,
loaders: {
categoryLoader: new DataLoader(async (categoryIds) => {
const categories = await db.categories.findMany({ where: { id: { in: categoryIds } } });
const map = new Map(categories.map((c) => [c.id, c]));
return categoryIds.map((id) => map.get(id));
}),
},
};
}
Caching de DataLoader Dentro de una Request
DataLoader cachea por key dentro de una sola request. Si dos resolvers llaman
load(42), la base de datos se consulta una sola vez. La segunda llamada
retorna el resultado cacheado. Este cache es por-request: una nueva request
obtiene DataLoaders nuevos.
DataLoader vs Redis Cache
DataLoader es un cache por-request. Redis es un cache cross-request. Usá ambos: DataLoader previene N+1 dentro de una request; Redis previene consultas redundantes a la base de datos entre requests. Para patrones de Redis, ver la guía de estrategias de Redis caching.
const categoryLoader = new DataLoader(async (categoryIds) => {
// Verificar Redis primero
const cached = await ctx.redis.mget(categoryIds.map((id) => `category:${id}`));
const missing = categoryIds.filter((id, i) => !cached[i]);
// Consultar faltantes en la base de datos
if (missing.length > 0) {
const fromDb = await db.categories.findMany({ where: { id: { in: missing } } });
await Promise.all(fromDb.map((c) => ctx.redis.set(`category:${c.id}`, JSON.stringify(c), "EX", 3600)));
}
// Combinar cacheados y frescos
return categoryIds.map((id, i) => cached[i] ? JSON.parse(cached[i]) : fromDb.find((c) => c.id === id));
});
Caching del Lado del Cliente con Apollo Client
Cache Normalizado
Apollo Client almacena datos en un cache normalizado por __typename:id. Esto
significa que actualizar un producto en una query lo actualiza en todos lados.
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Product: {
keyFields: ["id"],
},
Query: {
fields: {
products: {
merge(existing = [], incoming) {
return incoming;
},
},
},
},
},
}),
});
Actualizaciones de Cache Después de Mutaciones
Después de una mutación, actualizá el cache para reflejar el cambio sin volver a consultar.
const CREATE_PRODUCT = gql`
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
product { id name price }
}
}
`;
const GET_PRODUCTS = gql`
query GetProducts {
products { id name price }
}
`;
function CreateProduct() {
const [createProduct] = useMutation(CREATE_PRODUCT, {
update(cache, { data }) {
const newProduct = data.createProduct.product;
cache.modify({
fields: {
products(existing = []) {
cache.writeFragment({
data: newProduct,
fragment: gql`fragment NewProduct on Product { id name price }`,
});
return [...existing, newProduct];
},
},
});
},
});
// ...
}
Persistencia de Cache
Persistí el cache a localStorage o sessionStorage para que sobreviva
recargas de página.
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { LocalStorageWrapper, persistCache } from "apollo3-cache-persist";
const cache = new InMemoryCache();
await persistCache({
cache,
storage: new LocalStorageWrapper(window.localStorage),
maxSize: 1048576, // 1MB
});
Estrategias de Invalidación de Cache
Expiración Basada en TTL
Seteá un time-to-live en los datos cacheados. Cuando el TTL expira, la siguiente request pide datos frescos al origen. Es simple, pero puede servir datos obsoletos durante el TTL.
// Redis SET con TTL
await redis.set("product:42", JSON.stringify(product), "EX", 3600); // 1 hora
Invalidación Event-Driven
Publicá eventos de invalidación cuando los datos cambian. Los suscriptores eliminan la entrada de cache.
// Después de actualizar un producto
async function updateProduct(id, data) {
const product = await db.products.update({ where: { id }, data });
await redis.del(`product:${id}`);
await redis.publish("cache-invalidation", JSON.stringify({ type: "product", id }));
return product;
}
// Suscriptor
redis.subscribe("cache-invalidation", (message) => {
const { type, id } = JSON.parse(message);
redis.del(`${type}:${id}`);
});
Cache Keys Versionadas
Incluí un número de versión en la cache key. Incrementá la versión cuando los datos cambian. Las entradas viejas expiran naturalmente.
const version = await redis.get("product:version") || "1";
const cacheKey = `product:${id}:v${version}`;
const cached = await redis.get(cacheKey);
Invalidación Basada en Tags
Etiquetá entradas de cache con entidades relacionadas. Purgá por tag.
// Set con tags
await redis.set("product:42", JSON.stringify(product), "EX", 3600);
await redis.sadd("tag:category:5", "product:42");
// Purgar por tag
async function purgeCategory(categoryId) {
const keys = await redis.smembers(`tag:category:${categoryId}`);
if (keys.length > 0) {
await redis.del(...keys);
await redis.del(`tag:category:${categoryId}`);
}
}
Monitoreo de Performance del Cache
No podés optimizar lo que no medís. Seteé dashboards para cada capa de caching desde el principio, y me pagó cada vez.
Métricas Clave
- Hit rate por capa: CDN, gateway, DataLoader, Redis. Si cualquier capa baja del 50%, investigá por qué.
- Tasa de evicción: evicciones altas significan que tu cache es chico o los TTLs son muy cortos.
- Incidentes de datos stale: trackeá cuán seguido los usuarios reportan ver datos desactualizados. Esta es tu métrica de efectividad de invalidación.
- Load del origen: queries por segundo a la base de datos. Si el caching funciona, el load del origen se mantiene plano aunque el tráfico crezca.
- TTL vs frecuencia real de cambio: si tu TTL es 1 hora pero los datos cambian cada 5 minutos, estás sirviendo datos stale 55 minutos de cada 60.
Herramientas
La mayoría de los CDNs exponen hit rates en su dashboard. Para Redis, usá
INFO stats para verificar keyspace_hits y keyspace_misses. Para DataLoader,
añadir logging simple en la batch function para contar cache hits vs database
calls. Para Apollo Client, client.cache.extract() te deja inspeccionar el
cache normalizado en dev tools.
Una vez atrapé un issue en producción donde el hit rate del CDN cayó de 80% a 20% de la noche a la mañana. Resulta que un developer había añadido un header custom a todas las requests GraphQL, lo que cambió la cache key de cada respuesta. El monitoreo lo atrapó antes de que los usuarios lo notaran.
Qué Cachear vs Qué No Cachear
Cachear
- Datos públicos y de mucha lectura (catálogos, posts, categorías).
- Datos que cambian poco (configuraciones, datos de referencia).
- Datos agregados (conteos, resúmenes, reportes).
- Datos específicos de usuario con TTL corto (perfil, preferencias).
No Cachear
- Datos en tiempo real (precios de acciones, resultados en vivo).
- Datos sensibles que requieren lecturas frescas (saldo, registros médicos).
- Datos detrás de mutaciones que deben ser inmediatamente consistentes.
- Tokens de autenticación y datos de sesión.
Checklist de Producción
- GET requests habilitados para queries cacheables.
- Directivas
@cacheControlen tipos y campos públicos. - Persisted queries habilitadas para cache keys consistentes en CDN.
- CDN configurado para cachear respuestas
public. - Mecanismo de purge de CDN para cambios de datos.
- DataLoader para todos los resolvers de lista y relación.
- Redis cache para entidades frecuentemente accedidas.
- Apollo Client normalized cache configurado.
- Actualizaciones de cache después de mutaciones (sin datos obsoletos).
- Persistencia de cache para soporte offline (si se necesita).
- Monitoreo de cache hit rate en cada capa.
- TTLs seteados apropiadamente por tipo de dato.
Mejores Prácticas
Llevo varios años shippeando APIs GraphQL en producción y estas son las prácticas que realmente valieron la pena:
- Empezá con DataLoader, añadí Redis después. DataLoader te da el mayor beneficio con el menor esfuerzo. Una vez vi una query de lista de productos bajar de 200ms a 40ms solo por batchear cargas de categorías. Redis vino después cuando notamos que las mismas categorías se cargaban entre requests.
- Usá
@cacheControlen tipos, no solo en campos. SetearmaxAgeen el tipoProducthace que cada campo lo herede. Si no, te olvidás de anotar campos. Lo aprendí por las malas debuggeando por qué un catálogo no se cacheaba: tres campos no tenían la directiva. - Seteá
scope: PRIVATEen todo lo específico de usuario. Cachear públicamente datos de usuario es un bug de seguridad. Vi equipos que cacheaban respuestas decurrentUseraccidentalmente y le servían a un usuario los datos de otro. Auditá tu schema por esto. - Monitoreá el hit rate por capa. Si el hit rate del CDN está abajo del 50% para datos públicos, tus cache keys son demasiado variadas. Revisá diferencias de whitespace, persisted queries faltantes o headers específicos de usuario filtrándose en la cache key.
- Purgá en writes, no en un schedule. Invalidación event-driven le gana al TTL para datos que cambian por acción del usuario. Trabajé en un sistema que purgaba cada 5 minutos. Los usuarios veían precios stale hasta 5 minutos después de que un admin los actualizaba. Cambiar a purging event-driven lo resolvió.
Errores Comunes
- Cachear mutaciones. Vi equipos que añadían
@cacheControla respuestas de mutaciones pensando que aceleraría las cosas. Las mutaciones escriben datos; cachearlas significa que el write podría no llegar al servidor. Solo cacheá queries. - Olvidar crear DataLoaders nuevos por request. Si reusás DataLoaders entre requests, servís datos stale del request anterior. Siempre crealos en la context factory, no a nivel módulo.
- Usar POST para queries cacheables. Las respuestas POST no son cacheadas por CDNs ni navegadores. Cambiá a GET con persisted queries para datos públicos.
- Cachear demasiado agresivamente. Un TTL de 24 horas en perfiles de usuario significa que los usuarios no pueden ver sus propias actualizaciones por un día. Usá TTLs cortos (1-5 minutos) para datos específicos de usuario y TTLs largos (1 hora+) para datos públicos.
- No manejar cache stampede. Cuando una entrada popular expira, todas las
requests pegan a la base de datos al mismo tiempo. Usá un lock o el patrón
stale-while-revalidatepara prevenir thundering herd. - Ignorar los headers
Surrogate-Key. Sin surrogate keys, no podés hacer purges dirigidos. Vas a terminar purgando todo el cache del CDN en cada cambio de datos, lo que derrota el propósito.
See Also
- Apollo Server Caching Guide: documentación oficial sobre plugins y directivas de caching de Apollo Server.
- DataLoader: la librería original de DataLoader por Facebook, con patrones de batch caching.
- GraphQL Persisted Queries: el link de persisted queries de Apollo Client para cache keys consistentes en CDN.
- Redis Caching Patterns: documentación de Redis sobre patrones de caching, TTLs y pub/sub para invalidación.
- Patrón DataLoader: un análisis más profundo de DataLoader batching y caching por request.
- Guía de estrategias de CDN caching: una guía más amplia de estrategias de CDN caching más allá de GraphQL.
Preguntas frecuentes
¿Por qué no puedo cachear GraphQL como REST?
REST cachea por URL. Cada recurso tiene una URL única, entonces el CDN o
navegador puede cachearlo. GraphQL envía todas las requests a /graphql, por lo
que la URL es la misma para cada query. Para cachear GraphQL, necesitás GET
requests con la query en la URL, o persisted queries que produzcan cache keys
consistentes.
¿Debería cachear mutaciones?
No. No. Las mutaciones escriben datos y tienen que llegar al servidor. Solo cacheá queries
(operaciones de lectura). La directiva @cacheControl solo aplica a respuestas
de query.
¿Por cuánto tiempo debería cachear datos?
Depende de cuán obsoletos pueden estar los datos. Catálogos: 1 hora. Perfiles: 5 minutos. Configuraciones: 24 horas. Datos en tiempo real: 0 (sin cache). Seteá el TTL al máximo staleness aceptable para cada tipo de dato.
¿Cuál es la diferencia entre Apollo Client cache y server cache?
Apollo Client cache está en el browser. Previene requests de red redundantes y permite actualizaciones instantáneas de UI después de mutaciones. Server cache (CDN, Redis, DataLoader) previene consultas redundantes a la base de datos y computación. Ambas capas son necesarias para una aplicación rápida.
¿Cómo testeo el comportamiento del cache?
Verificá que queries repetidas retornen resultados cacheados (revisá headers de
respuesta como Age y X-Cache: HIT). Probá que las mutaciones invaliden el
cache. Asegurate de que no se sirvan datos obsoletos después de actualizaciones.
Usá client.cache.extract() de Apollo Client para inspeccionar el cache del
cliente.
¿Debería usar Redis o Memcached para caching GraphQL?
Redis soporta datos estructurados (hashes, sets, sorted sets), TTLs y pub/sub para invalidación de cache. Memcached es más simple y rápido para caching key-value. Usá Redis si necesitás invalidación por tags o pub/sub. Usá Memcached si solo necesitás caching TTL básico.
Recursos Relacionados
Diseño de Esquemas GraphQL
Disenar esquemas GraphQL para evolucion, rendimiento y mantenibilidad. Cubre diseno de tipos, conexiones, mutaciones, manejo de errores, deprecation y workflows schema-first vs code-first.
GuideSeguridad GraphQL
Asegurar APIs GraphQL contra leaks de introspection, ataques de profundidad, DoS por costo, abuso de batching e inyeccion. Cubre patrones de auth, rate limiting y hardening de produccion.
PatternPatron DataLoader en GraphQL
Consolida llamadas de carga individuales en llamadas batch con cache por peticion para prevenir queries N+1 y fetches redundantes.
GuideTesting GraphQL
Testear APIs GraphQL en cada capa: unit tests para resolvers, integration tests para esquemas, E2E para operaciones. Cubre mocking, fixtures, snapshot testing y performance testing.
GuideEstrategia de Caching CDN
Disenar caching CDN para aplicaciones web y APIs. Cubre edge caching, cache keys, cache headers, estrategias de invalidacion, surrogate keys y setups multi-CDN para rendimiento global.
GuideEstrategias de Caching con Redis
caching con Redis: cache-aside, read-through, write-through, write-behind y refresh-ahead. Cubre politicas de eviction, tuning de TTL, serializacion y operaciones de produccion.