Two users edit the same customer record simultaneously. User A updates the phone number, User B changes the address. User A saves first, then User B saves. Result: the phone number update disappears.
This is the classic lost update problem, and it happens more often than you think in production web applications. EF Core concurrency tokens solve this elegantly without the complexity of database locks.
The Lost Update Problem
Without concurrency control, this scenario plays out daily in production:
// User A loads customer
var customer = await context.Customers.FindAsync(customerId);
customer.Phone = "555-0123";
// User B loads the same customer (before A saves)
var customer2 = await context.Customers.FindAsync(customerId);
customer2.Address = "123 New Street";
// User A saves first
await context.SaveChangesAsync(); // Phone updated
// User B saves second
await context.SaveChangesAsync(); // Address updated, but phone reverted!
User B’s save overwrote User A’s phone number change because EF Core generated an UPDATE statement based on the original values User B loaded. The phone number silently reverted to its original value.
Concurrency Tokens to the Rescue
Concurrency tokens enable optimistic concurrency control. EF Core includes the token value in UPDATE and DELETE statements, ensuring the operation only succeeds if no one else modified the record:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Phone { get; set; }
public string Address { get; set; }
[Timestamp] // This creates a concurrency token
public byte[] RowVersion { get; set; }
}
Now when User B tries to save, EF Core generates:
UPDATE [Customers]
SET [Address] = @p0, [RowVersion] = @p1
WHERE [Id] = @p2 AND [RowVersion] = @p3
The WHERE
clause includes the original RowVersion value. If User A already saved and changed the RowVersion, this UPDATE affects 0 rows and EF Core throws DbUpdateConcurrencyException
.
SQL Server RowVersion Implementation
SQL Server’s rowversion
(formerly timestamp
) is perfect for concurrency tokens:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(entity =>
{
entity.Property(e => e.RowVersion)
.IsRowVersion() // Maps to SQL Server rowversion
.ValueGeneratedOnAddOrUpdate();
});
}
SQL Server automatically updates the rowversion whenever the row changes. You never set this value manually - the database handles it:
CREATE TABLE [Customers] (
[Id] int IDENTITY(1,1) NOT NULL,
[Name] nvarchar(max) NULL,
[Phone] nvarchar(max) NULL,
[Address] nvarchar(max) NULL,
[RowVersion] rowversion NOT NULL,
CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
Handling Concurrency Conflicts
When conflicts occur, you need to decide how to resolve them. Here are the common patterns:
Client Wins (Force Update)
Simply reload and retry the operation:
public async Task<bool> UpdateCustomerAsync(int customerId, CustomerUpdateDto dto)
{
var maxRetries = 3;
for (int retry = 0; retry < maxRetries; retry++)
{
try
{
var customer = await context.Customers.FindAsync(customerId);
customer.Name = dto.Name;
customer.Phone = dto.Phone;
customer.Address = dto.Address;
await context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
if (retry == maxRetries - 1) throw;
// Reload the entity with current database values
context.Entry(customer).Reload();
}
}
return false;
}
This approach overwrites any concurrent changes. Use it when the current user’s changes should always win.
Database Wins (Reject Update)
Show the current database values to the user:
public async Task<ConflictResult> UpdateCustomerWithConflictDetection(int customerId, CustomerUpdateDto dto)
{
try
{
var customer = await context.Customers.FindAsync(customerId);
customer.Name = dto.Name;
customer.Phone = dto.Phone;
customer.Address = dto.Address;
await context.SaveChangesAsync();
return new ConflictResult { Success = true };
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var currentValues = await entry.GetDatabaseValuesAsync();
return new ConflictResult
{
Success = false,
CurrentValues = currentValues.ToObject() as Customer,
OriginalValues = entry.OriginalValues.ToObject() as Customer,
ProposedValues = entry.CurrentValues.ToObject() as Customer
};
}
}
The user sees what changed and can decide how to proceed.
Smart Merge (Field-Level Resolution)
Automatically merge non-conflicting changes:
public async Task<MergeResult> SmartMergeCustomerAsync(int customerId, CustomerUpdateDto dto)
{
try
{
var customer = await context.Customers.FindAsync(customerId);
customer.Name = dto.Name;
customer.Phone = dto.Phone;
customer.Address = dto.Address;
await context.SaveChangesAsync();
return new MergeResult { Success = true };
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var currentValues = await entry.GetDatabaseValuesAsync();
var originalValues = entry.OriginalValues;
var proposedValues = entry.CurrentValues;
// Merge logic: if database value differs from original,
// keep database value, otherwise use proposed value
foreach (var property in entry.Metadata.GetProperties())
{
var currentValue = currentValues[property];
var originalValue = originalValues[property];
var proposedValue = proposedValues[property];
if (property.Name == nameof(Customer.RowVersion)) continue;
// If database changed this field, keep database value
if (!Equals(currentValue, originalValue))
{
proposedValues[property] = currentValue;
}
// Otherwise, use the proposed value
}
// Set original values to current database values
originalValues.SetValues(currentValues);
await context.SaveChangesAsync();
return new MergeResult { Success = true, WasMerged = true };
}
}
This automatically resolves conflicts when different users modify different fields.
Custom Concurrency Tokens
Sometimes you need more granular control than rowversion provides:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
[ConcurrencyCheck] // Custom concurrency token
public DateTime LastPriceUpdate { get; set; }
[ConcurrencyCheck] // Another concurrency token
public DateTime LastStockUpdate { get; set; }
}
Now price changes only conflict with other price changes, and stock changes only conflict with other stock changes:
// This will conflict if someone else changed the price
product.Price = 99.99m;
product.LastPriceUpdate = DateTime.UtcNow;
// This will conflict if someone else changed the stock
product.StockQuantity = 100;
product.LastStockUpdate = DateTime.UtcNow;
Performance Considerations
Include Tokens in Projections
When using projections for updates, include the concurrency token:
// Good: Includes RowVersion for concurrency checking
var customer = await context.Customers
.Where(c => c.Id == customerId)
.Select(c => new Customer
{
Id = c.Id,
Name = c.Name,
Phone = c.Phone,
RowVersion = c.RowVersion // Essential for concurrency
})
.SingleAsync();
Without the token, EF Core can’t perform concurrency checking.
Batch Operations with Concurrency
Concurrency tokens work with batch operations too:
public async Task<BatchUpdateResult> UpdateMultipleCustomersAsync(List<CustomerUpdateDto> updates)
{
var conflicts = new List<CustomerConflict>();
var succeeded = 0;
foreach (var update in updates)
{
try
{
var customer = await context.Customers.FindAsync(update.Id);
// Assume RowVersion is included in the DTO
context.Entry(customer).OriginalValues["RowVersion"] = update.OriginalRowVersion;
customer.Name = update.Name;
customer.Phone = update.Phone;
await context.SaveChangesAsync();
succeeded++;
}
catch (DbUpdateConcurrencyException ex)
{
conflicts.Add(new CustomerConflict
{
CustomerId = update.Id,
ConflictingFields = GetConflictingFields(ex)
});
}
}
return new BatchUpdateResult
{
SuccessCount = succeeded,
Conflicts = conflicts
};
}
API Design for Concurrency
Design your APIs to support concurrency tokens:
public class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Phone { get; set; }
public string Address { get; set; }
public string RowVersion { get; set; } // Base64-encoded byte array
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateCustomer(int id, CustomerDto dto)
{
if (string.IsNullOrEmpty(dto.RowVersion))
{
return BadRequest("RowVersion is required for concurrency checking");
}
try
{
var customer = await context.Customers.FindAsync(id);
// Set the original RowVersion for concurrency checking
context.Entry(customer).OriginalValues["RowVersion"] = Convert.FromBase64String(dto.RowVersion);
customer.Name = dto.Name;
customer.Phone = dto.Phone;
customer.Address = dto.Address;
await context.SaveChangesAsync();
return Ok(new CustomerDto
{
Id = customer.Id,
Name = customer.Name,
Phone = customer.Phone,
Address = customer.Address,
RowVersion = Convert.ToBase64String(customer.RowVersion)
});
}
catch (DbUpdateConcurrencyException)
{
return Conflict("The record has been modified by another user. Please refresh and try again.");
}
}
Pro Tip: I always include concurrency tokens in API responses, even for GET operations. Clients need the current token value to perform updates safely. Without it, you’re back to the lost update problem.
When to Use Concurrency Tokens
Use concurrency tokens when:
- Multiple users can edit the same data
- Data integrity is important
- Updates happen infrequently enough that conflicts are rare
- You can handle conflicts gracefully in your UI
Skip concurrency tokens when:
- Single-user applications
- Append-only data patterns
- High-frequency updates where conflicts would be constant
- Simple audit trail scenarios
Key Takeaway
Concurrency tokens prevent silent data loss in multi-user applications with minimal performance overhead. SQL Server’s rowversion makes implementation straightforward, and the conflict resolution patterns give you flexibility in how to handle the rare cases where conflicts occur.
The small upfront investment in concurrency control prevents much larger problems when multiple users start stepping on each other’s changes in production.