HTTP Caching and ETags

Send Cache-Control and ETag headers, answer If-None-Match with 304 and use If-Match with a concurrency token to reject lost updates with 412.

Syntaxvar etag = new EntityTagHeaderValue($"\"{order.Version}\""); http.Response.GetTypedHeaders().ETag = etag; if (http.Request.GetTypedHeaders().IfNoneMatch.Any(t => t.Compare(etag, false))) return Results.StatusCode(304);

HTTP caching happens outside your process: in browsers, CDNs and proxies, governed by response headers (RFC 9111). Your API decides what they may store and for how long with Cache-Control, and lets clients revalidate cheaply with validators such as ETag. ASP.NET Core sets no caching headers on API responses by default; static files get ETag and Last-Modified automatically, endpoints do not.

Cache-Control directives

DirectiveMeaning
max-age=60Fresh for 60 seconds in any cache
s-maxage=300Overrides max-age for shared caches (CDN)
privateOnly the end user's browser may store it
no-cacheMay be stored, but must be revalidated before every use
no-storeMust not be stored anywhere
stale-while-revalidate=30May serve stale for 30 s while refreshing in the background

Use public with s-maxage for anonymous catalogue data, private, no-cache for per-user resources that support revalidation, and no-store for tokens and payment details. Shared caches do not store responses to requests carrying Authorization unless the response explicitly allows it, which is a safety net, not a design. Add Vary for every request header that changes the body, such as Accept-Language.

Conditional GET: ETag and 304

An ETag is an opaque version of the representation, a quoted string such as "17"; W/"17" marks a weak validator (semantically equivalent, not byte-identical). The client stores it and sends If-None-Match: "17". If the resource still has that version, the server answers 304 Not Modified with no body but with the same ETag and Cache-Control it would have sent. If-None-Match uses weak comparison.

Derive the ETag from a version you already have: the row's concurrency token (xmin on PostgreSQL, rowversion on SQL Server) or an updated-at plus id. Then the 304 path is a cheap version lookup. Hashing the serialised body also works but still runs the query and serialisation, so it saves bandwidth only.

Conditional writes: If-Match and 412

The same ETag prevents lost updates. A client sends If-Match: "17" on PUT or PATCH; if the current version is not 17, return 412 Precondition Failed and let the client re-read. If-Match uses strong comparison, so a weak tag never matches. To force clients to opt in, answer 428 Precondition Required when the header is missing. The comparison must happen atomically in the database (UPDATE ... WHERE version = @expected, which is what EF Core concurrency tokens generate); checking in memory and then saving leaves a race.

Production traps

  • Compression changes the bytes; proxies such as nginx downgrade strong ETags to weak when they gzip, so If-Match against a proxied response can fail. Base ETags on the entity version, not the body bytes.
  • A CDN caching per-user JSON because someone sent public on an authenticated endpoint.
  • A 304 without ETag: some clients then drop their cached copy.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Let a mobile app revalidate GET /orders/{id} with If-None-Match and get a 304 with an empty body when the order status has not changed.
  • Reject a PUT to an order's shipping address with 412 when a support agent changed it after the customer loaded the page.
  • Serve the public category list from a CDN for 5 minutes with s-maxage while browsers revalidate every minute.

More examples

Parsing If-Match at the edge, saving in a service

The endpoint owns only the HTTP rules: no header means 428, a header that names no usable strong version still goes to the service and fails as 412, and <code>If-Match: *</code> means update if the order exists. A client may list several tags, so the service gets all of them. The database half, the conditional UPDATE on a <code>rowversion</code> or <code>xmin</code> token, deletes and re-reading the current version after a conflict, is in the EF Core concurrency lesson (<code>net-efcore-concurrency</code>); the endpoint just puts the version it gets back in the <code>ETag</code> header, including on 412.

Example · csharp
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();

app.MapPut("/orders/{id:int}/shipping-address", async (int id, AddressChange change, HttpContext http, IOrderService orders, CancellationToken ct) =>
{
    var precondition = Precondition.FromIfMatch(http.Request.Headers.IfMatch);
    if (precondition is null)
        return Results.Problem(statusCode: StatusCodes.Status428PreconditionRequired, title: "Send If-Match with the order's ETag.");

    var result = await orders.UpdateAddressAsync(id, precondition, change.Address, ct);
    if (result.CurrentVersion is { } version)
        http.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue($"\"{version}\"");

    return result.Outcome switch
    {
        UpdateOutcome.NotFound => Results.NotFound(),
        UpdateOutcome.Conflict => Results.Problem(statusCode: StatusCodes.Status412PreconditionFailed,
            title: "The order was changed by someone else. GET it again and reapply your change."),
        _ => Results.NoContent(),
    };
});
app.Run();

// AnyVersion is If-Match: *, "update only if the order exists". Versions empty means nothing usable was sent.
public sealed record Precondition(bool AnyVersion, IReadOnlyList<uint> Versions)
{
    public static Precondition? FromIfMatch(StringValues header)
    {
        if (StringValues.IsNullOrEmpty(header)) return null;
        if (!EntityTagHeaderValue.TryParseList(header, out var tags)) return new(false, []);
        if (tags.Any(t => t.Equals(EntityTagHeaderValue.Any))) return new(true, []);

        // If-Match uses strong comparison, so weak tags never match and are dropped.
        var versions = tags.Where(t => !t.IsWeak)
            .Select(t => uint.TryParse(t.Tag.AsSpan().Trim('"'), out var v) ? v : (uint?)null)
            .OfType<uint>()
            .ToList();
        return new(false, versions);
    }
}

public enum UpdateOutcome { Updated, NotFound, Conflict }
public sealed record UpdateResult(UpdateOutcome Outcome, uint? CurrentVersion);
public sealed record AddressChange(string Address);

public interface IOrderService
{
    Task<UpdateResult> UpdateAddressAsync(int id, Precondition precondition, string address, CancellationToken ct);
}

// Stand-in: the real implementation is the EF Core concurrency-token save from the concurrency lesson.
public sealed class OrderService : IOrderService
{
    public Task<UpdateResult> UpdateAddressAsync(int id, Precondition precondition, string address, CancellationToken ct) =>
        Task.FromResult(new UpdateResult(UpdateOutcome.NotFound, null));
}

The conversation on the wire

<code>private, no-cache</code> lets the browser keep the order but forces a revalidation on every use, so the user never sees a stale status yet pays only for a 304 when nothing changed. The 304 repeats <code>ETag</code> and <code>Cache-Control</code>. The PUT carries an older version, so the server refuses it instead of overwriting the change made in version 17.

Example · http
GET /orders/5001 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Cache-Control: private, no-cache
ETag: "17"
Content-Type: application/json; charset=utf-8

###

GET /orders/5001 HTTP/1.1
Host: api.example.com
If-None-Match: "17"

HTTP/1.1 304 Not Modified
Cache-Control: private, no-cache
ETag: "17"

###

PUT /orders/5001/shipping-address HTTP/1.1
Host: api.example.com
If-Match: "16"
Content-Type: application/json

{"address": "9 Quay Rd, Leith"}

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json

CDN-friendly headers for a public catalogue endpoint

A filter cannot read the status after <code>next</code>: the returned <code>IResult</code> executes after the filter pipeline, so <code>StatusCode</code> is still the default 200 there, even for a <code>NotFound</code> or a 500. Registering <code>Response.OnStarting</code> decides when headers are sent and the status is final: 200 gets the public headers, anything else gets <code>no-store</code>, because a cached 500 at the CDN would turn a short outage into five minutes of errors. <code>s-maxage</code> lets the CDN hold the data longer than browsers, since the CDN can be purged on deploy and browsers cannot. The body depends on <code>Accept-Language</code>, so <code>Vary</code> must name it or the CDN serves French to English readers.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var catalog = app.MapGroup("/catalog").AddEndpointFilter(async (context, next) =>
{
    var response = context.HttpContext.Response;
    // The returned IResult runs after the filter, so the final status is only known when headers are sent.
    response.OnStarting(() =>
    {
        if (response.StatusCode == StatusCodes.Status200OK)
        {
            // Browsers keep it 60 s, the CDN 5 minutes, and may serve stale for 30 s while refetching.
            response.Headers.CacheControl = "public, max-age=60, s-maxage=300, stale-while-revalidate=30";
            response.Headers.Vary = "Accept-Language";
        }
        else
        {
            response.Headers.CacheControl = "no-store";
        }
        return Task.CompletedTask;
    });
    return await next(context);
});

catalog.MapGet("/categories", (HttpRequest request) =>
{
    var french = request.Headers.AcceptLanguage.ToString().StartsWith("fr", StringComparison.OrdinalIgnoreCase);
    return TypedResults.Ok(french ? new[] { "Eclairage", "Mobilier" } : new[] { "Lighting", "Furniture" });
});

app.Run();

Discussion

  • Be the first to comment on this lesson.