Skip to main content

Optimistic Concurrency Control

Overview

Optimistic concurrency assumes conflicts are rare. It allows concurrent access but detects conflicts when saving, letting you handle them appropriately.

Concurrency Token Setup

Using Timestamp/RowVersion

public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }

[Timestamp]
public byte[] RowVersion { get; set; } // Concurrency token
}

// Or with Fluent API
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();

Using Any Property as Concurrency Token

public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }

[ConcurrencyCheck]
public DateTime LastModified { get; set; }
}

// Or Fluent API
modelBuilder.Entity<Product>()
.Property(p => p.LastModified)
.IsConcurrencyToken();

How It Works

// User A loads product
var productA = await contextA.Products.FindAsync(1);
// RowVersion = 0x00000001

// User B loads same product
var productB = await contextB.Products.FindAsync(1);
// RowVersion = 0x00000001

// User A updates and saves
productA.Price = 99.99m;
await contextA.SaveChangesAsync();
// UPDATE Products SET Price = 99.99, RowVersion = 0x00000002
// WHERE Id = 1 AND RowVersion = 0x00000001
// RowVersion now 0x00000002

// User B tries to save
productB.Name = "Updated Name";
await contextB.SaveChangesAsync();
// UPDATE Products SET Name = 'Updated Name', RowVersion = 0x00000002
// WHERE Id = 1 AND RowVersion = 0x00000001 // Fails! RowVersion changed
// Throws DbUpdateConcurrencyException

Handling Concurrency Conflicts

Basic Handling

try
{
product.Price = 99.99m;
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine("Concurrency conflict detected!");
// Handle conflict
}

Client Wins Strategy

try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
// Get current database values
var databaseValues = await entry.GetDatabaseValuesAsync();

if (databaseValues == null)
{
// Entity was deleted
throw new Exception("Entity was deleted by another user");
}

// Client wins - overwrite database values
entry.OriginalValues.SetValues(databaseValues);

// Retry save
await context.SaveChangesAsync();
}
}

Database Wins Strategy

catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var databaseValues = await entry.GetDatabaseValuesAsync();

if (databaseValues != null)
{
// Database wins - refresh client values
entry.CurrentValues.SetValues(databaseValues);
}

// Don't retry - client gets latest values
}
}

Merge Strategy

catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
if (entry.Entity is Product product)
{
var databaseEntry = await entry.GetDatabaseValuesAsync();
var databaseProduct = (Product)databaseEntry.ToObject();

// Merge: Keep client's price, database's name
product.Name = databaseProduct.Name; // Database wins for Name
// product.Price already has client value (client wins for Price)

// Update original values to retry
entry.OriginalValues.SetValues(databaseEntry);

await context.SaveChangesAsync();
}
}
}

User Decision Strategy

catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var currentValues = entry.CurrentValues;
var databaseValues = await entry.GetDatabaseValuesAsync();
var originalValues = entry.OriginalValues;

// Show user the conflict
Console.WriteLine("Conflict detected:");
foreach (var property in currentValues.Properties)
{
var current = currentValues[property];
var database = databaseValues[property];
var original = originalValues[property];

if (!Equals(current, database))
{
Console.WriteLine($"{property.Name}:");
Console.WriteLine($" Your value: {current}");
Console.WriteLine($" Database value: {database}");
Console.WriteLine($" Original value: {original}");
}
}

// Let user choose
var choice = GetUserChoice(); // Your UI logic

if (choice == "mine")
{
entry.OriginalValues.SetValues(databaseValues);
}
else
{
entry.CurrentValues.SetValues(databaseValues);
}
}
}

Real-World Scenarios

E-Commerce: Stock Management

public class Product
{
public int Id { get; set; }
public int StockQuantity { get; set; }

[Timestamp]
public byte[] RowVersion { get; set; }
}

// Two users try to buy last item
public async Task<bool> PurchaseProduct(int productId, int quantity)
{
using var transaction = await context.Database.BeginTransactionAsync();

try
{
var product = await context.Products.FindAsync(productId);

if (product.StockQuantity < quantity)
{
return false; // Not enough stock
}

product.StockQuantity -= quantity;
await context.SaveChangesAsync();
await transaction.CommitAsync();

return true;
}
catch (DbUpdateConcurrencyException)
{
await transaction.RollbackAsync();

// Reload and retry
context.Entry(product).Reload();

if (product.StockQuantity >= quantity)
{
return await PurchaseProduct(productId, quantity); // Retry
}

return false; // Lost race
}
}

Banking: Account Balance

public class Account
{
public int Id { get; set; }
public decimal Balance { get; set; }

[Timestamp]
public byte[] RowVersion { get; set; }
}

public async Task<bool> Transfer(int fromId, int toId, decimal amount)
{
var maxRetries = 3;
var attempt = 0;

while (attempt < maxRetries)
{
try
{
var from = await context.Accounts.FindAsync(fromId);
var to = await context.Accounts.FindAsync(toId);

if (from.Balance < amount)
return false;

from.Balance -= amount;
to.Balance += amount;

await context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
attempt++;

// Reload both accounts
await context.Entry(from).ReloadAsync();
await context.Entry(to).ReloadAsync();

if (attempt == maxRetries)
throw; // Give up after retries
}
}

return false;
}

Testing Concurrency

[Fact]
public async Task ConcurrentUpdate_ThrowsConcurrencyException()
{
// Arrange
var product = new Product { Name = "Test", Price = 100 };
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();

// Create two contexts (simulate two users)
await using var context1 = CreateContext();
await using var context2 = CreateContext();

// Both load same entity
var product1 = await context1.Products.FindAsync(product.Id);
var product2 = await context2.Products.FindAsync(product.Id);

// User 1 updates
product1.Price = 120;
await context1.SaveChangesAsync();

// User 2 tries to update
product2.Price = 110;

// Assert
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
() => context2.SaveChangesAsync());
}

Performance Considerations

// RowVersion adds overhead to every UPDATE
// But prevents lost updates

// Bad: Loading just to update
var product = await context.Products.FindAsync(id);
product.Price = newPrice;
await context.SaveChangesAsync();
// Requires: SELECT + UPDATE

// Better: Update without loading (but no concurrency check)
await context.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Price, newPrice));
// Only UPDATE, but bypasses concurrency token!

// Best: Load only when needed
if (needsConcurrencyCheck)
{
var product = await context.Products.FindAsync(id);
product.Price = newPrice;
await context.SaveChangesAsync();
}
else
{
await context.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Price, newPrice));
}

Interview Questions

Q: What is optimistic concurrency?

A: Optimistic concurrency assumes conflicts are rare and allows concurrent access. It detects conflicts when saving using a concurrency token (usually a timestamp/rowversion), then lets you handle the conflict.

Q: How does a concurrency token work?

A: A concurrency token (like RowVersion) is included in the WHERE clause of UPDATE/DELETE. If another user changed the record, the token won't match and the operation fails, throwing DbUpdateConcurrencyException.

Q: What are common strategies for handling conflicts?

A:

  1. Client Wins: Overwrite database with client changes
  2. Database Wins: Discard client changes, use database values
  3. Merge: Combine changes based on rules
  4. User Decision: Show conflict and let user choose

Q: When should you use optimistic vs pessimistic concurrency?

A:

  • Optimistic: Most scenarios, when conflicts are rare, web applications
  • Pessimistic: High conflict scenarios, critical operations (use database locks)

Additional Resources