Invalidation
Control cache freshness with passive TTL and active invalidation
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:
Calculates the overall response TTL by taking the minimum TTL from all cached origin responses included in the client response
Generates a
Cache-Controlheader for the client response reflecting this minimum TTLReturns 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:
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:
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@cacheControl or an equivalent mechanism for setting Cache-Control headers.Field-level TTLs
Apply @cacheControl to individual fields to set specific TTLs:
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:
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:
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 cachePRIVATE: The data is user-specific and requires aprivate_idconfiguration to cache per-user
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
maxAgebehavior for root fields vs. other fieldsRecommendations 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.
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:
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:
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}
24By subgraph
To invalidate all cached data from the accounts subgraph, send a request with the following format:
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:
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:
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:
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:
1[{
2 "kind": "cache_tag",
3 "subgraphs": ["accounts"],
4 "cache_tag": "posts-user-42"
5}]@cacheTag directive has the following constraints regarding its application:- Only applies to root query fields or resolvable entities (types marked with
@keydirective whereresolvableis unset ortrue) - When set on a root field, the
formatstring allows{$args.XXX}interpolation, whereargsis a map of all arguments for that root field - When set on an entity, the format is
{$key.XXX}, wherekeyis a map of all entity keys for that type- If you have multiple
@keydirectives (e.g.,@key(fields: "id")and@key(fields: "id name")), you can only access fields present in every@keydirective (in this case, only{$key.id})
- If you have multiple
- The
formatmust always generate a valid string (not an object)
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}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}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}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:
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:
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:
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"}]'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:
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:
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 minuteThe
@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.