website/pages/docs/caching-strategies.mdx
Caching is a core strategy for improving the performance and scalability of GraphQL servers. Because GraphQL allows clients to specify exactly what they need, the server often does more work per request (but fewer requests) compared to many other APIs.
This guide explores different levels of caching in a GraphQL.js so you can apply the right strategy for your application.
GraphQL servers commonly face performance bottlenecks due to repeated fetching of the same data, costly resolver logic, or expensive database queries. Since GraphQL shifts much of the composition responsibility to the server, caching becomes essential for maintaining fast response times and managing backend load.
There are several opportunities to apply caching within a GraphQL server:
Understanding where caching fits in your application flow helps you apply it strategically without overcomplicating your system.
Resolver-level caching is useful when a specific field’s value is expensive to compute and commonly requested with the same arguments. Instead of recomputing or refetching the data on every request, you can store the result temporarily in memory and return it directly when the same input appears again.
For example, consider a field that returns information about a product:
// utils/cache.js
import LRU from 'lru-cache';
export const productCache = new LRU({ max: 1000, ttl: 1000 * 60 }); // 1 min TTL
The next example shows how to use that cache inside a resolver to avoid repeated database lookups:
// resolvers/product.js
import { productCache } from '../utils/cache.js';
export const resolvers = {
Query: {
product(_, { id }, context) {
const cached = productCache.get(id);
if (cached) return cached;
const productPromise = context.db.products.findById(id);
productCache.set(id, productPromise);
return productPromise;
},
},
};
This example uses lru-cache, which limits the
number of stored items and support TTL-based expiration. You can replace it with Redis or
another cache if you need cross-process consistency.
DataLoader is a utility for batching and caching backend access during a single GraphQL operation. It's designed to solve the N+1 problem, where the same resource is fetched repeatedly in a single query across multiple fields.
The following example defines a DataLoader instance that batches user lookups by ID:
// loaders/userLoader.js
import DataLoader from 'dataloader';
import { batchGetUsers } from '../services/users.js';
export const createUserLoader = () => new DataLoader(ids => batchGetUsers(ids));
You can then include the loader in the per-request context to isolate it from other operations:
// context.js
import { createUserLoader } from './loaders/userLoader.js';
export function createContext() {
return {
userLoader: createUserLoader(),
};
}
Finally, use the loader in your resolvers to batch-fetch users efficiently:
// resolvers/user.js
export const resolvers = {
Query: {
async users(_, __, context) {
return context.userLoader.loadMany([1, 2, 3]);
},
},
};
To read more about DataLoader and the N+1 problem, see Solving the N+1 Problem with DataLoader.
Operation result caching stores the complete response of a query, keyed by the query string, variables, and potentially HTTP headers. It can dramatically improve performance when the same query is sent frequently, particularly for read-heavy applications.
The following example defines two functions to interact with a Redis cache, storing and retrieving cached results:
// cache/queryCache.js
import Redis from 'ioredis';
const redis = new Redis();
export async function getCachedResponse(cacheKey) {
const cached = await redis.get(cacheKey);
return cached ? JSON.parse(cached) : null;
}
export async function cacheResponse(cacheKey, result, ttl = 60) {
await redis.set(cacheKey, JSON.stringify(result), 'EX', ttl);
}
The next example shows how to wrap your execution logic to check the cache first and store results afterward:
// graphql/executeWithCache.js
import { getCachedResponse, cacheResponse } from '../cache/queryCache.js';
/**
* Stores in-flight requests to executeWithCache such that concurrent
* requests with the same cacheKey will only result in one call to
* `getCachedResponse` / `cacheResponse`. Once a request completes
* (with or without error) it is removed from the map.
*/
const inflight = new Map();
export function executeWithCache({ cacheKey, executeFn }) {
const existing = inflight.get(cacheKey);
if (existing) return existing;
const promise = _executeWithCacheUnbatched({ cacheKey, executeFn });
inflight.set(cacheKey, promise);
return promise.finally(() => inflight.delete(cacheKey));
}
async function _executeWithCacheUnbatched({ cacheKey, executeFn }) {
const cached = await getCachedResponse(cacheKey);
if (cached) return cached;
const result = await executeFn();
await cacheResponse(cacheKey, result);
return result;
}
Schema caching is useful when your schema construction is expensive, for example, when you are dynamically generating types, you are stitching multiple schemas, or fetching remote GraphQL services. This is especially important in serverless environments, where cold starts can significantly impact performance.
The following example shows how to cache a schema in memory after the first build:
import { buildSchema } from 'graphql';
let cachedSchema;
export function getSchema() {
if (!cachedSchema) {
cachedSchema = buildSchema(schemaSDLString); // or makeExecutableSchema()
}
return cachedSchema;
}
No caching strategy is complete without an invalidation plan. Cached data can become stale or incorrect, and serving outdated information can lead to bugs or a degraded user experience.
The following are common invalidation techniques:
Design your invalidation strategy based on your data’s volatility and your clients’ tolerance for staleness.
While GraphQL.js does not include built-in support for third-party or edge caching, it integrates well with external tools and middleware that handle full response caching or caching by query signature.
The following tools and layers are commonly used:
GraphQL clients include sophisticated client-side caches that store normalized query results and reuse them across views or components. While this is out of scope for GraphQL.js itself, server-side caching should be designed with client behavior in mind.
Server-side and client-side caches should align on freshness guarantees and invalidation behavior. If the client doesn't re-fetch automatically, server-side staleness may be invisible but impactful.