Output and Response Caching

Output caching stores whole responses on the server; response caching headers tell clients and proxies what they may keep.

Syntaxapp.MapGet("/path", handler).CacheOutput("PolicyName")

Caching data saves the work of producing a value. Caching a response saves producing the whole reply, headers and body together. ASP.NET Core has two separate mechanisms for that, and they are easy to confuse.

Output caching: your server keeps the copy

Output caching is middleware, a step in the pipeline that every request passes through. It stores a finished response and replays it for the next matching request. Register it with AddOutputCache, insert it with UseOutputCache, then opt an endpoint in with CacheOutput() or the [OutputCache] attribute. It was added in .NET 7. You decide what is stored, so a client cannot force fresh work. By default only status 200 replies to GET or HEAD requests are stored, never one that sets a cookie and never one for an authenticated request. The key is the whole URL unless a policy narrows it with SetVaryByQuery. Entries last 60 seconds unless Expire says otherwise, and the store is in-process memory unless you add the Redis store.

Response caching: someone else keeps the copy

Response caching is a set of HTTP headers. Cache-Control: public, max-age=60 tells the browser, and any proxy in between, that this reply may be reused for 60 seconds. It stores nothing on your server. In MVC the [ResponseCache] attribute writes those headers for you. There is also response caching middleware, but the HTTP specification makes it obey a client that demands a fresh copy, so output caching is the tool for controlling load.

Conditional requests

An ETag header is a version marker for a body. A client that has seen it sends If-None-Match next time, and if nothing changed you answer 304 Not Modified with no body. Cache only what is the same for everyone.

Example

Example · csharp
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddOutputCache(options =>
{
    // A named policy is opted into per endpoint. A base policy would instead
    // apply to every endpoint in the app, including the header-only one below.
    options.AddPolicy("Reports", policy => policy
        .Expire(TimeSpan.FromSeconds(30))
        .Tag("all"));
});

WebApplication app = builder.Build();

// Place this after UseAuthentication and UseAuthorization in an app that has them.
app.UseOutputCache();

int builds = 0;

// The server stores this response and replays it for 30 seconds.
app.MapGet("/report", () =>
{
    builds++;
    return Results.Ok(new { builds });
}).CacheOutput("Reports");

// Nothing is stored here. The header asks the browser and any proxy
// to keep their own copy for 60 seconds.
app.MapGet("/banner", (HttpContext context) =>
{
    context.Response.Headers.CacheControl = "public, max-age=60";
    return Results.Ok(new { text = "Autumn sale" });
});

app.Run();

When to use it

  • Serve a public price list from the server's own cache instead of querying on every request.
  • Let browsers keep a rarely changing configuration endpoint for a minute.
  • Answer 304 to clients polling a feed that has not changed.
  • Purge the cached home page the moment an editor publishes.

More examples

Vary by one query string, and purge by tag

Without SetVaryByQuery the whole URL is the key, so /products?utm_source=mail is cached separately from /products even though the body is identical, and anything adding random query strings can fill the store. Here only page changes the key. The purge endpoint is the shape to copy for an editor who publishes a change and needs it live immediately; in a real app, put authorisation on it.

Example · csharp
using Microsoft.AspNetCore.OutputCaching;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddOutputCache(options =>
{
    // Named policies are opted into per endpoint.
    options.AddPolicy("ByPage", policy => policy
        .Expire(TimeSpan.FromSeconds(20))
        .SetVaryByQuery("page")   // only ?page= makes a separate cache entry
        .Tag("products"));

    options.AddPolicy("NoCache", policy => policy.NoCache());
});

WebApplication app = builder.Build();
app.UseOutputCache();

app.MapGet("/products", (int page = 1) => Results.Ok(new { page }))
    .CacheOutput("ByPage");

// IOutputCacheStore is the storage behind the middleware. Evicting by tag
// removes every entry that carried it.
app.MapPost("/products/purge", async (IOutputCacheStore store, CancellationToken token) =>
{
    await store.EvictByTagAsync("products", token);
    return Results.NoContent();
});

app.Run();

ETag and a 304 response

Call curl -i http://localhost:5000/catalogue and the reply carries ETag: "catalogue-v7". Repeat it with -H 'If-None-Match: "catalogue-v7"' and the reply is 304 Not Modified with an empty body, which saves the bytes on the wire rather than the work on the server. Output caching performs this check itself for a response it has already stored; the explicit check covers the first request and any request that misses the cache. Derive the marker from something real, such as a row version, so it changes exactly when the content does.

Example · csharp
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache();

WebApplication app = builder.Build();
app.UseOutputCache();

app.MapGet("/catalogue", (HttpContext context) =>
{
    // A marker that changes only when the catalogue itself changes.
    const string etag = "\"catalogue-v7\"";
    context.Response.Headers.ETag = etag;

    // The client is telling us which version it already holds.
    if (context.Request.Headers.IfNoneMatch == etag)
    {
        return Results.StatusCode(StatusCodes.Status304NotModified);
    }

    return Results.Ok(new[] { "Blue mug", "Red mug" });
}).CacheOutput();

app.Run();

Headers only, with [ResponseCache] in a controller

The attribute only sets headers, which is the most misunderstood thing about it: adding [ResponseCache] does not make your server cache anything. Location.Client would write private instead of public, meaning the browser may keep a copy but no shared proxy may. The no-store pair is what you want on anything personal, because a public header on a per-user response is how one customer ends up seeing another customer's data through a proxy.

Example · csharp
using Microsoft.AspNetCore.Mvc;

namespace Shop.Controllers;

[ApiController]
[Route("api/[controller]")]
public sealed class StatusController : ControllerBase
{
    // Writes Cache-Control: public,max-age=60. Nothing is stored on the server;
    // Location.Any means the client or any proxy may keep a copy.
    [HttpGet("public")]
    [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
    public IActionResult GetPublic() => Ok(new { status = "ok" });

    // Writes Cache-Control: no-store,no-cache and Pragma: no-cache.
    // Use this for anything whose answer depends on who is asking.
    [HttpGet("me")]
    [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)]
    public IActionResult GetMine() => Ok(new { status = "ok", user = "signed-in" });
}

Discussion

  • Be the first to comment on this lesson.