website/src/docs/fusion/v16/cache-control.md
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.
Good cache rules help you:
To make caching work safely and predictably, you need two things:
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:
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.
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.
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/GetProducts/123456789
Variables can then be sent as query parameters.
GET /graphql/persisted/GetProducts/123456789?variables={"first":5}
In order to use persisted-operation routes you need to add the middleware MapGraphQLPersistedOperations.
var app = builder.Build();
app.MapGraphQLPersistedOperations();
@cacheControl in GraphQLA 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"])
}
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.
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.
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.
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();
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.
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);
}
}
In day-to-day terms, the flow is simple: