Imagine two users are editing the same customer record in an application. Both open the record at the same time. The first user changes the customer's phone number and saves it. A few minutes later, the second user saves an older copy of the record.
The second save may overwrite the first user's changes without showing any warning.
This problem is common in applications where multiple users can update the same data. In ASP.NET Core, you can use ETags and optimistic concurrency control to prevent these accidental overwrites.
An ETag helps the server identify a particular version of a resource. When a client sends an update, it can tell the server which version it originally received. If the data has changed since then, the server can reject the update instead of silently replacing someone else's work.
In this article, we will see how ETags work, how to use them in an ASP.NET Core API, and how to combine them with Entity Framework Core to handle concurrent updates safely.
What Is an ETag?
An ETag, or entity tag, is a value that identifies a particular version of a resource.
For example, an API might return a product like this:
HTTP/1.1 200 OK
ETag: "product-version-1"
Content-Type: application/jsonThe response body contains the product information. The ETag identifies the version of that information.
If the product changes, its ETag should also change. A client can then send the ETag back when updating the product.
ETags are commonly used for two purposes:
Caching: Helps clients determine whether a cached response is still valid.
Concurrency control: Helps prevent updates based on outdated data.
Here, we will focus on concurrency control.
How ETags Prevent Conflicting Updates
Suppose two users open the same product.
Both users receive the product with ETag
"version-1".User A changes the price and saves the product.
The server updates the product and its version.
User B tries to save changes using the old ETag.
The server detects that the version has changed and rejects User B's update.
The server can return 412 Precondition Failed because the condition supplied with the request is no longer valid.
The important point is that the server does not blindly accept the second update. The client can reload the latest data and decide how to handle the conflict.
Step 1: Create a Product Model with a Version Column
We will use Entity Framework Core with SQL Server. SQL Server supports a rowversion column that changes automatically when a row is updated.
This makes it useful for detecting concurrent changes.
Create a product model:
using System.ComponentModel.DataAnnotations;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = [];
}The [Timestamp] attribute tells Entity Framework Core to treat RowVersion as a concurrency token.
When SQL Server updates the row, it generates a new rowversion value. EF Core uses the original value when checking whether the update is still valid.
For SQL Server, the corresponding database column should be configured as rowversion. EF Core migrations can create this column from the model configuration.
Do not use the rowversion value as a timestamp that represents the date and time of an update. It is a version marker, not a clock.
Step 2: Return an ETag When Reading a Product
When the client requests a product, return its data along with an ETag.
[HttpGet("{id:int}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await _context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
return NotFound();
}
string etag = Convert.ToBase64String(
product.RowVersion);
Response.Headers.ETag = $"\"{etag}\"";
return Ok(new
{
product.Id,
product.Name,
product.Price
});
}This endpoint reads the product and creates an ETag from its rowversion.
The response might look like this:
HTTP/1.1 200 OK
ETag: "AAAAAAAAB9E="
Content-Type: application/jsonThe actual ETag value will depend on the database rowversion.
The client should save this ETag along with the product data. It will need the same value when sending an update.
Step 3: Require the ETag When Updating a Product
When a client updates a product, it should send the ETag it received earlier through the If-Match request header.
For example:
PUT /api/products/10
If-Match: "AAAAAAAAB9E="
Content-Type: application/jsonThe server can compare this value with the latest version of the product.
The following example shows the core update logic:
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateProduct(
int id,
UpdateProductRequest request)
{
string ifMatch = Request.Headers.IfMatch.ToString();
if (string.IsNullOrWhiteSpace(ifMatch))
{
return StatusCode(
StatusCodes.Status428PreconditionRequired,
"The If-Match header is required.");
}
var product = await _context.Products
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
return NotFound();
}
string currentETag =
$"\"{Convert.ToBase64String(product.RowVersion)}\"";
if (!string.Equals(
ifMatch,
currentETag,
StringComparison.Ordinal))
{
return StatusCode(
StatusCodes.Status412PreconditionFailed,
"The product has changed. Reload it before updating.");
}
product.Name = request.Name;
product.Price = request.Price;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return StatusCode(
StatusCodes.Status412PreconditionFailed,
"Another user updated the product. Reload and try again.");
}
return NoContent();
}
public class UpdateProductRequest
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Join the conversation! Your thoughts help the community grow.