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:
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:
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:
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":
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:
- Strong ETags (
"a1b2c3d4") — the resource is byte-for-byte identical. - Weak ETags (
W/"a1b2c3d4") — the resource is semantically equivalent but might differ in insignificant ways (whitespace, field ordering).
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:
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:
- Mobile clients with limited bandwidth where
304 Not Modifiedsaves significant data. - Frequently polled resources where most polls return unchanged data.
- Collaborative editing where multiple users may update the same resource.
- API gateways and CDNs that can cache responses based on ETags.
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.