Cache Control
Cache-Control is the HTTP header field that tells browsers, reverse proxies, and CDNs how they are allowed to store and reuse a response instead of sending the same request back to the server every time. Together with related headers such as Vary, it makes cached responses safe and predictable by defining whether a response may be reused, how long it may be reused, and which parts of the request affect that decision.
Why Cache Control Matters#
Good cache rules help you:
- Reduce latency for users.
- Reduce load on your backend services.
- Keep cache behavior predictable.
- Protect user-specific data from shared caches.
To make caching work safely and predictably, you need two things:
- A deterministic GET route, or a QUERY request, so caches get a stable cache key.
- Correct Cache-Control (and Vary) headers so caches know where and how long a response may be reused.
Why GraphQL Needs Extra Care#
GraphQL usually exposes one endpoint, but each request can ask for different fields. Two requests to the same URL can therefore return very different response shapes and different data sensitivity.
A single GraphQL response can also mix public data and user-specific data. Since HTTP cache headers apply to the full response, the gateway has to compute one safe final policy that represents everything selected in that operation.
That means one response can include:
- Public data (safe for shared caches), and
- User-specific data (not safe for shared caches).
The GraphQL operation type matters. Query operations are side-effect free reads, so they are the primary target for HTTP and CDN caching. Mutations change data and should not be cached as shared HTTP responses. Subscriptions are long-running streams and are not HTTP-cacheable.
Deterministic GET Routes#
The GraphQL over HTTP specification allows query operations to be sent over HTTP GET.
GET /graphql?query=query GetProducts{products{nodes{name}}}The same applies when variables are included.
GET /graphql?query=query GetProducts($first:Int!){products(first:$first){nodes{name}}}&variables={"first":5}In real requests, these values are URL-encoded, and for larger operations the query string quickly becomes difficult to work with. This is where persisted operations help.
Persisted Operation Routes#
In large first-party GraphQL APIs, a common approach used by companies such as Netflix, Meta, and X is to rely on trusted documents. Client operations are stored in an operation store, and clients send a stable operation identifier instead of the full query text.
You can read more in the First-Party API guide.
With trusted documents in place, persisted-operation routes become short and stable.
GET /graphql/persisted/123456789/GetProductsVariables can then be sent as query parameters.
GET /graphql/persisted/123456789/GetProducts?variables={"first":5}In order to use persisted-operation routes you need to add the middleware MapGraphQLPersistedOperations.
var app = builder.Build();
app.MapGraphQLPersistedOperations();QUERY Requests#
Query operations can also be sent over HTTP QUERY, which carries the same JSON body as a POST request. QUERY requests are disabled by default. Enable them on the gateway with EnableQueryRequests:
builder
.AddGraphQLGateway()
.ModifyServerOptions(o => o.EnableQueryRequests = true);QUERY /graphql
Content-Type: application/json
Accept: application/graphql-response+json
{
"query": "query GetProducts($first: Int!) { products(first: $first) { nodes { name } } }",
"variables": { "first": 5 }
}RFC 10008 makes the response to a QUERY request cacheable, and a cache that stores it includes the request body in the cache key. A QUERY response carries the same Cache-Control and Vary headers as a GET response. A variable batch is answered with Cache-Control: no-cache, whatever the method. Support for caching QUERY responses varies between CDNs and proxies, so GET routes and persisted-operation GET routes are the portable choice. See QUERY Requests for the full request rules.
@cacheControl in GraphQL#
A deterministic route alone is not enough. The gateway also needs policy metadata to decide whether a response is public or private, and how long it may be reused.
GraphQL provides the @cacheControl directive for this purpose. You can place it on fields and types to describe cache intent.
type Query {
productById(id: ID!): Product @cacheControl(maxAge: 300, sharedMaxAge: 900)
me: UserProfile
@cacheControl(maxAge: 60, scope: PRIVATE, vary: ["Authorization"])
}How Fusion Assembles the Final Headers#
Fusion computes one effective response policy by traversing the planned operation tree. It starts at the selected root fields, reads @cacheControl metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections, including interfaces and unions.
The return type is the named type behind any non-null and list wrappers, so a field of type UserProfile, UserProfile!, or [UserProfile!]! takes the settings of UserProfile. When the return type is an interface or a union, the settings of each object type that implements it or belongs to it apply too, whether or not the operation selects fields of that object type. A maxAge or sharedMaxAge declared on the field takes precedence over the lifetimes of these types.
All collected constraints are merged into one final policy. The merge is conservative: max-age and s-maxage take the lowest value, scope resolves to the strictest value (private over public), and vary values are merged, normalized, and deduplicated. Every GraphQL response already lists Accept in Vary, and the merged vary names are added to it. Some CDNs, Akamai by default, do not cache a response whose Vary lists anything other than Accept-Encoding. Configure the CDN to cache it anyway, or remove Accept from Vary in a custom formatter.
Fusion computes these cache constraints for query operations. Mutation requests, Subscription request, introspection requests, and operations with no cache constraints do not get cache-control headers.
Cache Policy Starts in Subgraphs#
In Fusion, cache policy starts where data is resolved, which means your subgraphs define cache intent for their own fields.
type Product {
id: ID!
name: String!
}
type UserProfile {
id: ID!
email: String!
}
type Query {
productById(id: ID!): Product @cacheControl(maxAge: 300, sharedMaxAge: 900)
me: UserProfile
@cacheControl(maxAge: 60, scope: PRIVATE, vary: ["Authorization"])
}If your subgraph runs on Hot Chocolate, you can express the @cacheControl directive with the [CacheControl] attribute.
using HotChocolate.Caching;
[QueryType]
public static class Query
{
[CacheControl(300, SharedMaxAge = 900)]
public static Product? GetProductById(int id)
=> ProductRepository.GetById(id);
[CacheControl(60, Scope = CacheControlScope.Private, Vary = ["Authorization"])]
public static UserProfile GetMe()
=> UserProfileRepository.GetCurrent();
}Enable cache-control metadata on the subgraph:
builder
.AddGraphQL()
.AddCacheControl();Types and Fields in Several Source Schemas#
When several source schemas define the same type or field and at least one of them declares @cacheControl, composition merges the declarations. A source schema that defines the type or field without @cacheControl declares nothing.
| Argument | Object type, interface, or union | Field |
|---|---|---|
scope | PRIVATE if any source schema declares it | PRIVATE if any source schema declares it |
vary | Every declared value | Every declared value |
maxAge | The lowest declared value, and when sharedMaxAge is left out, no higher than any declared sharedMaxAge | The lowest value, only when every source schema declares it |
sharedMaxAge | The lowest declared value, only when every source schema that declares maxAge or sharedMaxAge declares it | The lowest value, only when every source schema declares it |
inheritMaxAge | true only when every source schema declares true | true only when every source schema declares true |
Enable Cache Control in the Fusion Gateway#
The gateway must be configured to read cache-control metadata during planning and to write the final HTTP headers on the response.
var builder = WebApplication.CreateBuilder(args);
builder
.AddGraphQLGateway()
.AddCacheControl()
.UseQueryCache();AddCacheControl() enables cache-constraint planning. UseQueryCache() writes the final Cache-Control and Vary headers to HTTP responses.
Cache-Control Options#
ModifyCacheControlOptions configures the gateway's defaults:
using HotChocolate.Caching;
builder
.AddGraphQLGateway()
.AddCacheControl()
.ModifyCacheControlOptions(o =>
{
o.Enable = true;
o.DefaultMaxAge = 60;
o.DefaultScope = CacheControlScope.Public;
o.ApplyDefaults = true;
})
.UseQueryCache();| Option | Type | Default | Description |
|---|---|---|---|
Enable | bool | true | Enables or disables cache-control header generation. |
DefaultMaxAge | int | 0 | Default max-age when ApplyDefaults is enabled. |
DefaultScope | CacheControlScope | Public | Default cache scope when ApplyDefaults is enabled. |
ApplyDefaults | bool | true | Applies the defaults to root fields, and to fields a lookup brings in, that set no cache lifetime. |
With ApplyDefaults enabled, a root field without @cacheControl, such as one from a subgraph that does not use cache control, contributes DefaultMaxAge and DefaultScope. So does a root field whose @cacheControl sets no maxAge, no sharedMaxAge, and no inheritMaxAge: true, unless its return type's @cacheControl sets a lifetime. An interface or union return type sets a lifetime when it sets one itself, or when every object type that implements it or belongs to it does. When such a field's @cacheControl sets scope, the field contributes that scope with DefaultMaxAge. A field that the gateway selects directly on an entity it fetches from another subgraph through a lookup is treated like a root field, and the fields selected below it are not. With the default settings, a response that selects such a field gets max-age=0. The gateway applies its own options, so a subgraph's ApplyDefaults = false does not change them. If you want headers only when fields opt in explicitly, set ApplyDefaults = false on the gateway.
Skip Cache Control for a Specific Request#
Use SkipQueryCaching() on OperationRequestBuilder to bypass cache-control for a specific request.
using HotChocolate.AspNetCore;
using HotChocolate.Execution;
public sealed class NoCacheHeaderInterceptor : DefaultHttpRequestInterceptor
{
public override ValueTask OnCreateAsync(
HttpContext context,
IRequestExecutor requestExecutor,
OperationRequestBuilder requestBuilder,
CancellationToken cancellationToken)
{
if (context.Request.Headers.ContainsKey("X-Skip-Cache-Control"))
{
requestBuilder.SkipQueryCaching();
}
return base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken);
}
}Putting It Together#
In day-to-day terms, the flow is simple:
- Subgraphs declare cache intent.
- Fusion composes that metadata.
- The gateway calculates one safe policy for each query result.
- HTTP caches enforce the resulting headers.