Caching Entity Lookups with Redis in Subgraphs

A per-request loader removes duplicate work inside one operation and nothing across operations. When the same entities are resolved on every request — reference data, hot products, a popular author — a cross-request cache in front of the datasource is the next step, and it is the first caching layer that introduces genuine correctness questions. This page covers where the cache sits, how to key it, and how to invalidate it without inventing a distributed-systems problem. It sits under Optimizing Reference Resolvers for Performance.

When to use this pattern

  • Use it when a small set of entities is resolved on most requests and the datasource cost is meaningful — the classic hot-key distribution.
  • Use it when the underlying data changes far less often than it is read, which is the condition that makes any cache worth its invalidation cost.
  • Skip it until batching is in place and the remaining calls are still expensive. A cache in front of an N+1 pattern hides the problem and multiplies the invalidation surface.

Prerequisites

Three layers, three lifetimes

It helps to place this cache precisely, because three layers can all hold the same entity and they are not interchangeable.

The per-request loader lives and dies with one operation. It removes duplicate lookups within a request and has no staleness risk whatsoever, because nothing outlives the request.

The subgraph’s Redis cache — the subject of this page — spans requests and replicas. It is the first layer where a value can be wrong, and therefore the first that needs a TTL and an invalidation story.

The router’s entity cache sits above the subgraph entirely and caches the resolved entity as the router sees it. It saves the network hop as well as the datasource call, which the subgraph cache cannot.

They compose rather than compete, and the ordering matters: a request checks the loader, then Redis, then the datasource. A router-level cache hit means none of the three run at all.

Three layers, and where staleness starts The per-request loader cannot be stale. The subgraph Redis cache spans requests and is the first layer that can hold a wrong value. The router entity cache sits above the subgraph and saves the network hop too. Where the same entity can be held router entity cache — above the subgraph saves the network hop and everything below it. Needs cache hints to work at all. subgraph Redis cache — this page saves the datasource call, shared across replicas. First layer that can be stale. per-request loader — inside one operation removes duplicates within a request. Cannot be stale, and needs no invalidation. Add them bottom-up: the layer with no correctness cost first, the one above it only if needed.

Implementation walkthrough

The cache belongs inside the loader’s batch function, not around the resolver. That placement matters: it lets one batch resolve some keys from Redis and the rest from the datasource, which is the common case for a hot-key distribution.

import DataLoader from "dataloader";
import type Redis from "ioredis";

const TTL_SECONDS = 300;
// Version prefix: bumping it invalidates every entry at once, which is the
// cheapest possible response to a serialisation format change.
const key = (id: string) => `v1:product:${id}`;

async function batchGetProducts(redis: Redis, ids: readonly string[]) {
  // One round trip for the whole batch, not one per id.
  const cached = await redis.mget(...ids.map(key));
  const misses: string[] = [];
  const byId = new Map<string, Product>();

  ids.forEach((id, i) => {
    const raw = cached[i];
    if (raw) byId.set(id, JSON.parse(raw));
    else misses.push(id);
  });

  if (misses.length) {
    const rows = await db.findProductsByIds(misses);
    const pipe = redis.pipeline();
    for (const row of rows) {
      byId.set(row.id, row);
      // Jittered TTL: without it, everything written in one burst expires in
      // one burst, and the next request stampedes the database.
      pipe.set(key(row.id), JSON.stringify(row), "EX", TTL_SECONDS + Math.floor(Math.random() * 60));
    }
    await pipe.exec();
  }

  // Preserve key order and length — DataLoader matches by index.
  return ids.map((id) => byId.get(id) ?? null);
}

export const makeLoaders = (redis: Redis) => ({
  product: new DataLoader<string, Product | null>((ids) => batchGetProducts(redis, ids)),
});

Invalidation is the other half, and it must cover every writer rather than only the GraphQL mutation:

// Called from the mutation, from background jobs, and from admin tooling —
// anywhere a product changes. A single exported function is the only way this
// stays true as the service grows.
export async function invalidateProduct(redis: Redis, id: string) {
  await redis.del(key(id));
}

Two details in that code are load-bearing. The TTL jitter prevents a synchronised expiry stampede, which is the most common way a cache makes a system less stable than no cache at all. And the version prefix gives you a one-line escape hatch: change v1 to v2 and every entry is logically gone, without a scan or a flush.

Verification steps

Confirm the hit path, the miss path and the mixed batch, because the third is the one that breaks.

// A batch where some keys are cached and some are not must return every
// entity in key order. This is where a naive implementation drops misses.
await redis.set(key("P-1"), JSON.stringify(productOne));
const [a, b] = await loaders.product.loadMany(["P-1", "P-2"]);
expect(a).toEqual(productOne);   // from cache
expect(b).toEqual(productTwo);   // from the datasource

Then confirm invalidation actually reaches every writer. The reliable way is a test per write path rather than a single test of the invalidation function:

# After each kind of write, the cache entry must be gone.
redis-cli exists v1:product:P-1     # expect 0 after a mutation
                                     # expect 0 after the nightly import job
                                     # expect 0 after an admin tool edit

Finally, measure the hit rate and the datasource call rate together. A high hit rate with an unchanged datasource rate means the cache is being consulted and missed — usually a key mismatch, and one that no test catches because both sides are individually correct.

Four ways a subgraph cache goes wrong Synchronised expiry stampedes the datasource, a missed write path serves stale data indefinitely, a format change breaks deserialisation, and a per-viewer value cached globally leaks data. The four hazards, and the one line that fixes each hazard symptom mitigation synchronised expiry periodic database spikes jitter the TTL a write path with no invalidation stale until the TTL expires one shared invalidate function serialisation format changed deserialisation errors on deploy a version prefix in the key per-viewer value cached globally one user sees another's data never cache viewer-dependent data

Before adding any of this, it is worth being honest about whether the cache is the right tool at all, because a cross-request cache is the first optimisation on this subject that can make you wrong rather than merely slow. The three cheaper options all preserve correctness completely.

Removing the fetch with @provides is free at runtime and introduces no staleness. Batching with a per-request loader is the largest single win available and equally free of correctness questions. Narrowing what the datasource returns shrinks payloads without changing when anything is read. Only after all three are in place, and the remaining datasource calls are still the bottleneck, does a Redis layer earn its invalidation burden.

The tell that you have skipped a step is a cache with a modest hit rate and a busy datasource. That combination almost always means the same entity is being fetched repeatedly within a request, which a loader would have collapsed for nothing, and the cache is now paying a round trip to partially undo a problem that should not exist.

Whether the cache is worth keeping is a question with numbers behind it, and four of them tell the whole story. Reviewing them quarterly is enough to notice when an entity has stopped being hot.

Four numbers, reviewed quarterly Hit rate, datasource call rate, miss latency and stale reports together decide whether the cache is worth its invalidation burden. Four numbers that justify the cache metric healthy what a bad value means hit rate above 70% the entity is not actually hot datasource call rate fell with the cache a key mismatch — hits are missing p99 on a miss one round trip above baseline Redis is a bottleneck too stale-value reports zero an invalidation path is missing A high hit rate with an unchanged datasource rate means the cache is consulted and never satisfied. Delete the cache if the first row stays low — it is adding a round trip to most requests for nothing.

Common mistakes & gotchas

Caching a viewer-dependent value. The bottom row above is the only hazard here that is a security incident rather than a performance one. Cache the entity as the datasource sees it, never as a particular viewer sees it, and resolve per-viewer fields outside the cache.

Invalidating only from the GraphQL mutation. Background jobs, imports and admin tools all write, and each one that skips invalidation produces staleness that lasts a full TTL. Centralise invalidation in a function every writer calls.

Caching around the resolver instead of inside the batch function. It defeats batching, because a partial hit forces either a whole-batch miss or a per-key round trip.

Frequently Asked Questions

Should this cache or the router’s entity cache come first?

The router’s, if the data is genuinely shareable, because it saves the network hop as well as the datasource call and needs no code in your subgraph — only cache hints. Reach for a subgraph-level cache when the value is expensive to compute but not appropriate to cache at the router, or when you need finer control over invalidation than hints can express.

What TTL is right?

Whatever staleness you could defend with no invalidation at all, because invalidation will occasionally fail to run. Choosing the TTL that way makes the worst case something you already accepted rather than something you discover. Then add invalidation to make the common case fresher.

Is a cache miss slower than no cache?

Marginally, by one Redis round trip, and that cost is the reason a low hit rate is worse than nothing. If your hit rate is below roughly half, the cache is adding latency to most requests to save it on a minority — measure before assuming the entity is hot.

How should a Redis outage behave?

As a miss, always. Wrap the cache read in a short timeout and fall through to the datasource on any error, so an unavailable cache degrades performance rather than availability. A subgraph that fails when Redis is down has turned an optimisation into a dependency.