Concurrency Anti-Patterns: The Race Conditions That Ship

TL;DR Race conditions, deadlocks, and thread-safety bugs that pass code review: shared state mutations, fire-and-forget hazards, and lock misuse.

Concurrency bugs are invisible until production load reveals them. These patterns compile, pass tests, work in development, and then corrupt data under concurrent requests.

Common questions this answers

  • Why does my application behave differently under load?
  • What shared state bugs cause data corruption?
  • Why do my fire-and-forget tasks fail silently?
  • When does async/await cause race conditions?
  • How do I detect concurrency bugs before production?

Definition (what this means in practice)

Concurrency anti-patterns are code structures that appear safe but fail under concurrent execution. Research shows race conditions account for approximately 47% of concurrency bugs, with deadlocks at 13% and atomicity violations at 5%. Unlike syntax errors or logic bugs, these issues often work correctly in single-threaded testing and only manifest under production load with multiple simultaneous requests.

In practice, this means reviewing shared state, understanding when async code interleaves, and recognizing that "it works on my machine" means nothing for concurrency.

Terms used

  • Race condition: outcome depends on timing of concurrent operations
  • Shared mutable state: data accessible by multiple threads that can be modified
  • Thread-safety: code that produces correct results regardless of thread interleaving
  • Fire-and-forget: starting async work without awaiting completion
  • Check-then-act: reading a value, making a decision, then acting (dangerous without synchronization)

Reader contract

This article is for:

  • Engineers debugging production incidents involving data corruption or inconsistent state
  • Reviewers checking concurrent code paths

You will leave with:

  • Recognition of 6 concurrency anti-patterns
  • Detection strategies for each
  • Safe alternatives that maintain performance

This is not for:

  • Async/await basics (see Async/Await Pitfalls)
  • Parallel programming deep dives (PLINQ, channels, dataflow)

Quick start (10 minutes)

If you do nothing else, search your codebase for these patterns:

  1. Static mutable fields in web applications
  2. Task.Run without await (fire-and-forget)
  3. Dictionary<K,V> accessed from multiple threads
  4. += or ++ on shared counters
  5. Check-then-act patterns without locks
# Find potential race conditions
grep -rn "static.*=" --include="*.cs" | grep -v "readonly\|const"
grep -rn "Task.Run" --include="*.cs" | grep -v "await"
grep -rn "private.*Dictionary" --include="*.cs"

Anti-pattern 1: Shared mutable state in singletons

Singletons live for application lifetime. Mutable state in singletons is shared across all requests.

The problem

// Registered as singleton
public class ReportService
{
    private List<string> _currentReportData = [];  // Shared across requests!

    public void StartReport()
    {
        _currentReportData.Clear();  // Race condition!
    }

    public void AddRow(string row)
    {
        _currentReportData.Add(row);  // Race condition!
    }

    public string GetReport()
    {
        return string.Join("\n", _currentReportData);
    }
}

Why it fails

Request A calls StartReport(), Request B calls StartReport(), Request A calls AddRow(). Request A's data is now corrupted. Under load, List<T>.Add() can throw or corrupt internal state.

The fix

Eliminate shared mutable state. Pass state through method parameters or use request-scoped services:

// GOOD: No shared mutable state
public class ReportService
{
    public ReportBuilder CreateReport() => new ReportBuilder();
}

public class ReportBuilder
{
    private readonly List<string> _data = [];

    public void AddRow(string row) => _data.Add(row);
    public string Build() => string.Join("\n", _data);
}

// Usage: each request gets its own builder
var builder = reportService.CreateReport();
builder.AddRow("data");
var report = builder.Build();

Detection

Search for mutable fields in singleton-registered classes:

// Red flags in singletons:
private List<T> _items;           // Mutable collection
private Dictionary<K,V> _cache;   // Mutable dictionary
private int _counter;             // Mutable primitive
private SomeObject _current;      // Mutable reference

Why fire-and-forget tasks fail silently in C#

Starting async work without awaiting loses exceptions and can outlive request scope.

The problem

[HttpPost]
public IActionResult SubmitOrder(Order order)
{
    // Fire-and-forget: exceptions lost, scope may be disposed
    _ = SendConfirmationEmailAsync(order);
    _ = UpdateAnalyticsAsync(order);

    return Ok(order.Id);
}

private async Task SendConfirmationEmailAsync(Order order)
{
    // If this throws, exception is lost
    // If this runs after request ends, scoped services are disposed
    await _emailService.SendAsync(order.Email, "Confirmation", BuildBody(order));
}

Why it fails

  1. Exceptions in fire-and-forget tasks are swallowed silently
  2. Scoped services (DbContext, HttpContext) may be disposed before the task completes
  3. Application shutdown can kill in-flight tasks
  4. No visibility into failures

The fix

Use a background queue or hosted service:

// GOOD: Queue work for background processing
[HttpPost]
public IActionResult SubmitOrder(
    Order order,
    [FromServices] IBackgroundTaskQueue taskQueue)
{
    // Queue work items - they'll run with proper scope
    taskQueue.Enqueue(async (scope, ct) =>
    {
        var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
        await emailService.SendAsync(order.Email, "Confirmation", BuildBody(order));
    });

    return Ok(order.Id);
}

// Background service processes queue
public class QueuedHostedService(
    IBackgroundTaskQueue taskQueue,
    IServiceScopeFactory scopeFactory,
    ILogger<QueuedHostedService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var workItem = await taskQueue.DequeueAsync(ct);

            try
            {
                using var scope = scopeFactory.CreateScope();
                await workItem(scope, ct);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Background task failed");
            }
        }
    }
}

Detection

Search for Task.Run or async method calls without await:

grep -rn "_ = " --include="*.cs"
grep -rn "Task.Run" --include="*.cs" | grep -v "await"

Anti-pattern 3: Check-then-act without synchronization

Reading state, making a decision, then acting is dangerous without atomicity.

The problem

public class InventoryService
{
    private readonly Dictionary<int, int> _stock = new();

    public bool TryReserve(int productId, int quantity)
    {
        // CHECK
        if (_stock.TryGetValue(productId, out var available) && available >= quantity)
        {
            // ACT - but another thread may have changed _stock!
            _stock[productId] = available - quantity;
            return true;
        }
        return false;
    }
}

Why it fails

Between the check and the act, another thread can modify _stock. Two requests each see 10 units available, both reserve 8 units, and inventory goes to -6.

The fix

Use atomic operations or proper synchronization:

// GOOD: ConcurrentDictionary with atomic update
public class InventoryService
{
    private readonly ConcurrentDictionary<int, int> _stock = new();

    public bool TryReserve(int productId, int quantity)
    {
        // Atomic check-and-update
        while (true)
        {
            if (!_stock.TryGetValue(productId, out var current))
                return false;

            if (current < quantity)
                return false;

            // Only succeeds if value hasn't changed
            if (_stock.TryUpdate(productId, current - quantity, current))
                return true;

            // Value changed, retry
        }
    }
}

// OR: Use Interlocked for simple counters
public class Counter
{
    private int _value;

    public int IncrementAndGet()
    {
        return Interlocked.Increment(ref _value);
    }

    public bool TryDecrement(int amount)
    {
        while (true)
        {
            var current = _value;
            if (current < amount) return false;

            if (Interlocked.CompareExchange(ref _value, current - amount, current) == current)
                return true;
        }
    }
}

Detection

Look for patterns where a value is read, a condition is checked, then the value is modified:

// Red flag pattern:
if (collection.Contains(key))
{
    collection.Remove(key);  // Key might be gone!
}

if (dictionary[key] > 0)
{
    dictionary[key]--;  // Value might have changed!
}

ConcurrentDictionary vs Dictionary: when thread-safety matters

Standard collections are not thread-safe. Concurrent access causes corruption.

The problem

public class CacheService
{
    private readonly Dictionary<string, object> _cache = new();

    public void Set(string key, object value)
    {
        _cache[key] = value;  // Not thread-safe!
    }

    public object? Get(string key)
    {
        return _cache.TryGetValue(key, out var value) ? value : null;
    }
}

Why it fails

Dictionary<K,V> internal state can be corrupted by concurrent writes. Symptoms:

  • Infinite loops in lookup
  • Wrong values returned
  • KeyNotFoundException for keys that exist
  • Application hangs

The fix

Use thread-safe collections:

// GOOD: ConcurrentDictionary
public class CacheService
{
    private readonly ConcurrentDictionary<string, object> _cache = new();

    public void Set(string key, object value)
    {
        _cache[key] = value;
    }

    public object? Get(string key)
    {
        return _cache.TryGetValue(key, out var value) ? value : null;
    }

    // For complex updates, use AddOrUpdate
    public object GetOrCreate(string key, Func<object> factory)
    {
        return _cache.GetOrAdd(key, _ => factory());
    }
}
Standard Collection Thread-Safe Alternative
Dictionary<K,V> ConcurrentDictionary<K,V>
List<T> ConcurrentBag<T> or ImmutableList<T>
Queue<T> ConcurrentQueue<T>
Stack<T> ConcurrentStack<T>
HashSet<T> ConcurrentDictionary<T,byte>

Detection

Search for standard collections in shared contexts:

grep -rn "Dictionary<" --include="*.cs" | grep "private\|static"
grep -rn "List<" --include="*.cs" | grep "private.*static"

Anti-pattern 5: Lock scope too wide or too narrow

Incorrect lock scope either kills performance or fails to protect.

The problem

// TOO WIDE: locks during I/O, destroys concurrency
public class DataService
{
    private readonly object _lock = new();
    private readonly Dictionary<int, Data> _cache = new();

    public async Task<Data> GetDataAsync(int id)
    {
        lock (_lock)
        {
            if (_cache.TryGetValue(id, out var cached))
                return cached;

            // Holding lock during async I/O - terrible!
            var data = await _database.LoadAsync(id);
            _cache[id] = data;
            return data;
        }
    }
}

// TOO NARROW: doesn't protect the full operation
public class Counter
{
    private int _value;
    private readonly object _lock = new();

    public int IncrementAndGet()
    {
        int result;
        lock (_lock) { _value++; }  // Lock released too early
        lock (_lock) { result = _value; }  // Another thread may have incremented
        return result;
    }
}

The fix

Lock only what needs synchronization, but lock the complete operation:

// GOOD: Minimal lock scope, complete operation
public class DataService
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);
    private readonly Dictionary<int, Data> _cache = new();

    public async Task<Data> GetDataAsync(int id)
    {
        // Check cache without lock
        lock (_cache)
        {
            if (_cache.TryGetValue(id, out var cached))
                return cached;
        }

        // Load outside lock
        await _semaphore.WaitAsync();
        try
        {
            // Double-check after acquiring semaphore
            lock (_cache)
            {
                if (_cache.TryGetValue(id, out var cached))
                    return cached;
            }

            var data = await _database.LoadAsync(id);

            lock (_cache)
            {
                _cache[id] = data;
            }

            return data;
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

// GOOD: Complete atomic operation
public class Counter
{
    private int _value;
    private readonly object _lock = new();

    public int IncrementAndGet()
    {
        lock (_lock)
        {
            _value++;
            return _value;
        }
    }
}

Detection

Review all lock statements for:

  • Async calls inside lock (use SemaphoreSlim instead)
  • I/O operations inside lock
  • Multiple separate locks for one logical operation

Anti-pattern 6: HttpContext accessed from background threads

HttpContext is request-scoped and not thread-safe.

The problem

[HttpGet]
public async Task<IActionResult> Search(string query)
{
    var tasks = new[]
    {
        SearchProviderAsync("google", query),
        SearchProviderAsync("bing", query)
    };

    await Task.WhenAll(tasks);
    return Ok(tasks.Select(t => t.Result));
}

private async Task<SearchResult> SearchProviderAsync(string provider, string query)
{
    // DANGER: HttpContext accessed from parallel task
    var userId = HttpContext.User.Identity?.Name;
    _logger.LogInformation("User {User} searching {Provider}", userId, provider);

    return await _searchService.SearchAsync(provider, query);
}

Why it fails

HttpContext is not thread-safe. Parallel access can cause:

  • NullReferenceException
  • Incorrect values
  • Request corruption

The fix

Capture context values before parallel execution:

[HttpGet]
public async Task<IActionResult> Search(string query)
{
    // Capture context values on request thread
    var userId = HttpContext.User.Identity?.Name;
    var correlationId = HttpContext.TraceIdentifier;

    var tasks = new[]
    {
        SearchProviderAsync("google", query, userId, correlationId),
        SearchProviderAsync("bing", query, userId, correlationId)
    };

    await Task.WhenAll(tasks);
    return Ok(tasks.Select(t => t.Result));
}

private async Task<SearchResult> SearchProviderAsync(
    string provider,
    string query,
    string? userId,
    string correlationId)
{
    // Use captured values, not HttpContext
    _logger.LogInformation(
        "User {User} searching {Provider}, Correlation: {CorrelationId}",
        userId, provider, correlationId);

    return await _searchService.SearchAsync(provider, query);
}

Detection

Search for HttpContext usage in methods called with Task.WhenAll or Parallel:

grep -rn "HttpContext\." --include="*.cs"
grep -rn "Task.WhenAll\|Parallel.For" --include="*.cs"

Copy/paste artifact: concurrency code review checklist

Concurrency Code Review Checklist

1. Shared mutable state
   - [ ] No mutable static fields
   - [ ] No mutable instance fields in singletons
   - [ ] Collections in shared contexts are thread-safe

2. Fire-and-forget
   - [ ] No unawaited async calls that matter
   - [ ] Background work uses hosted services or queues
   - [ ] Errors are logged, not swallowed

3. Check-then-act
   - [ ] Compound operations are atomic
   - [ ] ConcurrentDictionary uses atomic methods
   - [ ] Interlocked for counter operations

4. Locking
   - [ ] No async/await inside lock
   - [ ] No I/O inside lock
   - [ ] Lock scope covers complete operation

5. HttpContext
   - [ ] Not accessed from parallel tasks
   - [ ] Values captured before Task.WhenAll
   - [ ] Not passed to background work

6. Collections
   - [ ] Dictionary -> ConcurrentDictionary in shared contexts
   - [ ] List -> ConcurrentBag or ImmutableList
   - [ ] No modification during enumeration

Common failure modes

  1. Data corruption: Two requests modify shared state, result is garbage
  2. Lost updates: Check-then-act without atomicity
  3. Silent failures: Fire-and-forget swallows exceptions
  4. Deadlocks: Lock ordering inconsistency
  5. Performance collapse: Lock scope too wide

Checklist

  • No mutable static fields in web applications
  • Background work uses proper queues/hosted services
  • Shared collections are thread-safe types
  • Check-then-act uses atomic operations
  • HttpContext values captured before parallel work
  • No async inside lock statements

FAQ

What is a race condition in C#?

A race condition occurs when the correctness of a program depends on the timing of concurrent operations. Two threads access shared data, and at least one modifies it, without proper synchronization. The outcome varies depending on which thread executes first, leading to unpredictable bugs that only appear under load.

How do I make a Dictionary thread-safe in C#?

Replace Dictionary<K,V> with ConcurrentDictionary<K,V> from System.Collections.Concurrent. For atomic operations, use methods like GetOrAdd, AddOrUpdate, and TryUpdate instead of separate check-then-act patterns.

Are async methods thread-safe?

No. Async methods can be interleaved. The code before and after await may run on different threads, and multiple async operations can run concurrently.

Is Lazy<T> thread-safe?

By default, yes. Lazy<T> uses LazyThreadSafetyMode.ExecutionAndPublication, which ensures the factory runs exactly once even under concurrent access.

When should I use lock vs SemaphoreSlim?

Use lock for synchronous code only. Use SemaphoreSlim when you need to await inside the critical section.

Is ImmutableDictionary faster than ConcurrentDictionary?

For reads, they're similar. For writes, ConcurrentDictionary is faster because ImmutableDictionary creates a new instance on every modification.

How do I test for race conditions?

Use stress tests with many concurrent requests. Tools like Parallel.For in tests can help surface race conditions, but absence of failures doesn't prove safety.

What to do next

Search your codebase for shared mutable state accessed without synchronization. Review any fire-and-forget patterns for proper error handling.

For more on async patterns that cause production issues, read Async/Await Pitfalls.

If you want help reviewing concurrency patterns in your codebase, reach out via Contact.

References

Author notes

Decisions:

  • Treat shared mutable state as suspect by default. Rationale: race conditions are hard to detect and reproduce.
  • Prefer immutable data structures for concurrent access. Rationale: eliminates synchronization complexity.
  • Use ConcurrentDictionary over manual locking for dictionaries. Rationale: well-tested, optimized for concurrent access patterns.

Observations:

  • Race conditions often appear only under production load, not in unit tests or development.
  • Fire-and-forget patterns cause silent failures that surface as data inconsistencies.
  • Lock contention manifests as latency spikes under concurrent requests.