ETag in ASP.NET Core + EF Core, a practical guide for real APIs
ETag is one of those features that looks “extra” until you ship an app that people actually use. Then you see the same problems everywhere:
- The UI keeps reloading the same data even when nothing changed.
- Two users edit the same record, the second save silently overwrites the first.
- Mobile clients waste bandwidth and feel slow.
ETag solves these in a clean HTTP-native way.
What an ETag really is
An ETag is a short string that represents the current version of a resource.
When you return a resource, you also return an ETag header:
ETag: "abc123"
Later, clients can use it in two ways.
1) Fast reads (caching style)
Client sends:
If-None-Match: "abc123"
If the resource is unchanged, the server returns:
-
304 Not Modified(no body)
2) Safe writes (optimistic concurrency style)
Client sends on update:
If-Match: "abc123"
If the resource changed since the client last fetched it, the server returns:
412 Precondition Failed
If the client did not send If-Match at all, best practice is:
428 Precondition Required
This is exactly what your helper is designed to do.
public static class ETagHelper
{
public static void SetETag(HttpResponse response, string etag)
{
response.Headers.ETag = $"\"{etag}\"";
response.Headers.CacheControl = "private, must-revalidate";
}
public static bool IsNotModified(HttpRequest request, string currentETag)
{
var clientETag = request.Headers.IfNoneMatch.ToString().Trim('"');
return !string.IsNullOrWhiteSpace(clientETag) && clientETag == currentETag;
}
public static void ValidateIfMatch(HttpRequest request, string currentETag)
{
var ifMatch = request.Headers.IfMatch.ToString();
if (string.IsNullOrWhiteSpace(ifMatch))
throw new PreconditionRequiredException(
"If-Match header is required for update operations.");
var clientETag = ifMatch.Trim('"');
if (clientETag != "*" && clientETag != currentETag)
throw new PreconditionFailedException(
"The resource has been modified since you last retrieved it. " +
"Fetch the latest version (including its ETag) and retry.");
}
}
Why this matters in CRUD apps
Where it helps a lot
-
Details screens:
/products/{id},/orders/{id} - Admin panels: multiple users editing same entity
- Polling dashboards: refresh every 10 seconds without downloading unchanged payloads
- Mobile apps: reduce data usage, speed up screens
- Any “edit then save” flow: prevents “last write wins” accidents
What it does not magically solve
- Business conflicts (two users legitimately editing different fields), you still need merge logic if you want automatic merging.
- List endpoints are trickier, ETag for lists needs careful versioning per query.
Your helper, what it enforces
You already have a good ETag helper:
-
SetETagsets bothETagandCache-Control: private, must-revalidate -
IsNotModifiedcompares the client ETag to the current one -
ValidateIfMatchenforcesIf-Matchon update operations, rejects stale versions, supportsIf-Match: *
About the Cache-Control you set:
-
privatemeans shared caches should not store it (good for user-specific data). -
must-revalidatemeans if a cache has a stale copy, it must check with the server before using it.
This is a sensible default for most authenticated apps.
Picking the ETag value in EF Core
The best ETag is one that changes whenever the record changes.
Option A (recommended): RowVersion concurrency token
This is the cleanest for EF Core, because it doubles as real concurrency control.
using System.ComponentModel.DataAnnotations;
public sealed class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = [];
}
Then convert RowVersion to an ETag string:
static string ToETag(byte[] rowVersion) => Convert.ToBase64String(rowVersion);
static byte[] FromETag(string etag) => Convert.FromBase64String(etag);
Option B: UpdatedAt timestamp (works, but weaker)
If you do not want RowVersion, you can use UpdatedAt and make ETag from it, for example ticks. It works, but you must guarantee UpdatedAt changes on every update, including “no-op” updates if you allow them.
RowVersion is usually safer.
Read endpoint (GET), add “more to read” behavior
The core flow is:
- Load entity (AsNoTracking for GET)
- Compute ETag
- If client already has it, return 304
- Else return 200 with ETag
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id, CancellationToken ct)
{
var product = await db.Products
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (product is null) return NotFound();
var currentETag = ToETag(product.RowVersion);
if (ETagHelper.IsNotModified(Request, currentETag))
return StatusCode(StatusCodes.Status304NotModified);
ETagHelper.SetETag(Response, currentETag);
return Ok(new { product.Id, product.Name, product.Price });
}
What clients gain on GET
- If nothing changed, they get a fast 304 and skip JSON parsing.
- If changed, they get the new body and a new ETag.
Update endpoints (PUT, PATCH), protect the record
PUT example
Your logic is:
- Load entity
- Compute current ETag
- Validate
If-Match - Apply changes
- Set EF Core OriginalValue so SaveChanges performs concurrency check
- Save, return updated entity plus new ETag
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateProductDto dto, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
if (product is null) return NotFound();
var currentETag = ToETag(product.RowVersion);
ETagHelper.ValidateIfMatch(Request, currentETag);
var clientETag = Request.Headers.IfMatch.ToString().Trim('"');
product.Name = dto.Name;
product.Price = dto.Price;
if (clientETag != "*")
db.Entry(product).Property(x => x.RowVersion).OriginalValue = FromETag(clientETag);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new PreconditionFailedException(
"The resource has been modified since you last retrieved it. Fetch the latest version and retry.");
}
var newETag = ToETag(product.RowVersion);
ETagHelper.SetETag(Response, newETag);
return Ok(new { product.Id, product.Name, product.Price });
}
PATCH
Same idea, just update selected fields. ETag logic stays identical.
DELETE with If-Match (highly recommended)
Deletes are also “writes”, they can also overwrite someone else’s change, especially if the UI shows “Delete” after viewing old data.
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);
if (product is null) return NotFound();
var currentETag = ToETag(product.RowVersion);
ETagHelper.ValidateIfMatch(Request, currentETag);
db.Products.Remove(product);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new PreconditionFailedException(
"The resource has been modified since you last retrieved it. Fetch the latest version and retry.");
}
return NoContent();
}
Proper HTTP codes, make your API feel “professional”
You already throw:
-
PreconditionRequiredExceptionfor missingIf-Match(428) -
PreconditionFailedExceptionfor stale ETag (412)
Add an exception filter (or middleware) so this is consistent across the API.
public sealed class PreconditionRequiredException : Exception
{
public PreconditionRequiredException(string message) : base(message) { }
}
public sealed class PreconditionFailedException : Exception
{
public PreconditionFailedException(string message) : base(message) { }
}
public sealed class HttpPreconditionExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is PreconditionRequiredException ex428)
{
context.Result = new ObjectResult(new { error = ex428.Message })
{ StatusCode = StatusCodes.Status428PreconditionRequired };
context.ExceptionHandled = true;
return;
}
if (context.Exception is PreconditionFailedException ex412)
{
context.Result = new ObjectResult(new { error = ex412.Message })
{ StatusCode = StatusCodes.Status412PreconditionFailed };
context.ExceptionHandled = true;
}
}
}
Register:
builder.Services.AddControllers(o => o.Filters.Add<HttpPreconditionExceptionFilter>());
Client examples you can paste in docs
curl demo (GET, then conditional GET)
curl -i https://localhost:5001/api/products/10
# copy the ETag header value
curl -i https://localhost:5001/api/products/10 \
-H 'If-None-Match: "PASTE_ETAG_HERE"'
# 304 if unchanged
curl demo (safe update)
curl -i -X PUT https://localhost:5001/api/products/10 \
-H 'Content-Type: application/json' \
-H 'If-Match: "PASTE_ETAG_HERE"' \
-d '{"name":"New Name","price":9.99}'
If someone else updated it, you get 412 and you know you must refetch.
Lists and search endpoints, how to think about them
ETag on /api/products/10 is easy.
ETag on /api/products?category=1&page=2&sort=price is harder, because the “resource” is the result set, not one row.
If you still want it, the ETag must depend on:
- Query parameters
- The set of returned IDs
- A version indicator for each item (or a global “catalog version”)
A practical approach is to start with ETag only on single-resource endpoints, then add list ETags later when you have a clear versioning strategy.
Common pitfalls (quick checklist)
- Do not generate ETag from “now”. It must represent the resource version, not the response time.
- Always return the new ETag after successful updates, so clients can continue editing safely.
- For GET, use
AsNoTracking()to keep it cheap. - Enforce
If-Matchfor PUT, PATCH, DELETE, otherwise concurrency protection is optional and clients will forget it. - If your API is user-specific,
privatecache-control is the right call (your helper already does it).
Closing
With your ETagHelper plus EF Core concurrency, you get:
- Faster reads via
If-None-Matchand304 Not Modified - Safer writes via
If-Match,428when missing,412when stale - EF Core protects the database update with real optimistic concurrency
Top comments (2)
The one great constant in the technology industry is it's history of change and the speed of that change so no education is going to give anyone the skills they need for an entire career. You've got to have curiosity, you've got to have a lifetime of curiosity and a dedication to a lifetime of learning because the tools and technology that we use in this industry are always going to be changing
Exactly. Education gives you a foundation, but longevity comes from curiosity, strong fundamentals, and the discipline to keep learning as the industry evolves.