ETags and Conditional Requests in .NET APIs

ETags (entity tags) are a mechanism for two things that look different but share the same foundation: caching (avoiding unnecessary data transfer) and concurrency control (preventing lost updates). An ETag is a fingerprint of a resource's state. When the state changes, the ETag changes. Clients can use ETags to ask "has this changed?" before fetching data or "is this still what I think it is?" before writing.

How ETags Work

The server includes an ETag header in the response:

HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Content-Type: application/json

{ "id": 42, "name": "Widget", "price": 9.99 }

The client can then make conditional requests using If-None-Match (for reads) or If-Match (for writes).

Conditional GET — Caching

The client sends the ETag back with If-None-Match:

GET /api/products/42
If-None-Match: "a1b2c3d4"

If the resource has not changed, the server responds with 304 Not Modified and an empty body — saving bandwidth and serialisation time.

Here is an implementation in ASP.NET Core:

Example.cs
app.MapGet("/api/products/{id:int}", async (
    int id,
    HttpContext httpContext,
    AppDbContext db,
    CancellationToken ct) =>
{
    var product = await db.Products.FindAsync([id], ct);
    if (product is null) return Results.NotFound();

    var etag = GenerateETag(product);

    // Check If-None-Match
    var requestETag = httpContext.Request.Headers.IfNoneMatch.FirstOrDefault();
    if (requestETag == etag)
    {
        return Results.StatusCode(StatusCodes.Status304NotModified);
    }

    httpContext.Response.Headers.ETag = etag;
    return Results.Ok(product);
});

Generating ETags

There are several strategies for generating ETags. The simplest is hashing the serialised resource:

Example.cs
static string GenerateETag<T>(T resource)
{
    var json = JsonSerializer.SerializeToUtf8Bytes(resource);
    var hash = SHA256.HashData(json);
    return $"\"{Convert.ToBase64String(hash)[..12]}\"";
}

If your entity has a RowVersion or ConcurrencyToken column, use that directly — it is cheaper than hashing:

Example.cs
public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = [];
}

static string GenerateETag(Product product) =>
    $"\"{Convert.ToBase64String(product.RowVersion)}\"";

The [Timestamp] attribute maps to a SQL Server rowversion column that changes automatically on every update.

Conditional PUT — Concurrency Control

The more powerful use of ETags is preventing lost updates. Two users load the same product, both edit it, and both submit. Without concurrency control, the second write silently overwrites the first.

With If-Match, the client says "only apply this update if the resource still matches this ETag":

Example.cs
app.MapPut("/api/products/{id:int}", async (
    int id,
    Product updated,
    HttpContext httpContext,
    AppDbContext db,
    CancellationToken ct) =>
{
    var product = await db.Products.FindAsync([id], ct);
    if (product is null) return Results.NotFound();

    var currentETag = GenerateETag(product);
    var requestETag = httpContext.Request.Headers.IfMatch.FirstOrDefault();

    if (string.IsNullOrEmpty(requestETag))
    {
        return Results.BadRequest(new ProblemDetails
        {
            Title = "If-Match header required",
            Detail = "Provide the ETag from the GET response in the If-Match header.",
            Status = 400
        });
    }

    if (requestETag != currentETag)
    {
        return Results.StatusCode(StatusCodes.Status412PreconditionFailed);
    }

    product.Name = updated.Name;
    product.Price = updated.Price;

    try
    {
        await db.SaveChangesAsync(ct);
    }
    catch (DbUpdateConcurrencyException)
    {
        return Results.Conflict(new ProblemDetails
        {
            Title = "Concurrency conflict",
            Detail = "The resource was modified by another request.",
            Status = 409
        });
    }

    var newETag = GenerateETag(product);
    httpContext.Response.Headers.ETag = newETag;
    return Results.Ok(product);
});

A 412 Precondition Failed response tells the client "your copy is stale — fetch the latest version and try again."

Weak vs Strong ETags

ETags come in two flavours:

For API responses, strong ETags are generally appropriate because you control the serialisation format. Use weak ETags when comparing rendered content that might vary in non-meaningful ways.

Middleware Approach

For a cleaner implementation, extract ETag handling into an endpoint filter:

ETagFilter.cs
public class ETagFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var result = await next(context);

        if (result is IValueHttpResult { Value: not null } valueResult)
        {
            var etag = GenerateETag(valueResult.Value);
            var httpContext = context.HttpContext;

            httpContext.Response.Headers.ETag = etag;

            var ifNoneMatch = httpContext.Request.Headers.IfNoneMatch.FirstOrDefault();
            if (httpContext.Request.Method == "GET" && ifNoneMatch == etag)
            {
                return Results.StatusCode(StatusCodes.Status304NotModified);
            }
        }

        return result;
    }

    private static string GenerateETag(object value)
    {
        var json = JsonSerializer.SerializeToUtf8Bytes(value);
        var hash = SHA256.HashData(json);
        return $"\"{Convert.ToBase64String(hash)[..12]}\"";
    }
}

When to Use ETags

ETags are particularly valuable for:

The implementation cost is low, and the benefits compound across your API surface. If your resources have a natural version indicator (row version, last-modified timestamp), adding ETags is straightforward.