Imagine two users opening the same customer record in a web application. Both users see the same address. The first user updates the address and saves the changes. A few minutes later, the second user saves an older version of the record.
If the application accepts both updates without checking whether the record has changed, the second user's request can overwrite the first user's work.
This problem is known as a lost update. It often occurs in applications where multiple users can edit the same data, such as customer management systems, inventory applications, document editors, and administrative dashboards.
ASP.NET Core APIs can help prevent this problem by using HTTP ETags and conditional requests. An ETag identifies a particular version of a resource. When a client sends an update, the server can check whether the resource still matches the version the client originally received.
In this article, we will see how ETags work, how to implement them in an ASP.NET Core API, and how to handle conflicting updates without losing user changes.
What Is an ETag?
ETag stands for entity tag. It is an HTTP header that identifies a particular version of a resource.
For example, an API might return the following response when a client requests a customer record:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "customer-42-v3"
{
"id": 42,
"name": "Rahul Sharma",
"city": "Patna"
}
The ETag value, "customer-42-v3", represents the version of the customer record returned by the server.
The client can store this value and send it back when updating the record.
PUT /api/customers/42
If-Match: "customer-42-v3"
Content-Type: application/json
{
"name": "Rahul Sharma",
"city": "Delhi"
}
The If-Match header tells the server to perform the update only if the current resource matches the supplied ETag.
If another user has already changed the record, the ETag will no longer match. The server should reject the update instead of silently overwriting the newer data.
For this approach to work correctly, the server must generate a new ETag whenever the relevant resource changes.
How ETags Prevent Lost Updates
Consider the following sequence:
Alice requests a customer record and receives ETag "v1".
Bob requests the same record and also receives ETag "v1".
Alice updates the record using If-Match: "v1".
The server saves Alice's changes and generates ETag "v2".
Bob tries to update the record using the old ETag "v1".
The server detects the mismatch and rejects Bob's update.
Bob's changes are not automatically discarded from his screen. Instead, the API tells his application that the record has changed and that the user needs to review the latest version before trying again.
This is an example of optimistic concurrency control. The application allows users to work without locking the record, then checks for conflicts when they save.
ETags vs. Database Row Versioning
ETags and database concurrency tokens solve related problems, but they serve different purposes.
Feature | HTTP ETag | Database concurrency token |
|---|
Main purpose | Identify a resource version in HTTP requests | Detect changes during database updates |
Used by | API clients and servers | Database access code |
Typical mechanism | Version string or content-derived value | Row version, timestamp-like token, or version number |
Prevents lost updates by itself | Only if the server enforces conditional requests correctly | Only if the database update checks the original token |
Can be combined | Yes | Yes |
For a reliable implementation, use an ETag at the HTTP boundary and a concurrency token in the database when appropriate. The HTTP check protects the API contract, while the database check protects against concurrent updates that occur between reading a record and saving it.
Step 1: Add a Concurrency Token to the Entity
Entity Framework Core supports optimistic concurrency through concurrency tokens. In SQL Server, a rowversion column is a common choice.
Create a customer entity:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public byte[] RowVersion { get; set; } = [];
}
Configure the RowVersion property in your database context:
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.Property(customer => customer.RowVersion)
.IsRowVersion();
}
}
The IsRowVersion() configuration tells EF Core to use the property as a database-generated concurrency token for providers that support this behavior.
With SQL Server, the database updates the rowversion value when the row changes. EF Core includes the original version in its update condition. If another operation changes the row first, the update affects no matching row, and EF Core reports a concurrency conflict.
For SQL Server, create the corresponding database migration and apply it. Other database providers may require a different concurrency-token strategy.
Step 2: Return the ETag from the API
The API should include an ETag whenever it returns the customer representation.
The following helper converts the database row version into a quoted ETag value:
private static string CreateETag(byte[] rowVersion)
{
return $"\"{Convert.ToBase64String(rowVersion)}\"";
}
Base64 encoding makes the binary version suitable for use in an HTTP header. The quotation marks follow the standard format for an entity tag.
Now return the customer with the ETag header:
[HttpGet("{id:int}")]
public async Task<IActionResult> GetCustomer(int id)
{
var customer = await _dbContext.Customers.FindAsync(id);
if (customer is null)
{
return NotFound();
}
Response.Headers.ETag = CreateETag(customer.RowVersion);
return Ok(new
{
customer.Id,
customer.Name,
customer.City
});
}
Here, _dbContext represents the injected AppDbContext.
The response body contains the customer data, while the ETag header identifies the database version associated with that data. The client should retain the ETag and send it with the next update.
Step 3: Require If-Match When Updating the Customer
The update endpoint must read the client's ETag, compare it with the current database version, and save the changes only when the version matches.
The following example shows the main logic:
public record UpdateCustomerRequest(
string Name,
string City);
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateCustomer(
int id,
UpdateCustomerRequest request,
CancellationToken cancellationToken)
{
if (!Request.Headers.TryGetValue(
"If-Match", out var ifMatchValues))
{
return StatusCode(
StatusCodes.Status428PreconditionRequired,
"The If-Match header is required.");
}
if (ifMatchValues.Count != 1 ||
string.IsNullOrWhiteSpace(ifMatchValues[0]))
{
return BadRequest("Provide one valid If-Match value.");
}
var customer = await _dbContext.Customers
.SingleOrDefaultAsync(
customer => customer.Id == id,
cancellationToken);
if (customer is null)
{
return NotFound();
}
string currentETag = CreateETag(customer.RowVersion);
if (!string.Equals(
ifMatchValues[0],
currentETag,
StringComparison.Ordinal))
{
return StatusCode(
StatusCodes.Status412PreconditionFailed,
"The customer has changed. Reload the latest version.");
}
customer.Name = request.Name;
customer.City = request.City;
try
{
await _dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
return StatusCode(
StatusCodes.Status412PreconditionFailed,
"The customer changed during the update. Reload and try again.");
}
Response.Headers.ETag = CreateETag(customer.RowVersion);
return Ok(new
{
customer.Id,
customer.Name,
customer.City
});
}
Why both ETag checks are necessary
The first check compares the client's ETag with the version currently loaded from the database. If the client is already working with an outdated version, the API rejects the request before changing the entity.
However, another request could update the database after this comparison but before SaveChangesAsync() completes.
The database concurrency token handles this second case. EF Core checks the original row version when issuing the update. If the row has changed, it throws DbUpdateConcurrencyException.
The API converts that exception into HTTP 412 Precondition Failed, telling the client that its condition is no longer valid.
This second check closes an important race condition that a simple ETag comparison in application code cannot prevent.
The example uses a simplified If-Match implementation that accepts one exact ETag. HTTP also supports wildcard and multiple-tag forms of If-Match; an API that needs full HTTP conditional-request support should parse and handle those forms according to the protocol.
Step 4: Handle the Response in the Client
The client must save the ETag received from the GET request and include it in the update request.
For example, a JavaScript client can do the following:
const response = await fetch("/api/customers/42");
if (!response.ok) {
throw new Error("Unable to load customer.");
}
const customer = await response.json();
const etag = response.headers.get("ETag");
const updateResponse = await fetch("/api/customers/42", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"If-Match": etag
},
body: JSON.stringify({
name: customer.name,
city: "Delhi"
})
});
if (updateResponse.status === 412) {
console.log("The customer changed. Reload before saving.");
} else if (!updateResponse.ok) {
throw new Error("Unable to update customer.");
}
In a real application, the user interface should handle a conflict more carefully than simply logging a message. It can show the latest server data, preserve the user's unsaved changes, and let the user decide how to combine the two versions.
The client should also check that the ETag exists before sending the update.
Understanding the HTTP Status Codes
Choosing the correct status code helps clients understand why an update failed.
Status code | Meaning | When to use it |
|---|
200 OK
| The request succeeded | The update completed and the API returns the updated resource |
404 Not Found
| The resource does not exist | The customer ID cannot be found |
400 Bad Request
| The request is malformed | The ETag header is empty or invalid under the API's rules |
428 Precondition Required
| A required condition is missing | The API requires If-Match, but the client did not send it |
412 Precondition Failed
| The supplied condition is no longer satisfied | The ETag does not match the current resource version |
A consistent status-code strategy makes it easier for frontend applications and other API clients to handle conflicts correctly.
Common Mistakes to Avoid
Generating an ETag that never changes
If the server always returns the same ETag, the client cannot detect newer versions. Generate the ETag from a reliable version token and ensure that every relevant update changes that token.
Checking the ETag without database concurrency control
A comparison performed before saving does not protect against changes that happen immediately afterward. Use a database concurrency token or an equivalent atomic conditional update.
Returning a new ETag before the database update succeeds
The ETag must represent the saved version, not an expected version. Return it only after the update succeeds.
Overwriting the ETag in the client
If the client replaces its stored ETag with an outdated value or ignores the header, concurrency protection may fail. Keep the version associated with the data the user is editing.
Treating a conflict as an ordinary failure
A concurrency conflict does not necessarily mean the user made a mistake. The client should help the user reload, compare, or merge changes instead of silently retrying the same outdated request.
Best Practices for Using ETags in ASP.NET Core
Use database concurrency tokens when multiple requests can update the same record.
Require If-Match for update operations where overwriting another user's changes is unacceptable.
Return the updated ETag after a successful write so the client can use it for the next operation.
Keep API response versions consistent with the representation being protected.
Return 412 Precondition Failed when an existing ETag no longer matches.
Return 428 Precondition Required when your API requires a conditional update but the client omits the precondition.
Preserve unsaved user changes when handling conflicts in the user interface.
Test concurrent updates using two requests that start with the same ETag.
Remember that ETags are not authorization tokens. Continue to enforce authentication and resource-level authorization.
If using an ETag based on a representation's content, ensure that changes to the relevant representation produce a different validator. For database-backed updates, a concurrency token is often easier to use consistently.
Advantages and Disadvantages of ETags
Advantages
Prevents silent overwriting of newer changes when the server enforces conditional updates correctly.
Does not require locking a record while a user edits it.
Works through standard HTTP headers.
Helps REST APIs communicate resource-version conflicts clearly.
Can be combined with database concurrency tokens for stronger protection.
Disadvantages
Requires the server and client to manage ETags correctly.
Adds concurrency-handling logic to update endpoints.
Requires a conflict-resolution strategy in the user interface.
Needs careful implementation when resources include related data or multiple database records.
Does not automatically merge conflicting changes.
Summary
ETags provide a practical way to prevent users from overwriting each other's changes in ASP.NET Core applications. The server returns an ETag with the resource, and the client sends that value through the If-Match header when updating it.
The server checks whether the version still matches and rejects outdated requests with 412 Precondition Failed. For database-backed applications, combining ETags with EF Core concurrency tokens protects against conflicts that occur between the initial check and the database update.
The key is to treat concurrency as part of the API design. Validate the client's version, let the database detect simultaneous updates, and give users a clear way to resolve conflicts without losing their work.
Join the conversation! Your thoughts help the community grow.