Invalidation

Control cache freshness with passive TTL and active invalidation

Requires ≥ Router v2.8.0
Preview Feature

Cache invalidation ensures that cached data stays fresh. The router supports two approaches: passive TTL-based invalidation and active invalidation when you know data has changed.

Passive vs. active invalidation

  • Passive (TTL-based): Data automatically expires after its configured TTL. This works well for data that changes predictably or where slightly stale data is acceptable.

  • Active (invalidation): You explicitly tell the router to remove cached data when you know it has changed. This works well for data that changes unpredictably or where stale data is unacceptable.

Combine both approaches by setting reasonable TTLs as a safety net and actively invalidating when you know data has changed.

Time to live (TTL)

Time to live (TTL) determines how long the router keeps cached origin responses before fetching fresh data.

How TTLs work

The router caches origin query responses—specifically, root query fields as complete units and entity representations as independent reusable units. To determine how long to cache each response, the router reads the Cache-Control header that the origin returns. The router uses the max-age value from this header as the TTL for that cached data.

The Cache-Control header itself is derived from @cacheControl directives in your origin schema. When an origin response contains multiple entity representations, the origin generates a Cache-Control header with the minimum TTL value across all representations in that response. (You can use the cache debugger to inspect these headers.)

When responding to client queries, the router:

  1. Calculates the overall response TTL by taking the minimum TTL from all cached origin responses included in the client response

  2. Generates a Cache-Control header for the client response reflecting this minimum TTL

  3. Returns cached data as long as the TTL hasn't expired

This ensures that client responses never claim to be fresher than their least-fresh component.

Configure default TTL

Set a default TTL for all sources using response caching. Define this in per-subgraph configuration or inherit it from global configuration. The router uses this default TTL if the source returns a Cache-Control header without a max-age directive:

YAML
router.yaml
1preview_response_cache:
2  enabled: true
3  subgraph:
4    all:
5      enabled: true
6      ttl: 60s  # Default TTL for all subgraphs
7      redis:
8        urls: ["redis://localhost:6379"]

Control TTL with @cacheControl

For GraphQL origins that support the @cacheControl directive (such as Apollo Server), you can set field-level and type-level TTLs directly in your schema. The origin translates these directives into Cache-Control headers in its HTTP responses. The router reads those Cache-Control headers to determine TTLs—the directives themselves don't affect what the router caches, only the headers the source sends back.

Add the directive to your schema

First, add the @cacheControl directive definition to your subgraph schema:

GraphQL
1enum CacheControlScope {
2  PUBLIC
3  PRIVATE
4}
5
6directive @cacheControl(
7  maxAge: Int
8  scope: CacheControlScope
9  inheritMaxAge: Boolean
10) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
note
Apollo Server recognizes this directive automatically. For other GraphQL servers, consult your server's documentation to determine if it supports @cacheControl or an equivalent mechanism for setting Cache-Control headers.

Field-level TTLs

Apply @cacheControl to individual fields to set specific TTLs:

GraphQL
1type Post {
2  id: ID!
3  title: String
4  author: Author
5  votes: Int @cacheControl(maxAge: 30)
6  readByCurrentUser: Boolean! @cacheControl(maxAge: 10, scope: PRIVATE)
7}

These @cacheControl directives tell the GraphQL origin to include Cache-Control headers in its HTTP responses—that's all they do. The router reads those Cache-Control headers to determine TTLs.

When a query requests both the votes field (30-second TTL) and the readByCurrentUser field (10-second TTL), the origin returns a Cache-Control header with max-age=10 (the minimum). The router then caches the data from that response for 10 seconds.

Type-level TTLs

Apply @cacheControl to types to set a default TTL for all fields returning that type:

GraphQL
1type Post @cacheControl(maxAge: 240) {
2  id: Int!
3  title: String
4  author: Author
5  votes: Int
6}
7
8type Comment {
9  post: Post!  # Cached for up to 240 seconds
10  body: String!
11}

The @cacheControl directive on the Post type tells the GraphQL origin to include Cache-Control: max-age=240 in responses that return Post data. When the router queries for a Comment that includes a post field, the origin sends back that header, and the router caches the data for up to 240 seconds.

Field-level settings override type-level settings:

GraphQL
1type Comment {
2  post: Post! @cacheControl(maxAge: 120)  # Overrides the 240s type-level setting
3  body: String!
4}

Here, the origin sends Cache-Control: max-age=120 instead of 240 when this field is queried.

Scope: PUBLIC vs PRIVATE

The scope argument controls whether cached data can be shared across users:

  • PUBLIC (default): The data is identical for all users and can be shared in the cache

  • PRIVATE: The data is user-specific and requires a private_id configuration to cache per-user

GraphQL
1type User {
2  id: ID!
3  name: String @cacheControl(maxAge: 3600)  # Public: same for all users
4  cart: [Product!]! @cacheControl(maxAge: 60, scope: PRIVATE)  # Private: per-user
5}

See the Customization page for details on caching private data.

Learn more about TTL

For complete details on cache control configuration in Apollo Server, including:

  • Dynamic TTL setting in resolvers

  • Default maxAge behavior for root fields vs. other fields

  • Recommendations for TTL configuration

See the Apollo Server caching documentation.

Active invalidation

Active invalidation enables you to remove specific cached data before its TTL expires. This is useful when you know data has changed and want to ensure clients receive fresh data immediately.

Use active invalidation when:

  • Changes happen infrequently and unpredictably (product updates, price changes)

  • You need immediate cache updates when data changes

  • The cost of serving stale data is high

Configure invalidation

Configure response cache invalidation globally with preview_response_cache.invalidation. You can also override the global setting for a subgraph with preview_response_cache.subgraph.subgraphs.SUBGRAPH_NAME.invalidation.

note
Before sending invalidation requests, set the INVALIDATION_SHARED_KEY environment variable (or store it in your secrets management system). This shared key authenticates invalidation requests and prevents unauthorized cache clearing.

The following example shows both global and per-subgraph invalidation configuration:

YAML
router.yaml
1preview_response_cache:
2  enabled: true
3
4  # global invalidation configuration
5  invalidation:
6    # address of the invalidation endpoint
7    # this should only be exposed to internal networks
8    listen: "127.0.0.1:3000"
9    path: "/invalidation"
10
11  subgraph:
12    all:
13      enabled: true
14      redis:
15        urls: ["redis://..."]
16      invalidation:
17        enabled: true
18        # base64 string that will be provided in the `Authorization` header value
19        shared_key: ${env.INVALIDATION_SHARED_KEY}
20    subgraphs:
21      products:
22        # per subgraph invalidation configuration overrides global configuration
23        invalidation:
24          # whether invalidation is enabled for this subgraph
25          enabled: true
26          # override the shared key for this particular subgraph. If another key is provided, the invalidation requests for this subgraph's entities will not be executed
27          shared_key: ${env.INVALIDATION_SHARED_KEY_PRODUCTS}

Configuration options

listen

The address and port to listen on for invalidation requests. Only expose this endpoint to internal networks.

path

The path to listen on for invalidation requests.

shared_key

A string that is used to authenticate invalidation requests. Store this securely and provide it in the Authorization header when making invalidation requests.

Send invalidation requests

The invalidation endpoint accepts HTTP POST requests containing a JSON payload with an array of invalidation operations. For example, if price data changes before a price entity's TTL expires, you can send an invalidation request:

One invalidation request can invalidate multiple cached entries at the same time. It can invalidate:

  • All cached entries for a specific subgraph

  • All cached entries for a specific type in a specific subgraph

  • All cached entries marked with a cache tag in specific subgraphs

Invalidation methods

Consider the following subgraph schema, which is part of a federated schema:

GraphQL
accounts.graphql
1extend schema
2  @link(
3    url: "https://specs.apollo.dev/federation/v2.12"
4    import: ["@key", "@requires", "@external", "@cacheTag"]
5  )
6
7type Query {
8  user(id: ID!): User @cacheTag(format: "profile")
9  users: [User!]! @cacheTag(format: "profile") @cacheTag(format: "users-list")
10  postsByUser(userId: ID!): [Post!]! @cacheTag(format: "posts-user-{$args.userId}")
11}
12
13type User @key(fields: "id") @cacheTag(format: "user-{$key.id}") {
14  id: ID!
15  name: String!
16  email: String!
17  posts: [Post!]! @external
18}
19
20type Post @key(fields: "id") {
21  id: ID!
22  content: String! @external
23}
24

By subgraph

To invalidate all cached data from the accounts subgraph, send a request with the following format:

JSON
1[{
2  "kind": "subgraph",
3  "subgraph": "accounts"
4}]

By entity type

To invalidate all cached data for entity type User in the accounts subgraph, send a JSON payload in this format:

JSON
1[{
2  "kind": "type",
3  "subgraph": "accounts",
4  "type": "User"
5}]

By cache tag

To invalidate all cached data with a specific cache tag profile in the accounts subgraph, use cache tags. For example, if you have a profile page that executes multiple queries, tag them with the profile cache tag. You can then invalidate all data fetched for the profile page with a single request.

Send a JSON payload in this format:

JSON
1[{
2  "kind": "cache_tag",
3  "subgraphs": ["accounts"],
4  "cache_tag": "profile"
5}]

You can also add a dynamic cache tag containing the entity key to the User entity type, specified like this: @cacheTag(format: "user-{$key.id}"). This enables you to invalidate cached data for a specific User, such as one with an ID of 42:

JSON
1[{
2  "kind": "cache_tag",
3  "subgraphs": ["accounts"],
4  "cache_tag": "user-42"
5}]

Invalidate root fields with parameters using dynamic cache tags. Set a cache tag on root fields with parameters by interpolating parameters in the cache tag format using $args. For example, on the postsByUser root field, set @cacheTag(format: "posts-user-{$args.userId}"), which becomes posts-user-42 when you pass 42 as the userId parameter:

JSON
1[{
2  "kind": "cache_tag",
3  "subgraphs": ["accounts"],
4  "cache_tag": "posts-user-42"
5}]
note
The @cacheTag directive has the following constraints regarding its application:
  • Only applies to root query fields or resolvable entities (types marked with @key directive where resolvable is unset or true)
  • When set on a root field, the format string allows {$args.XXX} interpolation, where args is a map of all arguments for that root field
  • When set on an entity, the format is {$key.XXX}, where key is a map of all entity keys for that type
    • If you have multiple @key directives (e.g., @key(fields: "id") and @key(fields: "id name")), you can only access fields present in every @key directive (in this case, only {$key.id})
  • The format must always generate a valid string (not an object)
❌ Invalid example (field not in all keys):
GraphQL
1type Product
2  @key(fields: "upc") @key(fields: "name")
3  @cacheTag(format: "product-{$key.name}") {
4# Error at composition: name isn't in all @key directives
5  upc: String!
6  name: String!
7  price: Int
8}
✅ Valid example (field in all keys):
GraphQL
1type Product
2  @key(fields: "upc") @key(fields: "upc isbn")
3  @cacheTag(format: "product-{$key.upc}") {
4  upc: String!
5  isbn: String!
6  name: String!
7  price: Int
8}
❌ Invalid example (format generates an object):
GraphQL
1type Product
2  @key(fields: "upc country { name }")
3  @cacheTag(format: "product-upc-{$key.upc}-country-{$key.country}") { # Error: country is an object
4  upc: String!
5  name: String!
6  price: Int
7  country: Country!
8}
✅ Valid example (format generates a string):
GraphQL
1type Product
2  @key(fields: "upc country { name }")
3  @cacheTag(format: "product-upc-{$key.upc}-country-{$key.country.name}") {
4  upc: String!
5  name: String!
6  price: Int
7  country: Country!
8}
9
10type Country {
11  name: String!
12}

If you need to set cache tags programmatically (for example, if the tag depends on neither root field arguments nor entity keys), create the cache tags in your subgraph and set them in the response extensions.

For cache tags on entities, set apolloEntityCacheTags in extensions. The following example shows a response payload that sets cache tags for entities returned by a subgraph:

JSON
1{
2   "data": {"_entities": [
3       {"__typename": "User", "id": 42, ...},
4       {"__typename": "User", "id": 1023, ...},
5       {"__typename": "User", "id": 7, ...},
6   ]},
7   "extensions": {"apolloEntityCacheTags": [
8       ["products", "product-42"],
9       ["products", "product-1023"],
10       ["products", "product-7"]
11   ]}
12}

For cache tags on root fields, set apolloCacheTags in extensions. The following example shows a response payload that sets cache tags for root fields returned by a subgraph:

JSON
1{
2   "data": {
3       "someField": {...}
4   },
5   "extensions": {"apolloCacheTags": ["homepage", "user-9001-homepage"]}
6}

Invalidation HTTP endpoint

The invalidation endpoint exposed by the router expects to receive an array of invalidation requests and processes them in sequence. For authorization, you must provide a shared key in the request header. For example, with the previous configuration, send the following request:

Text
1curl --request POST \
2	--header 'authorization: ${INVALIDATION_SHARED_KEY}' \
3	--header 'content-type: application/json' \
4	--url http://localhost:4000/invalidation \
5	--data '[{"kind":"type","subgraph":"invalidation-subgraph-type-accounts","type":"User"}]'
Text
1POST http://127.0.0.1:3000/invalidation
2Authorization: ${INVALIDATION_SHARED_KEY}
3Content-Length:96
4Content-Type:application/json
5Accept: application/json
6
7[{
8    "kind": "type",
9    "subgraph": "invalidation-subgraph-type-accounts",
10    "type": "User"
11}]

The router would send the following response:

Text
1HTTP/1.1 201 OK
2Content-Type: application/json
3
4{
5  "count": 300
6}

The count field indicates the number of keys that were removed from Redis.

Combining both approaches

You can use both @cacheControl (for TTL-based passive invalidation) and @cacheTag (for active invalidation) together on the same types and fields. This gives you the flexibility to set reasonable TTLs as a safety net while also having the ability to invalidate data immediately when needed.

Here's a simple example combining both directives:

GraphQL
1extend schema
2  @link(
3    url: "https://specs.apollo.dev/federation/v2.12"
4    import: ["@key", "@cacheTag"]
5  )
6
7directive @cacheControl(
8  maxAge: Int
9  scope: CacheControlScope
10) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
11
12type User @key(fields: "id")
13         @cacheControl(maxAge: 60)
14         @cacheTag(format: "user-{$key.id}") {
15  id: ID!
16  name: String!
17  email: String!
18}

In this example:

  • The @cacheControl(maxAge: 60) directive sets a 60-second TTL—data automatically expires after one minute

  • The @cacheTag(format: "user-{$key.id}") directive enables immediate invalidation when user data changes

This combined approach means you get automatic passive invalidation after 60 seconds, but you can also actively invalidate specific users (like user-42) the moment their data changes in your backend.

Feedback

Edit on GitHub

Ask Community