3 min readBy Muhammad Shahid
EF Core Optimistic Concurrency with RowVersion
Two Angular tabs, one encounter, last write wins. How I map SQL Server rowversion, return 409, and when RCSI is the wrong fix.
Part of EF Core
Quick answers
- How do I stop lost updates in EF Core?
- Map a SQL Server rowversion as a concurrency token. GET returns it. PUT sends it back. The second save throws DbUpdateConcurrencyException — map that to 409, not 500.
- Can I use DateTime instead of rowversion?
- You can mark LastModified as a concurrency token. The app must update it on every save. Two writes in the same millisecond can slip through. rowversion is the database’s job.
- Does snapshot isolation fix lost updates?
- No. RCSI stops readers blocking writers. Two PUTs still need a concurrency token or you keep last-write-wins.
Two clinicians had the same encounter open. Both patched status. The second save silently overwrote the first. That is a lost update, not a deadlock.
Optimistic concurrency puts a token on the row. SQL Server rowversion is the token I use. EF adds it to the WHERE of the UPDATE. Zero rows updated means someone else already saved. I map that to 409 with ProblemDetails so Angular can refresh.
This URL is the implementation. The interview prompt is EF Core interview questions. RCSI / snapshot isolation is the blocking article — it will not save you from two writers on the same encounter.
Map the token
public class Encounter
{
public Guid Id { get; set; }
public EncounterStatus Status { get; set; }
public byte[] RowVersion { get; set; } = [];
}
modelBuilder.Entity<Encounter>()
.Property(e => e.RowVersion)
.IsRowVersion();
[Timestamp] on byte[] is the attribute form. I prefer fluent so it shows up next to the rest of the mapping. SQL Server maintains the value. You do not set it in C# except to send back what you read.
GET then PUT
The Angular editor stores rowVersion from GET (Base64 in JSON). PUT sends it with the body. The API copies it onto the tracked entity before SaveChanges:
var encounter = await db.Encounters
.FirstAsync(e => e.Id == id, ct);
encounter.Status = body.Status;
db.Entry(encounter).Property(e => e.RowVersion).OriginalValue = body.RowVersion;
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new ConcurrencyConflictException(id);
}
The UPDATE looks like WHERE [Id] = @id AND [RowVersion] = @token. If clinic A already saved, clinic B’s token matches zero rows. EF throws. I do not return 500. ProblemDetails with 409 is the contract the interceptor and the SPA already understand.
Prove it with two tests against real SQL (Testcontainers). In-memory provider is a liar for rowversion.
When not to use pessimistic locks
UPDLOCK for the lifetime of an Angular tab means a clinician went to coffee and locked the encounter. I do not do that on SPA products. Optimistic + 409 + “reload” is the product behavior.
Writer/writer deadlocks on different rows under read committed are a locking problem — RCSI. Writer/writer on the same row without a token is last-write-wins — this page.
DateTime tokens
.IsConcurrencyToken() on LastModified works if every code path stamps it. Clock resolution and a missed assignment are why I still want rowversion on Encounter, Order, and FeeSchedule headers. Use a datetime token only when you cannot add a column this release.
If two admin tabs keep overwriting fee headers, contact me. The GET DTO plus whether RowVersion is on the PUT is the review.