Imagine two employees open the same customer record in an application. One changes the customer's phone number, while the other updates the address. Both save their changes, but the second request overwrites the first because it was based on an older copy of the record.
This is called a lost update. It happens when an application accepts a change without checking whether the underlying data has changed since the user originally loaded it.
ASP.NET Core applications can prevent this problem by using HTTP entity tags, commonly called ETags, with the If-Match request header. The client sends the version it received earlier, and the server checks that version before saving an update.
If the record has changed, the server rejects the stale request with HTTP 412 Precondition Failed. The client can then reload the latest data and let the user resolve the conflict.
What Is an ETag?
An ETag is an HTTP response header that identifies a particular version of a resource representation. A client can save the ETag it receives and send it back when making a conditional request.
For example, a GET request might return:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "customer-42-v7"
{
"id": 42,
"name": "Anita Sharma",
"city": "Patna"
}
The ETag in this example identifies version 7 of the customer resource. The value is illustrative. Your application must generate a version identifier that changes whenever the relevant resource representation changes.
When the client wants to update the customer, it sends the ETag in the If-Match header:
PUT /api/customers/42
If-Match: "customer-42-v7"
Content-Type: application/json
{
"name": "Anita Sharma",
"city": "Delhi"
}
The server checks whether the current resource still matches that version.
If another user has already updated the customer, the ETag no longer matches. The server rejects the request instead of silently overwriting the newer data.
How ETags Prevent Lost Updates
Consider two users, A and B, who read the same customer record.
Both users receive ETag "customer-42-v7".
User A updates the city and the server stores version 8.
User B submits an update with the old ETag.
The server detects that version 7 is no longer current.
The server returns 412 Precondition Failed without applying User B's update.
This is a form of optimistic concurrency control. The application allows users to work independently and checks for conflicts when a change is submitted.
ETags do not lock the record while users edit it. They prevent a stale update from succeeding when the server correctly checks the condition at the time of the write.
1. Create a Resource With a Version Token
For a database-backed ASP.NET Core application, the resource needs a version identifier that changes when the data changes.
With Entity Framework Core and SQL Server, a rowversion column is one option.
using System.ComponentModel.DataAnnotations;
public sealed class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
[Timestamp]
public byte[] Version { get; set; } = [];
}
The Timestamp attribute configures Version as a concurrency token. In SQL Server, EF Core can use the database-generated rowversion value to detect whether another operation has changed the row.
The database generates a new token when the row changes. EF Core includes the original token in its update condition. If another request has modified the row, the update affects no matching row and EF Core throws DbUpdateConcurrencyException.
This example is specific to SQL Server. Other database providers may require an application-managed concurrency token or another supported mechanism.
2. Configure the Database Context
The following configuration makes the concurrency behavior explicit:
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.Property(customer => customer.Version)
.IsRowVersion();
}
}
The IsRowVersion() configuration tells EF Core that the database generates the concurrency token.
Create and apply a migration so the database schema contains the required column:
dotnet ef migrations add AddCustomerVersion
dotnet ef database update
For a production application, apply schema changes through the normal deployment process rather than running database updates automatically at application startup.
The database token is the foundation of concurrency detection. The HTTP ETag is the representation of that version that the client can send back to the API.
3. Return an ETag From a GET Endpoint
The API needs to expose the current version to clients.
The following Minimal API endpoint reads a customer and returns its ETag:
app.MapGet("/api/customers/{id:int}", async (
int id,
AppDbContext db,
HttpContext context) =>
{
var customer = await db.Customers
.AsNoTracking()
.SingleOrDefaultAsync(customer => customer.Id == id);
if (customer is null)
{
return Results.NotFound();
}
var etag = $"\"customer-{customer.Id}-{Convert.ToBase64String(customer.Version)}\"";
context.Response.Headers.ETag = etag;
return Results.Ok(new
{
customer.Id,
customer.Name,
customer.City
});
});
The response includes the customer data and an ETag derived from the database version.
The quotation marks are part of the HTTP entity-tag syntax. This example uses a strong ETag, without the W/ prefix.
For a resource with multiple representations, make sure the ETag identifies the representation whose changes must be detected. If different content negotiation or serialization options can produce different representations, design the validator accordingly.
4. Check If-Match Before Updating the Customer
The update endpoint must reject requests that do not match the current ETag. It must also rely on database concurrency detection to handle two updates that arrive at nearly the same time.
The following example demonstrates both checks:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
public sealed record UpdateCustomerRequest(
string Name,
string City);
app.MapPut("/api/customers/{id:int}", async (
int id,
UpdateCustomerRequest request,
AppDbContext db,
HttpContext context) =>
{
var ifMatch = context.Request.Headers.IfMatch;
if (ifMatch.Count != 1 || string.IsNullOrWhiteSpace(ifMatch[0]))
{
return Results.StatusCode(
StatusCodes.Status428PreconditionRequired);
}
var customer = await db.Customers
.SingleOrDefaultAsync(customer => customer.Id == id);
if (customer is null)
{
return Results.NotFound();
}
var currentETag =
$"\"customer-{customer.Id}-{Convert.ToBase64String(customer.Version)}\"";
if (ifMatch[0] != currentETag)
{
return Results.StatusCode(
StatusCodes.Status412PreconditionFailed);
}
customer.Name = request.Name;
customer.City = request.City;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return Results.StatusCode(
StatusCodes.Status412PreconditionFailed);
}
var newETag =
$"\"customer-{customer.Id}-{Convert.ToBase64String(customer.Version)}\"";
context.Response.Headers.ETag = newETag;
return Results.NoContent();
});
This endpoint illustrates the main flow, but it is not a complete implementation of the HTTP If-Match grammar. A production API should parse entity-tag lists correctly, support or deliberately reject the wildcard form, and use strong comparison as required by HTTP semantics.
The explicit ETag comparison detects changes that occurred before the record was read. The EF Core concurrency token catches a second update that wins the race after this check but before SaveChangesAsync() completes.
This second check is important. Comparing an ETag in application code without database concurrency protection leaves a window in which another request can modify the record.
The example returns 428 Precondition Required when the client does not provide an ETag. This is an application policy that requires clients to send a precondition. HTTP 428 is defined for requests that must be conditional.
5. Handle a 412 Response on the Client
A client should not automatically retry a failed update using the same ETag. That version is already stale.
Instead, the application should reload the latest resource and let the user decide what to do with the conflicting changes.
A simple JavaScript client can detect the conflict:
async function updateCustomer(id, customer, etag) {
const response = await fetch(`/api/customers/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"If-Match": etag
},
body: JSON.stringify(customer)
});
if (response.status === 412) {
throw new Error(
"This customer was changed by another user. Reload the record before saving."
);
}
if (!response.ok) {
throw new Error(`Update failed: ${response.status}`);
}
return response.headers.get("ETag");
}
The client must keep the ETag received from the GET response and send it with the update. The API should return the new ETag after a successful update if the client needs to make further conditional changes.
For a cross-origin API, configure CORS to expose the ETag response header and allow the If-Match request header. Otherwise, browser JavaScript may be unable to read the ETag or send the required header.
Common Mistakes to Avoid
Checking the ETag only in application code
A record can change between the comparison and the database update. Use a database concurrency token or an equivalent atomic conditional update.
Generating an ETag that never changes
A static ETag does not detect updates. Derive it from a version token or a reliable representation hash.
Treating a stale update as a server error
A concurrency conflict is normally a precondition failure, not an internal server error. Return a suitable status code and help the client recover.
Automatically retrying with the same ETag
The original ETag is stale. Reload the resource, review the latest values, and submit a new request with the current ETag.
Assuming every database supports rowversion
SQL Server provides a database-generated rowversion mechanism. Other database systems may require different concurrency-token strategies.
Best Practices for Production APIs
Generate strong ETags that change when the relevant resource version changes.
Require If-Match for updates where lost changes would be harmful.
Enforce concurrency at the database level as well as at the HTTP boundary.
Return 412 Precondition Failed when the supplied version is stale.
Return 428 Precondition Required if the API requires a precondition and the client omits it.
Provide a clear conflict-resolution flow in the user interface.
Test concurrent updates using separate requests and separate database contexts.
Avoid exposing internal database paths or implementation details in error responses.
Ensure ETag handling is correct when the API supports multiple representations or content negotiation.
Advantages and Disadvantages
Advantages | Disadvantages |
|---|
Prevents stale clients from silently overwriting newer data | Requires additional version and request-header handling |
Does not require locking a record while a user edits it | Clients must handle conflicts and reload data |
Works with standard HTTP conditional requests | Database concurrency still needs correct implementation |
Helps APIs support multiple independent clients | Incorrect ETag generation can undermine conflict detection |
Troubleshooting Common Problems
Every update returns 412
Check whether the client is sending the exact current ETag, including its quotation marks. Confirm that the API generates the same token format when reading and updating the resource.
Concurrent updates still overwrite data
Verify that the database update checks the original concurrency token. An application-level comparison alone is not enough.
The client cannot read the ETag
For cross-origin requests, configure CORS to expose the ETag response header. Also confirm that the API actually returns the header on successful GET responses.
EF Core throws DbUpdateConcurrencyException
Another update may have changed the row after it was loaded. Return a conflict response and let the client reload the current state instead of blindly retrying the same values.
Summary
ETags and the If-Match header provide a standard HTTP approach to preventing lost updates in ASP.NET Core APIs. The client sends the version it originally read, and the server checks that version before accepting a change.
For reliable protection, combine ETag validation with database-level concurrency detection. This closes the race between checking the version and saving the record.
When a conflict occurs, return 412 Precondition Failed and let the client reload or resolve the newer data. This approach protects user changes without requiring the application to lock records throughout an editing session.
Join the conversation! Your thoughts help the community grow.