website/src/docs/hotchocolate/v16/server/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 server 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 as well. Query operations 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 in the same way.
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"])
}
In Hot Chocolate you can express @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();
}
Hot Chocolate computes one effective response policy by traversing the selected query fields. It reads @cacheControl metadata on each field, falls back to the field return type when values are missing, and continues recursively through child selections.
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.
Hot Chocolate computes cache constraints only for query operations. Introspection requests and operations for which no selected field contributes maxAge or sharedMaxAge do not produce a cache policy. UseQueryCache() writes the final headers only when the executed result has no GraphQL errors and the request has not opted out of cache-control header generation.
Install the package first:
dotnet add package HotChocolate.Caching
Then configure the server to register the directive and write the final headers:
using HotChocolate.Caching;
builder
.AddGraphQL()
.AddQueryType<Query>()
.UseQueryCache()
.AddCacheControl()
.ModifyCacheControlOptions(o =>
{
o.ApplyDefaults = false;
});
AddCacheControl() registers the @cacheControl directive and the schema and execution components needed to compute cache constraints. UseQueryCache() writes the final Cache-Control and Vary values so the HTTP response formatter can emit them as HTTP headers.
ModifyCacheControlOptions configures default behavior:
using HotChocolate.Caching;
builder
.AddGraphQL()
.AddQueryType<Query>()
.UseQueryCache()
.AddCacheControl()
.ModifyCacheControlOptions(o =>
{
o.Enable = true;
o.DefaultMaxAge = 60;
o.DefaultScope = CacheControlScope.Public;
o.ApplyDefaults = true;
});
| 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 defaults to eligible fields without explicit @cacheControl. |
With the default settings, eligible query fields that do not declare explicit cache metadata still contribute max-age=0. If you want headers only when fields opt in explicitly, set ApplyDefaults = false.
Hot Chocolate 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.
Hot Chocolate 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.
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);
}
}
Register the interceptor:
builder
.AddGraphQL()
.AddHttpRequestInterceptor<NoCacheHeaderInterceptor>();
:::note You can only register a single HttpRequestInterceptor per schema. :::
In day-to-day terms, the flow is simple: