|
| 1 | +--- |
| 2 | +title: Caching Strategies |
| 3 | +--- |
| 4 | + |
| 5 | +# Caching Strategies |
| 6 | + |
| 7 | +Caching is a core strategy for improving the performance and scalability of GraphQL |
| 8 | +servers. Because GraphQL allows clients to specify exactly what they need, the server often |
| 9 | +does more work per request (but fewer requests) compared to many other APIs. |
| 10 | + |
| 11 | +This guide explores different levels of caching in a GraphQL.js so you can apply the right |
| 12 | +strategy for your application. |
| 13 | + |
| 14 | +## Why caching matters |
| 15 | + |
| 16 | +GraphQL servers commonly face performance bottlenecks due to repeated fetching of the |
| 17 | +same data, costly resolver logic, or expensive database queries. Since GraphQL shifts |
| 18 | +much of the composition responsibility to the server, caching becomes essential for maintaining |
| 19 | +fast response times and managing backend load. |
| 20 | + |
| 21 | +## Caching levels |
| 22 | + |
| 23 | +There are several opportunities to apply caching within a GraphQL server: |
| 24 | + |
| 25 | +- **Resolver-level caching**: Cache the result of specific fields. |
| 26 | +- **Request-level caching**: Batch and cache repeated access to backend resources |
| 27 | +within a single operation. |
| 28 | +- **Operation result caching**: Reuse the entire response for repeated identical queries. |
| 29 | +- **Schema caching**: Cache the compiled schema when startup cost is high. |
| 30 | +- **Transport/middleware caching**: Leverage caching behavior in HTTP servers or proxies. |
| 31 | + |
| 32 | +Understanding where caching fits in your application flow helps you apply it strategically |
| 33 | +without overcomplicating your system. |
| 34 | + |
| 35 | +## Resolver-level caching |
| 36 | + |
| 37 | +Resolver-level caching is useful when a specific field’s value is expensive to compute and |
| 38 | +commonly requested with the same arguments. Instead of recomputing or refetching the data on |
| 39 | +every request, you can store the result temporarily in memory and return it directly when the |
| 40 | +same input appears again. |
| 41 | + |
| 42 | +### Use cases |
| 43 | + |
| 44 | +- Fields backed by slow or rate-limited APIs |
| 45 | +- Fields that require complex computation |
| 46 | +- Data that doesn't change frequently |
| 47 | + |
| 48 | +For example, consider a field that returns information about a product: |
| 49 | + |
| 50 | +```js |
| 51 | +// utils/cache.js |
| 52 | +import LRU from 'lru-cache'; |
| 53 | + |
| 54 | +export const productCache = new LRU({ max: 1000, ttl: 1000 * 60 }); // 1 min TTL |
| 55 | +``` |
| 56 | + |
| 57 | +The next example shows how to use that cache inside a resolver to avoid repeated database |
| 58 | +lookups: |
| 59 | + |
| 60 | +```js |
| 61 | +// resolvers/product.js |
| 62 | +import { productCache } from '../utils/cache.js'; |
| 63 | + |
| 64 | +export const resolvers = { |
| 65 | + Query: { |
| 66 | + product(_, { id }, context) { |
| 67 | + const cached = productCache.get(id); |
| 68 | + if (cached) return cached; |
| 69 | + |
| 70 | + const productPromise = context.db.products.findById(id); |
| 71 | + productCache.set(id, productPromise); |
| 72 | + return productPromise; |
| 73 | + }, |
| 74 | + }, |
| 75 | +}; |
| 76 | +``` |
| 77 | + |
| 78 | +This example uses [`lru-cache`](https://www.npmjs.com/package/lru-cache), which limits the |
| 79 | +number of stored items and support TTL-based expiration. You can replace it with Redis or |
| 80 | +another cache if you need cross-process consistency. |
| 81 | + |
| 82 | +### Guidelines |
| 83 | + |
| 84 | +- Resolver-level caches are global. Be careful with authorization-sensitive data. |
| 85 | +- This technique works best for data that doesn't change often or can tolerate short-lived |
| 86 | +staleness. |
| 87 | +- TTL should match how often the underlying data is expected to change. |
| 88 | + |
| 89 | +## Request-level caching with DataLoader |
| 90 | + |
| 91 | +[DataLoader](https://github.com/graphql/dataloader) is a utility for batching and caching |
| 92 | +backend access during a single GraphQL operation. It's designed to solve the N+1 problem, |
| 93 | +where the same resource is fetched repeatedly in a single query across multiple fields. |
| 94 | + |
| 95 | +### Use cases |
| 96 | + |
| 97 | +- Resolving nested relationships |
| 98 | +- Avoiding duplicate database or API calls |
| 99 | +- Scoping caching to a single request, without persisting globally |
| 100 | + |
| 101 | +The following example defines a DataLoader instance that batches user lookups by ID: |
| 102 | + |
| 103 | +```js |
| 104 | +// loaders/userLoader.js |
| 105 | +import DataLoader from 'dataloader'; |
| 106 | +import { batchGetUsers } from '../services/users.js'; |
| 107 | + |
| 108 | +export const createUserLoader = () => new DataLoader(ids => batchGetUsers(ids)); |
| 109 | +``` |
| 110 | + |
| 111 | +You can then include the loader in the per-request context to isolate it from other |
| 112 | +operations: |
| 113 | + |
| 114 | +```js |
| 115 | +// context.js |
| 116 | +import { createUserLoader } from './loaders/userLoader.js'; |
| 117 | + |
| 118 | +export function createContext() { |
| 119 | + return { |
| 120 | + userLoader: createUserLoader(), |
| 121 | + }; |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +Finally, use the loader in your resolvers to batch-fetch users efficiently: |
| 126 | + |
| 127 | +```js |
| 128 | +// resolvers/user.js |
| 129 | +export const resolvers = { |
| 130 | + Query: { |
| 131 | + async users(_, __, context) { |
| 132 | + return context.userLoader.loadMany([1, 2, 3]); |
| 133 | + }, |
| 134 | + }, |
| 135 | +}; |
| 136 | +``` |
| 137 | + |
| 138 | +### Guidelines |
| 139 | + |
| 140 | +- The cache is scoped to the request. Each request gets a fresh loader instance. |
| 141 | +- This strategy works best for resolving repeated references to the same resource type. |
| 142 | +- This isn't a long-lived cache. Combine it with other layers for broader coverage. |
| 143 | + |
| 144 | +To read more about DataLoader and the N+1 problem, |
| 145 | +see [Solving the N+1 Problem with DataLoader](https://github.com/graphql/dataloader). |
| 146 | + |
| 147 | +## Operation result caching |
| 148 | + |
| 149 | +Operation result caching stores the complete response of a query, keyed by the query string, variables, and potentially |
| 150 | +HTTP headers. It can dramatically improve performance when the same query is sent frequently, particularly for read-heavy |
| 151 | +applications. |
| 152 | + |
| 153 | +### Use cases |
| 154 | + |
| 155 | +- Public data or anonymous content |
| 156 | +- Expensive queries that return stable results |
| 157 | +- Scenarios where the same query is sent frequently |
| 158 | + |
| 159 | +The following example defines two functions to interact with a Redis cache, |
| 160 | +storing and retrieving cached results: |
| 161 | + |
| 162 | +```js |
| 163 | +// cache/queryCache.js |
| 164 | +import Redis from 'ioredis'; |
| 165 | +const redis = new Redis(); |
| 166 | + |
| 167 | +export async function getCachedResponse(cacheKey) { |
| 168 | + const cached = await redis.get(cacheKey); |
| 169 | + return cached ? JSON.parse(cached) : null; |
| 170 | +} |
| 171 | + |
| 172 | +export async function cacheResponse(cacheKey, result, ttl = 60) { |
| 173 | + await redis.set(cacheKey, JSON.stringify(result), 'EX', ttl); |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +The next example shows how to wrap your execution logic to check the cache first and store results |
| 178 | +afterward: |
| 179 | + |
| 180 | +```js |
| 181 | +// graphql/executeWithCache.js |
| 182 | +import { getCachedResponse, cacheResponse } from '../cache/queryCache.js'; |
| 183 | + |
| 184 | +/** |
| 185 | + * Stores in-flight requests to executeWithCache such that concurrent |
| 186 | + * requests with the same cacheKey will only result in one call to |
| 187 | + * `getCachedResponse` / `cacheResponse`. Once a request completes |
| 188 | + * (with or without error) it is removed from the map. |
| 189 | + */ |
| 190 | +const inflight = new Map(); |
| 191 | + |
| 192 | +export function executeWithCache({ cacheKey, executeFn }) { |
| 193 | + const existing = inflight.get(cacheKey); |
| 194 | + if (existing) return existing; |
| 195 | + |
| 196 | + const promise = _executeWithCacheUnbatched({ cacheKey, executeFn }); |
| 197 | + inflight.set(cacheKey, promise); |
| 198 | + return promise.finally(() => inflight.delete(cacheKey)); |
| 199 | +} |
| 200 | + |
| 201 | +async function _executeWithCacheUnbatched({ cacheKey, executeFn }) { |
| 202 | + const cached = await getCachedResponse(cacheKey); |
| 203 | + if (cached) return cached; |
| 204 | + |
| 205 | + const result = await executeFn(); |
| 206 | + await cacheResponse(cacheKey, result); |
| 207 | + return result; |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +### Guidelines |
| 212 | + |
| 213 | +- Don't cache personalized or auth-sensitive data unless you scope the cache per user or token. |
| 214 | +- Invalidation is nontrivial. Consider TTLs, cache versioning, or event-driven purging. |
| 215 | + |
| 216 | +## Schema caching |
| 217 | + |
| 218 | +Schema caching is useful when your schema construction is expensive, for example, when |
| 219 | +you are dynamically generating types, you are stitching multiple schemas, or fetching |
| 220 | +remote GraphQL services. This is especially important in serverless environments, |
| 221 | +where cold starts can significantly impact performance. |
| 222 | + |
| 223 | +### Use cases |
| 224 | + |
| 225 | +- Serverless functions that rebuild the schema on each invocation |
| 226 | +- Applications that use schema stitching or remote schema delegation |
| 227 | +- Environments where schema generation takes noticeable time on startup |
| 228 | + |
| 229 | +The following example shows how to cache a schema in memory after the first build: |
| 230 | + |
| 231 | +```js |
| 232 | +import { buildSchema } from 'graphql'; |
| 233 | + |
| 234 | +let cachedSchema; |
| 235 | + |
| 236 | +export function getSchema() { |
| 237 | + if (!cachedSchema) { |
| 238 | + cachedSchema = buildSchema(schemaSDLString); // or makeExecutableSchema() |
| 239 | + } |
| 240 | + return cachedSchema; |
| 241 | +} |
| 242 | +``` |
| 243 | + |
| 244 | +## Cache invalidation |
| 245 | + |
| 246 | +No caching strategy is complete without an invalidation plan. Cached data can |
| 247 | +become stale or incorrect, and serving outdated information can lead to bugs or a |
| 248 | +degraded user experience. |
| 249 | + |
| 250 | +The following are common invalidation techniques: |
| 251 | + |
| 252 | +- **TTL (time-to-live)**: Automatically expire cached items after a time window |
| 253 | +- **Manual purging**: Remove or refresh cache entries when related data is updated |
| 254 | +- **Key versioning**: Encode version or timestamp metadata into cache keys |
| 255 | +- **Stale-while-revalidate**: Serve stale data while refreshing it in the background |
| 256 | + |
| 257 | +Design your invalidation strategy based on your data’s volatility and your clients’ |
| 258 | +tolerance for staleness. |
| 259 | + |
| 260 | +## Third-party and edge caching |
| 261 | + |
| 262 | +While GraphQL.js does not include built-in support for third-party or edge caching, it integrates |
| 263 | +well with external tools and middleware that handle full response caching or caching by query |
| 264 | +signature. |
| 265 | + |
| 266 | +### Use cases |
| 267 | + |
| 268 | +- Serving public, cacheable content to unauthenticated users |
| 269 | +- Deploying behind a CDN or reverse proxy |
| 270 | +- Using a gateway service that supports persistent response caching |
| 271 | + |
| 272 | +The following tools and layers are commonly used: |
| 273 | + |
| 274 | +- Redis or Memcached for in-memory and cross-process caching |
| 275 | +- CDN-level caching for static, cache-friendly GraphQL queries |
| 276 | +- API gateways |
| 277 | + |
| 278 | +### Guidelines |
| 279 | + |
| 280 | +- Partition cache entries for personalized data using auth tokens or headers |
| 281 | +- Monitor cache hit/miss ratios to identify tuning opportunities |
| 282 | +- Consider varying cache strategy per query or operation type |
| 283 | + |
| 284 | +## Client-side caching |
| 285 | + |
| 286 | +GraphQL clients include sophisticated client-side caches that store |
| 287 | +normalized query results and reuse them across views or components. While this is out of scope for GraphQL.js |
| 288 | +itself, server-side caching should be designed with client behavior in mind. |
| 289 | + |
| 290 | +### When to consider it in server design |
| 291 | + |
| 292 | +- You want to avoid redundant server work for cold-started clients |
| 293 | +- You need consistency between server and client freshness guarantees |
| 294 | +- You're coordinating with clients that rely on local cache behavior |
| 295 | + |
| 296 | +Server-side and client-side caches should align on freshness guarantees and invalidation |
| 297 | +behavior. If the client doesn't re-fetch automatically, server-side staleness |
| 298 | +may be invisible but impactful. |
0 commit comments