Check-then-act and read-modify-write races, non-thread-safe collections, async interleaving, lost updates in databases, and detection and fixes.
A race condition is a bug where the result depends on the timing of concurrent operations. Classic forms are read-modify-write (count++ from two threads), check-then-act (if (!dict.ContainsKey(k)) dict.Add(k, v)), and unsafe publication. In .NET they also appear with non-thread-safe collections (a shared Dictionary or List corrupted under concurrent writes), in async code that interleaves at await points even on one thread, and in databases as lost updates when two requests read, modify and save the same row. Fixes: atomic operations, locks, concurrent collections, immutability, single-writer designs, and in databases optimistic concurrency tokens, unique constraints and idempotency.
"A race condition is a bug where the outcome depends on the timing of concurrent operations. Usually it's two actors reading and writing shared state without the operations being atomic as a whole. The classic shapes are read-modify-write (
count++from two threads loses updates), check-then-act (if (!dict.ContainsKey(k)) dict.Add(k, v)lets two threads both pass the check), and unsafe initialisation or publication. In .NET I also see three variants in production. Non-thread-safe collections like a sharedDictionaryorListget corrupted under concurrent writes. Async interleaving atawaitpoints can race even with no extra threads. And database lost updates happen when two requests read the same row, modify it and save. My fixes, in order of preference: design the sharing away (immutability, a single owner), push the invariant into something that guarantees atomicity (Interlocked, a concurrent collection, a unique constraint, an atomic SQLUPDATE, an optimistic concurrency token), and only then use locks. Races rarely show up in unit tests, so I find them with stress tests, tools like Coyote, review of shared mutable state, and production signals like impossible states."
| Pattern | Example | Fix |
|---|---|---|
| Read-modify-write | _total += x; |
Interlocked.Add, lock |
| Check-then-act (memory) | if (!cache.ContainsKey(k)) cache[k] = Load(k); |
ConcurrentDictionary.GetOrAdd (+ Lazy<T>), lock |
| Check-then-act (DB) | if (!db.Bookings.Any(...)) db.Add(booking); |
Unique constraint; conditional atomic update |
| Lazy initialisation | Two threads create the "singleton" | Lazy<T>, LazyInitializer |
| Unsafe publication | Flag set before data is visible | Volatile.Write, immutable objects, locks |
| Shared non-thread-safe collection | List<T>.Add from many threads |
Concurrent/immutable collection, per-thread results merged |
| Async interleaving | Double-click triggers two concurrent saves | Disable re-entry, SemaphoreSlim, idempotency |
| DB lost update | Two requests read stock 5, both write 4 | Concurrency token, UPDATE ... SET Qty = Qty - 1 WHERE Qty > 0 |
| Duplicate message processing | At-least-once delivery | Idempotency key with a unique index |
| Time-of-check vs time-of-use (files) | if (File.Exists(p)) File.Delete(p) |
Just attempt the operation and handle failure |
| Collection | Concurrent writes can cause |
|---|---|
Dictionary<K,V> |
Lost entries, InvalidOperationException ("concurrent operationsβ¦"), corrupted buckets, historically infinite loops at 100% CPU |
List<T> |
Lost items, IndexOutOfRangeException, nulls in the array |
HashSet<T>, Queue<T> |
Same class of corruption |
"It's mostly reads" isn't safe: a single concurrent writer is enough.
UI click #1: await SaveAsync() ββ suspended while the HTTP call is in flight
UI click #2: await SaveAsync() ββ runs before #1 finishes β two orders created
Server side: two requests for the same user interleave around awaits that read and write shared in-memory state (caches, session-like objects). Fixes: guard re-entry, per-key SemaphoreSlim, idempotency keys.
| Technique | How it prevents the race | Trade-off |
|---|---|---|
Atomic conditional update (ExecuteUpdateAsync in EF Core 7+) |
One statement checks and changes | Logic in SQL/predicate |
Optimistic concurrency ([Timestamp] / rowversion, IsConcurrencyToken) |
Save fails with DbUpdateConcurrencyException if the row changed |
Retry/merge logic |
| Unique constraint | DB rejects the second insert | Handle the duplicate-key error |
Pessimistic locking (UPDLOCK, SELECT ... FOR UPDATE) |
Serialises access to the row | Contention, deadlock risk |
| Higher isolation (serializable, snapshot with conflict detection) | DB detects conflicts | Cost, retries |
Read committed (SQL Server's default) does not prevent lost updates.
| Method | Notes |
|---|---|
| Code review for shared mutable state | Statics, singletons, caches, fields in services |
| Stress tests | Many concurrent workers, randomised delays, many iterations |
| Microsoft Coyote | Systematic exploration of async interleavings |
| Load tests on hot entities | 100 clients fighting over one seat/account |
| Production invariants | Alerts on impossible states (negative stock, duplicate bookings) |
| Arm64 testing | Exposes memory-ordering races hidden on x64 |
During a popular on-sale, our [ticketing site] had [20,000] users online and double-booked [37] seats. The booking code loaded the seat, checked HolderId == null, set the holder and saved, a check-then-act across two round trips, and logs showed two requests passing the check within milliseconds. I moved the invariant into the database: a unique constraint on (EventId, SeatId) in bookings, and an atomic conditional update (UPDATE Seats SET HolderId = @c WHERE Id = @s AND HolderId IS NULL, via ExecuteUpdateAsync), returning 409 "seat taken" when zero rows changed. I added a load test where [100] clients book the same seat at once and assert exactly one success. Double bookings went to zero in the next on-sale.
Checks the ability to recognise and eliminate races across layers.
Green flags
Red flags
lock.| Design | How it removes races |
|---|---|
| Immutable data + atomic reference swap | Readers never see partial updates; writers replace whole objects |
Single owner (actor, one Channel<T> reader per key) |
Only one piece of code mutates the state |
| Partition by key | Each key handled by one worker; no shared state across keys |
| Database as the source of truth | Constraints and atomic statements enforce invariants for all instances |
| Idempotency keys | Duplicates become no-ops |
ConcurrentDictionary caveats// Factory can run more than once under contention; only one result is stored.
var value = cache.GetOrAdd(key, k => ExpensiveLoad(k));
// Run the factory at most once per key:
var lazy = cache2.GetOrAdd(key, k => new Lazy<Data>(() => ExpensiveLoad(k), LazyThreadSafetyMode.ExecutionAndPublication));
var data = lazy.Value;
// Compound operations are still races:
if (cache.TryGetValue(k, out var v)) cache[k] = v + 1; // RACE
cache.AddOrUpdate(k, 1, (_, old) => old + 1); // OK (update delegate may be retried)
for (int attempt = 0; attempt < 3; attempt++)
{
var product = await db.Products.SingleAsync(p => p.Id == id, ct);
if (product.Stock < qty) return Results.Conflict("Out of stock");
product.Stock -= qty;
try
{
await db.SaveChangesAsync(ct); // WHERE Id = @id AND RowVersion = @original
return Results.Ok();
}
catch (DbUpdateConcurrencyException)
{
db.ChangeTracker.Clear(); // reload and retry
}
}
return Results.Conflict("Please retry");
For simple counters, the atomic update is simpler and has no retries:
int rows = await db.Products
.Where(p => p.Id == id && p.Stock >= qty)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - qty), ct);
if (rows == 0) return Results.Conflict("Out of stock");
[Fact]
public async Task Only_one_booking_wins()
{
var tasks = Enumerable.Range(0, 100).Select(c => client.PostAsync($"/seats/42/book?customer={c}", null));
var responses = await Task.WhenAll(tasks);
Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.OK));
Assert.Equal(99, responses.Count(r => r.StatusCode == HttpStatusCode.Conflict));
}
| Version | Change |
|---|---|
| .NET Framework 4.0 | Concurrent collections, Lazy<T> |
| .NET Core 3.0+ | Dictionary throws on detected concurrent modification in more cases instead of looping |
| EF Core 7 | ExecuteUpdateAsync / ExecuteDeleteAsync |
| .NET 8 | Frozen collections for read-only shared data |
| .NET 10 | Same primitives |
Dictionary version and run it repeatedly; watch for exceptions or wrong counts.List<int> from 8 tasks and print Count.[Timestamp] and save the same entity from two DbContexts; catch DbUpdateConcurrencyException.using System.Collections.Concurrent;
// 1. Check-then-act race and its fix.
var unsafeCache = new Dictionary<string, int>();
var safeCache = new ConcurrentDictionary<string, Lazy<int>>();
int loads = 0;
Parallel.For(0, 1_000, i =>
{
string key = $"k{i % 10}";
// Race: two threads can both miss and both load (and corrupt the Dictionary).
// if (!unsafeCache.ContainsKey(key)) unsafeCache[key] = Load(key);
// Safe: one Lazy per key, so Load runs once per key even under contention.
_ = safeCache.GetOrAdd(key, k => new Lazy<int>(() => Load(k))).Value;
});
Console.WriteLine($"Loads: {loads}"); // 10
int Load(string key)
{
Interlocked.Increment(ref loads);
return key.Length;
}
// 2. Database lost update: atomic statement instead of read-modify-write (EF Core 7+).
// int rows = await db.Seats
// .Where(s => s.Id == seatId && s.HolderId == null)
// .ExecuteUpdateAsync(s => s.SetProperty(x => x.HolderId, customerId), ct);
// if (rows == 0) return Results.Conflict("Seat already taken");
// 3. Optimistic concurrency token on an entity.
public sealed class Seat
{
public int Id { get; set; }
public int? HolderId { get; set; }
[System.ComponentModel.DataAnnotations.Timestamp]
public byte[] RowVersion { get; set; } = [];
}
Dictionary/List across threads "because it's mostly reads"What happens: a static dictionary is read by requests and occasionally written. Why it is wrong: a single concurrent write can corrupt it. Do instead: concurrent or immutable collections, or lock every access.
What happens: if (!exists) insert across two round trips.
Why it is wrong: two requests pass the check simultaneously.
Do instead: unique constraints or atomic conditional updates.
What happens: UI handlers or per-user server flows run concurrently around awaits.
Why it is wrong: code interleaves at every await.
Do instead: re-entry guards, per-key SemaphoreSlim, idempotency.
GetOrAdd factories to run onceWhat happens: an expensive or side-effecting factory runs twice.
Why it is wrong: factories can run multiple times under contention.
Do instead: store Lazy<T> values.
What happens: TryGetValue then indexer set on ConcurrentDictionary.
Why it is wrong: each call is atomic; the combination isn't.
Do instead: AddOrUpdate, GetOrAdd, or a lock.
What happens: "we're in a transaction, so it's safe". Why it is wrong: read committed doesn't stop two transactions overwriting each other. Do instead: concurrency tokens, atomic updates or appropriate locking.
DbUpdateConcurrencyException and ignoring itWhat happens: the conflict is logged and the request returns success. Why it is wrong: the user's change silently didn't happen. Do instead: retry with fresh data, merge, or return 409.
What happens: lock around booking code in a service running 4 replicas.
Why it is wrong: locks are per process; other instances still race.
Do instead: database guarantees or distributed coordination.
What happens: stepping through code to find the race. Why it is wrong: the debugger changes timing and hides it. Do instead: stress tests, logging with correlation ids, Coyote.
What happens: races are only discovered by customers. Why it is wrong: data damage accumulates silently. Do instead: alerts and periodic checks for impossible states (negative balances, duplicates).
I look for races wherever two actors share mutable state, and the database is the most common place in web systems. The strongest fixes move the invariant to something that guarantees it atomically: a constraint, an atomic statement, or a single owner.
| Shared state | Strongest fix |
|---|---|
| In-memory counter | Interlocked |
| In-memory cache | ConcurrentDictionary with Lazy<T> |
| Row updated by many requests | Concurrency token or atomic UPDATE |
| "Only one" business rule | Unique constraint |
| Message processing | Idempotency key with a unique index |
Interlocked provides atomic operations on shared variables: Increment, Decrement, Add, Exchange, CompareExchange, Read (for 64-bit values on 32-bit platforms), and And/Or (.NET 5+). Each is a single indivisible hardware operation and a full memory fence. CompareExchange(ref x, newValue, expected) writes only if x still equals expected and returns the original value, which is the building block for lock-free algorithms: read, compute, try to swap, and retry if another thread changed it. Use it for counters, flags and reference swaps; use a lock when several values must change together.
System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.
lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.
How to tell it (STAR)
During a [big on-sale] we double-booked [37] seats. The code checked a seat was free and then saved, so two requests could both pass the check. I moved the rule into the database with a unique constraint and an atomic update, and added a load test that fights over one seat. Double bookings went to zero.
Last updated 31 Oct 2026.