Skip to content

Response Caching

Many API endpoints return the same data for the same request parameters. A product catalog, a list of countries, a user’s profile — these change infrequently but are requested constantly. Without caching, every request hits your application logic, database, and serialization pipeline. This wastes server resources and increases latency for clients.

Response caching (also called output caching) stores the serialized HTTP response and serves it directly for subsequent identical requests, bypassing your endpoint logic entirely. Pragmatic.Endpoints provides the [ResponseCache] attribute to configure this per-endpoint, backed by ASP.NET Core’s Output Cache middleware.


The attribute is defined in Pragmatic.Endpoints.Attributes.ResponseCacheAttribute and supports the following properties:

PropertyTypeDefaultDescription
Durationint0Cache duration in seconds. Generates CacheOutput(c => c.Expire(...)).
NoStoreboolfalseWhen true, skips cache output generation entirely.
VaryByQueryKeysstring[]?nullQuery string keys that create separate cache entries. Generates c.SetVaryByQuery(...).
VaryByHeadersstring[]?nullRequest headers that create separate cache entries. Generates c.SetVaryByHeader(...).
Profilestring?nullNamed cache tag for grouping. Generates c.Tag("profile").
LocationResponseCacheLocationAnyParsed by the SG. ASP.NET Core Output Cache does not have a direct Location API — use VaryByHeaders with Authorization for private caching.
ValueMeaning
AnyResponse can be cached by client, proxy, and server
ClientResponse can only be cached by the client (private)
NoneResponse should not be cached

[Endpoint(HttpVerb.Get, "/api/products")]
[ResponseCache(Duration = 300)] // 5 minutes
public partial class GetProducts : Endpoint<ProductListDto> { }

For 5 minutes after the first request, subsequent GET requests to /api/products receive the cached response without executing HandleAsync.

When different query parameters should produce different cache entries:

[Endpoint(HttpVerb.Get, "/api/products")]
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "page" })]
public partial class GetProducts : Endpoint<ProductListDto> { }

/api/products?category=shoes&page=1 and /api/products?category=hats&page=1 are cached independently.

For user-specific responses that should not be cached by shared proxies:

[Endpoint(HttpVerb.Get, "/api/user/preferences")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)]
public partial class GetUserPreferences : Endpoint<PreferencesDto> { }

To explicitly mark an endpoint as non-cacheable (useful to override group-level defaults):

[Endpoint(HttpVerb.Post, "/api/orders")]
[ResponseCache(NoStore = true)]
public partial class PlaceOrder : DomainAction<OrderId> { }

When the response depends on request headers (e.g., Accept-Language for localized content):

[Endpoint(HttpVerb.Get, "/api/content")]
[ResponseCache(Duration = 600, VaryByHeaders = new[] { "Accept-Language" })]
public partial class GetContent : Endpoint<ContentDto> { }

How It Works: Source Generator to Output Cache

Section titled “How It Works: Source Generator to Output Cache”

When the SG detects a [ResponseCache] attribute on an endpoint (and NoStore is not true), it emits a .CacheOutput() call in the endpoint’s configuration:

// Generated code
builder.CacheOutput(c => c.Expire(System.TimeSpan.FromSeconds(300)));

This uses ASP.NET Core’s Output Cache (Microsoft.AspNetCore.OutputCaching), not the older Response Caching middleware. Output Cache is server-side: it stores the complete HTTP response in memory (or a distributed store) and serves it directly from the middleware pipeline.

The SG reads the Duration property and emits the corresponding Expire() call. The VaryByQueryKeys and VaryByHeaders properties flow as configuration to the output cache policy.


By default, ASP.NET Core’s Output Cache stores responses in memory. This works well for single-instance deployments but has two limitations:

  1. Each instance maintains its own cache, so in a multi-instance setup, cache hit rates are lower.
  2. Memory pressure from large cached responses can impact your application.

Distributed: UseOutputCacheFromPragmaticCaching

Section titled “Distributed: UseOutputCacheFromPragmaticCaching”

Pragmatic.Endpoints.AspNetCore provides a bridge that replaces the in-memory store with Pragmatic.Caching’s ICacheStack. This means cached responses use the same backend (Redis, SQL Server, or whatever IDistributedCache you have configured) as your business-layer caching.

using Pragmatic.Endpoints.AspNetCore.Extensions;
builder.Services.AddPragmaticCaching(); // Register Pragmatic.Caching
builder.Services.UseOutputCacheFromPragmaticCaching(); // Bridge: output cache -> same backend

The UseOutputCacheFromPragmaticCaching() extension method does two things:

  1. Calls AddOutputCache() to register the output cache services.
  2. Registers PragmaticOutputCacheStore as the IOutputCacheStore singleton.

The store implements IOutputCacheStore with three operations:

OperationBehavior
GetAsync(key)Reads from ICacheStack with key outputcache:{key}
SetAsync(key, value, tags, validFor)Writes to ICacheStack with the specified TTL. Tags are prefixed with outputcache:tag:
EvictByTagAsync(tag)Invalidates all entries matching outputcache:tag:{tag} via ICacheStack.InvalidateByTagAsync

The outputcache: prefix prevents collisions with business-layer cache entries. The store resolves ICacheStack lazily using CacheStackProvider.ForCategory<CacheCategories.OutputCache>, falling back to the default cache stack if no output-cache-specific category is configured.

If ICacheStack is not available at runtime (e.g., Pragmatic.Caching was not registered), all operations are no-ops — the output cache degrades gracefully to no caching rather than throwing.


These are different caching layers that solve different problems. They can coexist on the same endpoint.

Aspect[ResponseCache][Cacheable]
LayerHTTP (response serialization)Business logic (action result)
What is cachedComplete HTTP response (headers + body)Action return value (before serialization)
Where it runsASP.NET Core Output Cache middlewarePragmatic.Caching in the action invoker
Cache keyURL + query + headersAction type + input parameters
InvalidationTag-based or TTL expiryExplicit invalidation or TTL expiry
Use caseReduce serialization and middleware overheadReduce database queries and computation
PackagePragmatic.EndpointsPragmatic.Caching

Consider an endpoint that returns a product catalog:

[Endpoint(HttpVerb.Get, "/api/products")]
[ResponseCache(Duration = 60)] // HTTP layer: serve cached response for 1 min
public partial class GetProducts : Endpoint<ProductListDto>
{
// ...
}

If the underlying GetProductsQuery is also decorated with [Cacheable], the caching happens at two levels:

  1. Output Cache catches repeated HTTP requests and returns the cached response without entering the handler.
  2. Business Cache catches the query result even if the output cache has expired but the data hasn’t changed.

This layered approach is particularly effective when you have expensive queries behind frequently-accessed endpoints.


Output caching requires the middleware in your pipeline:

app.UseOutputCache(); // Must be in the pipeline
app.MapPragmaticEndpoints();

Without UseOutputCache(), the CacheOutput() configuration on endpoints has no effect. The SG generates the cache configuration metadata, but the middleware is your responsibility.

Ordering matters: UseOutputCache() should come after authentication/authorization middleware (so cached responses respect auth) and before endpoint routing.


// Program.cs or IStartupStep
services.AddPragmaticCaching();
services.UseOutputCacheFromPragmaticCaching(); // Optional: distributed backend
// Pipeline
app.UseOutputCache();
app.MapPragmaticEndpoints();
// Cache product list for 5 minutes, vary by category and page
[Endpoint(HttpVerb.Get, "/api/products")]
[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "page" })]
public partial class GetProducts : Endpoint<ProductListDto>
{
[FromQuery] public string? Category { get; set; }
[FromQuery] public int? Page { get; set; }
public override async Task<Result<ProductListDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
// This only executes if the output cache doesn't have a valid entry
// ...
}
}
// Private cache for user-specific data
[Endpoint(HttpVerb.Get, "/api/user/dashboard")]
[ResponseCache(Duration = 30, Location = ResponseCacheLocation.Client)]
public partial class GetDashboard : Endpoint<DashboardDto> { }
// No caching on mutations
[Endpoint(HttpVerb.Post, "/api/products")]
[ResponseCache(NoStore = true)]
public partial class CreateProduct : DomainAction<ProductId> { }

  • GET and HEAD only: Output caching only applies to GET and HEAD requests by default. POST, PUT, DELETE responses are not cached.
  • Authenticated responses: Be careful caching responses for authenticated endpoints. Use Location = ResponseCacheLocation.Client for per-user data, or use VaryByHeaders with authorization-related headers.
  • Group-level cache: EndpointGroupOptions.ResponseCacheDuration sets a default cache duration for all endpoints in a group. Individual endpoints can override this with their own [ResponseCache] attribute.
  • NoStore skips generation: When NoStore = true, the SG does not emit any CacheOutput() call. This is the correct way to explicitly mark an endpoint as uncacheable.
  • Output Cache vs Response Caching: Pragmatic uses ASP.NET Core’s Output Cache (CacheOutput), not the older Response Caching middleware (ResponseCaching). Output Cache is more capable: it supports tag-based invalidation, programmatic policies, and distributed stores.