Entity Caching with the Apollo Router Response Cache

The Apollo Router’s Redis-backed response cache stores the result of each subgraph _entities fetch independently, letting a federated graph serve the bulk of its entity reads without touching subgraphs — provided you configure cache keys, TTLs, scopes, and invalidation correctly. This guide is the configuration deep-dive behind the layered overview in Caching Strategies for Federated GraphQL.

When to Use This Pattern

  • You have read-heavy entities whose datasource fetches dominate p50/p99 latency, and a 5-second-to-1-hour staleness window is acceptable.
  • You run multiple router replicas and want a shared cache so a warm entity benefits every replica, not just the one that fetched it.
  • You need per-field freshness control — caching a slow catalog subgraph aggressively while leaving a real-time inventory subgraph nearly uncached.

Prerequisites

How the Entity Cache Keys Entries

The router caches at the granularity of a subgraph fetch, not a whole response. When the query plan dispatches an _entities call to a subgraph, the router derives a cache key from four components: the subgraph name, the entity __typename, the entity’s @key field values, and a hash of the requested field set (so a query asking for name and one asking for name, price do not collide). For PRIVATE entries, a session identifier is mixed in as well. On a hit, the router splices the cached entity into the assembled response and never calls the subgraph; on a miss, it calls the subgraph, stores the result under that key with the subgraph’s TTL, and continues.

Because the key includes the field set, widening a client’s selection set produces a cold key the first time — this is expected. It also means the cache is naturally correct across schema evolution: adding a field changes the field-set hash, so old narrower entries remain valid for old narrower selections.

Annotated router.yaml

# router.yaml — Redis-backed entity cache configuration
preview_entity_cache:
  enabled: true

  redis:
    urls:
      - "redis://entity-cache-1.internal:6379"
      - "redis://entity-cache-2.internal:6379"   # cluster members for HA
    timeout: 5ms            # fail fast — a slow cache must fall through to the subgraph
    ttl: 60s                # global default TTL when a subgraph sets none
    namespace: "router:v1"  # key prefix; bump to invalidate the whole cache on deploy

  # Per-subgraph overrides — different freshness per service
  subgraphs:
    products:
      enabled: true
      ttl: 3600s            # catalog data changes slowly; cache for an hour
    inventory:
      enabled: true
      ttl: 5s               # near-real-time stock; a tiny TTL still cuts datasource load
    users:
      enabled: true
      ttl: 300s
      private_id: "sub"     # partition PRIVATE entries by the JWT 'sub' claim

# The cache honours hints emitted by subgraphs; this enables header-based hints
include_subgraph_errors:
  all: true                 # surface subgraph errors so cache-related failures are visible

Two keys do the heavy lifting. timeout keeps a degraded Redis from slowing requests — if the lookup does not return within the budget the router treats it as a miss and calls the subgraph. namespace is your blunt-instrument global invalidation: bumping the prefix on deploy orphans every existing entry, which is the simplest way to flush after a backfill or a schema change that affects cached shapes.

Annotated Subgraph @cacheControl SDL

The router only caches what subgraphs declare cacheable. Each subgraph annotates its types and fields, importing the cache-control directive via @link:

# products subgraph — cache hints drive router entity-cache TTL and scope
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])
  @link(url: "https://specs.apollo.dev/cache-control/v0.1", import: ["@cacheControl"])

# Type-level default: every Product field inherits maxAge 3600, PUBLIC
type Product @key(fields: "id") @cacheControl(maxAge: 3600, scope: PUBLIC) {
  id: ID!
  name: String!
  description: String!
  # Field override: price is fresher than the rest of the catalog
  price: Money! @cacheControl(maxAge: 60)
  # PRIVATE: a personalised flag must never land in a shared cache entry
  isInWishlist: Boolean! @cacheControl(maxAge: 30, scope: PRIVATE)
}

type Query {
  product(id: ID!): Product
}

When the router fetches a Product for the entity cache, it records the minimum maxAge and strictest scope across the selected fields. A selection of name and price is cached PUBLIC for 60 seconds; a selection that includes isInWishlist becomes PRIVATE for 30 seconds and is partitioned by private_id.

For TTLs that depend on the data itself, emit the hint dynamically from a resolver instead:

const resolvers = {
  Product: {
    // Reference resolver routed through DataLoader so misses still batch
    __resolveReference: async (ref: { id: string }, ctx: Context, info) => {
      const product = await ctx.loaders.product.load(ref.id);
      // Discontinued products are immutable — cache them far longer
      if (product?.discontinued) {
        info.cacheControl.setCacheHint({ maxAge: 86400, scope: 'PUBLIC' });
      }
      return product;
    },
  },
};

PRIVATE vs PUBLIC Scopes

Scope decides which cache partition an entry lands in. PUBLIC entries are shared across all callers — ideal for catalog, content, and reference data identical for every user. PRIVATE entries are partitioned by the private_id claim so that user A’s cached entity is never served to user B. The rule of thumb: if the value can differ depending on who is asking, it must be PRIVATE. Getting this wrong is the one cache bug that becomes a security incident rather than a stale read, so default unannotated fields to uncacheable and require an explicit PUBLIC hint to share anything.

PRIVATE caching multiplies key cardinality by the number of distinct sessions, so reserve it for entities a single user re-reads within the TTL window. For private-and-rarely-reread data, skip the shared cache and let request-scoped DataLoader handle deduplication.

Invalidation and TTLs

The default invalidation mechanism is time: an entry simply expires after its TTL. This is the simplest model and correct whenever a bounded staleness window is acceptable. Pick the TTL from the data’s real change rate — hours for a catalog, seconds for inventory — not from a desire for freshness you will not actually use.

When a write must be visible immediately, expire the entry explicitly. The router exposes cache invalidation; the operational pattern is to delete the affected entity key (or, more bluntly, bump the namespace) on mutation. A subgraph can also shorten its own TTL for write-heavy entities so the worst-case staleness stays small without per-write invalidation wiring.

# Blunt global flush after a bulk backfill: rotate the namespace in router.yaml,
# then trigger a rolling restart so every replica adopts the new prefix.
# Old entries under "router:v1" are orphaned and evicted by Redis maxmemory policy.

Verification Steps

Confirm the cache is actually serving entities rather than silently missing:

# 1. Send the same query twice; the second should be markedly faster.
rover dev # or hit the router directly
# First call: cold — subgraph is invoked
# Second call: warm — served from Redis, subgraph untouched

# 2. Inspect the response Cache-Control header to verify hint aggregation
curl -sD - -o /dev/null \
  -H 'content-type: application/json' \
  -d '{"query":"{ product(id:\"1\"){ name price } }"}' \
  https://router.internal/graphql | grep -i cache-control
# Expect: cache-control: max-age=60, public   (min maxAge across name=3600, price=60)
# 3. Confirm keys are landing in Redis with the expected TTL
redis-cli --scan --pattern 'router:v1:*' | head
redis-cli ttl 'router:v1:<one-key-from-above>'   # should be <= the subgraph TTL

A warm read should show the subgraph receiving zero _entities traffic in its logs or traces. If the subgraph is still called on the second request, the entry was PRIVATE without a matching private_id, the field set differed, or the TTL was too short.

Common Mistakes and Gotchas

  • Defaulting fields to PUBLIC. An unannotated field served from a shared cache leaks per-user data. Leave the default uncacheable and require explicit scope: PUBLIC.
  • Generous Redis timeout. A multi-hundred-millisecond timeout makes cached requests slower than uncached ones when Redis is degraded. Keep it in single-digit milliseconds so the router fails fast to the subgraph.
  • Wide TTL with no invalidation. A 1-hour TTL on data that gets written makes the write invisible for up to an hour. Either shorten the TTL or delete the key on mutation.
  • Forgetting the @cacheControl @link import. Using the directive without importing it via @link fails composition; rover supergraph compose reports the unknown directive.

Frequently Asked Questions

What exactly does the router cache as a key?

The subgraph name, the entity __typename, the @key field values, a hash of the requested field set, and — for PRIVATE entries — the configured session identifier. Because the field set is part of the key, two queries selecting different fields of the same entity get separate entries, which keeps cached shapes correct as schemas evolve.

How do I invalidate a single entity after an update?

Delete its cache key on mutation (the router supports targeted invalidation), or shorten that entity’s TTL so the staleness window is small enough to ignore. For coarse invalidation after a backfill, rotate the namespace prefix in router.yaml and roll the router so all replicas drop the old entries at once.

Why is my subgraph still being called on a warm cache?

The most common causes are a PRIVATE scope without a matching private_id (so every request lands in a different partition), a differing field set producing a cold key, a TTL shorter than the time between your two requests, or a Redis timeout firing and treating the lookup as a miss. Check the response Cache-Control header and the Redis keyspace to localise which.