Skip to content

Async Performance Pitfalls

Key Points

  • Sync-over-async (.Result, .Wait(), .GetAwaiter().GetResult()) blocks a threadpool thread waiting for I/O — exhausts the pool under load.
  • Task.Run for CPU-bound only, not to "make async". Wrapping I/O in Task.Run adds a thread hop without benefit.
  • ConfigureAwait(false) in libraries to avoid synchronization context capture. In ASP.NET Core (no SynchronizationContext), redundant — but harmless.
  • ValueTask over Task for hot paths returning sync most of the time — avoids Task allocation.
  • Threadpool starvation is the #1 production .NET pathology. Diagnose with dotnet-counters (threadpool-queue-length).
  • async void = unhandled exceptions crash the process. Banned outside event handlers.

Concepts (deep dive)

Sync-over-async

// ❌ Block until task completes — burns a thread
var result = SomeAsync().Result;
SomeAsync().Wait();
SomeAsync().GetAwaiter().GetResult();

Each blocked call holds a threadpool thread idle. Under load, threadpool exhausts → new requests queued → latency cliff.

ASP.NET Core has no SynchronizationContext, so deadlocks are rare — but starvation absolutely happens.

Fix: full async all the way down.

Why Task.Run is not "making something async"

// ❌ Wraps blocking I/O — no benefit, adds thread hop
public async Task<string> ReadFileAsync(string path)
    => await Task.Run(() => File.ReadAllText(path));

// ✅ Use the async API
public Task<string> ReadFileAsync(string path)
    => File.ReadAllTextAsync(path);

Task.Run is for CPU-bound work that would otherwise block the calling thread (UI thread, request thread). For I/O, use the async API.

ConfigureAwait(false)

public async Task<string> LibraryMethodAsync()
{
    var data = await SomeIo().ConfigureAwait(false);
    return Process(data);
}

In a UI app or old ASP.NET (with SynchronizationContext), await resumes on the captured context. ConfigureAwait(false) says "don't bother — any thread is fine". In libraries, always use it (you don't know your caller's context).

ASP.NET Core has no SynchronizationContextConfigureAwait(false) is redundant in app code. Still good in libraries for portability.

There's no perf benefit in ASP.NET Core code — but no harm either.

ValueTask

// Task<T> always allocates a Task object
public async Task<int> GetAsync(int id) => /* often cached */;

// ValueTask<T> avoids allocation when result is sync
public ValueTask<int> GetAsync(int id)
{
    if (_cache.TryGetValue(id, out var v)) return ValueTask.FromResult(v);
    return new ValueTask<int>(LoadAsync(id));
}

When the cache hits, no Task is allocated. Only when the slow path runs is allocation paid.

Caveats: - Don't await ValueTask twice. - Don't store ValueTask in a field for later await. - ValueTask is bigger (struct) — passing by value has cost.

Use for high-frequency methods where results are often sync (caches, state machines).

IAsyncEnumerable<T>

For streaming results:

public async IAsyncEnumerable<Order> GetOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var row in _db.QueryAsync(ct))
        yield return MapOrder(row);
}

await foreach (var order in svc.GetOrdersAsync(ct))
    Process(order);

Backpressure-friendly. Each item awaited individually; consumer controls pace.

Threadpool starvation diagnosis

dotnet-counters monitor -p $(pgrep myapp) --counters System.Runtime

Look for: - threadpool-thread-count — climbing, not stabilizing. - threadpool-queue-length — high; work waiting for threads. - monitor-lock-contention-count — contention symptom.

Fix: 1. Find sync-over-async — git grep -E '\.Result|\.Wait\(\)|GetAwaiter\(\).GetResult'. 2. Make async I/O properly await. 3. Use SemaphoreSlim.WaitAsync instead of lock for awaitable critical sections.

async void is banned

// ❌ Unhandled exception → process crash
public async void DoStuffAsync() { await ...; }

// ✅
public async Task DoStuffAsync() { await ...; }

Only acceptable: event handlers (Windows Forms, WPF). Even then, wrap in try/catch.

Avoid creating Tasks unnecessarily

// ❌ Async ceremony for synchronous work
public async Task<int> Add(int a, int b) => a + b;

// ✅
public int Add(int a, int b) => a + b;

Or for cached values:

public Task<int> GetCachedAsync() => Task.FromResult(_cache);   // ❌ allocates Task
// vs
public ValueTask<int> GetCachedAsync() => new(_cache);           // ✅ no allocation

await using for IAsyncDisposable

await using var conn = new SqlConnection(connStr);
await conn.OpenAsync();

Disposes asynchronously — flushes pending I/O properly.

Batching parallel work

// ❌ Sequential
foreach (var url in urls) await _http.GetAsync(url);

// ✅ Parallel with cap
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 10 },
    async (url, ct) => await _http.GetAsync(url, ct));

// ✅ Or Task.WhenAll for unbounded
var tasks = urls.Select(u => _http.GetAsync(u));
await Task.WhenAll(tasks);

For I/O-bound parallelism, Parallel.ForEachAsync (with MaxDegreeOfParallelism) avoids unbounded concurrency that overwhelms downstreams.

CancellationToken propagation

public async Task<Order> GetAsync(int id, CancellationToken ct = default)
{
    var data = await _db.FindAsync(id, ct);
    var related = await _http.GetAsync($"/related/{id}", ct);
    return Combine(data, related);
}

Always pass tokens through. Cancellation is performance — abandons doomed work.

Avoid .Result/.Wait even in tests

In test runners with their own SynchronizationContext, sync-over-async deadlocks. Make tests async.

Hot-path async overhead

public async Task<int> Method() => await Task.FromResult(1);

The state-machine allocation costs ~50 bytes; async machinery overhead ~30ns. For nanosecond-critical code, ValueTask + struct state machines (async IAsyncEnumerable).

Fire-and-forget

// ❌ Lost exception
_ = ProcessAsync();

// ✅ Capture
_ = Task.Run(async () =>
{
    try { await ProcessAsync(); }
    catch (Exception ex) { _log.LogError(ex, "Background failed"); }
});

Or use IHostedService for background work.

TaskCreationOptions.LongRunning

For long-running CPU-bound work — gives a dedicated thread instead of pool:

Task.Factory.StartNew(() => HeavyWork(), TaskCreationOptions.LongRunning);

Use sparingly — really long work probably belongs in IHostedService.

Stephen Toub's heuristics

  • Avoid async void.
  • Use ConfigureAwait(false) in libraries.
  • Don't Task.Run to "go async" for I/O.
  • Prefer ValueTask<T> for sync-completing hot paths.
  • Don't allocate when result is sync — ValueTask over Task.FromResult.
  • Always pass CancellationToken.

Code: correct vs wrong

❌ Wrong: sync-over-async

public IActionResult Get(int id)
{
    var user = _svc.GetUserAsync(id).Result;   // blocks
    return Ok(user);
}

✅ Correct: async all the way

public async Task<IActionResult> Get(int id)
{
    var user = await _svc.GetUserAsync(id);
    return Ok(user);
}

❌ Wrong: Task.Run for I/O

await Task.Run(() => File.ReadAllText(path));

✅ Correct: async API

await File.ReadAllTextAsync(path);

❌ Wrong: unbounded fanout

var tasks = items.Select(i => _http.GetAsync(i.Url));   // 10000 concurrent reqs
await Task.WhenAll(tasks);

✅ Correct: bounded

await Parallel.ForEachAsync(items, new() { MaxDegreeOfParallelism = 20 },
    async (i, ct) => await _http.GetAsync(i.Url, ct));

Design patterns for this topic

Pattern 1 — "Async all the way"

  • Intent: no .Result/.Wait ever.

Pattern 2 — "ValueTask for hot sync paths"

  • Intent: zero allocation on cache hits.

Pattern 3 — "Bounded parallelism"

  • Intent: avoid downstream overload.

Pattern 4 — "CancellationToken everywhere"

  • Intent: abandon doomed work.

Pattern 5 — "ConfigureAwait(false) in libs"

  • Intent: avoid context capture.

Pros & cons / trade-offs

Aspect Pros Cons
Async I/O High throughput Complexity
ValueTask No alloc on sync Constraints; bigger struct
Parallel.ForEachAsync Bounded Setup verbose
Task.WhenAll Simple parallel Unbounded

When to use / when to avoid

  • Always async for I/O.
  • Use Task.Run only for CPU-bound work that would block.
  • Use ValueTask in hot paths.
  • Avoid sync-over-async.
  • Avoid async void.

Interview Q&A

Q1. What's threadpool starvation? Pool exhausted; new work queued; latency climbs. Common cause: sync-over-async blocking threads.

Q2. Why is Task.Run(File.Read) wrong? Wraps blocking I/O in a threadpool thread. Doesn't free a thread; adds a hop. Use the async API.

Q3. ValueTask vs Task? Task<T>: reference; allocates. ValueTask<T>: struct; avoids allocation when result is synchronous. Use ValueTask in hot paths.

Q4. ConfigureAwait(false) in ASP.NET Core? No SynchronizationContext → no benefit. But harmless. In libraries, always use it.

Q5. Why async void banned? Unhandled exceptions don't propagate to a Task → process crash.

Q6. How diagnose threadpool starvation? dotnet-countersthreadpool-queue-length, threadpool-thread-count rising.

Q7. Bounded vs unbounded parallelism? Task.WhenAll(items.Select(...)) is unbounded. Parallel.ForEachAsync with MaxDegreeOfParallelism is bounded — avoids overwhelming downstreams.

Q8. CancellationToken — why thread it through? Lets caller abandon work. Saves CPU/IO on doomed requests.

Q9. Async hot-path cost? ~50-byte state machine alloc; ~30ns machinery overhead. Use ValueTask + struct state machines for nanosecond-critical.

Q10. IAsyncEnumerable use case? Streaming results with backpressure. Each item awaited; consumer controls pace.

Q11. await using? Disposes async — flushes pending I/O. Required for IAsyncDisposable.

Q12. Fire-and-forget pattern? Wrap in Task.Run with try/catch + logging. Or use IHostedService for proper lifecycle.


Gotchas / common mistakes

  • ⚠️ .Result / .Wait — starvation.
  • ⚠️ Task.Run for I/O — pointless thread hop.
  • ⚠️ async void — process crash on exception.
  • ⚠️ Awaiting ValueTask twice — undefined behavior.
  • ⚠️ No CancellationToken — wasted work.
  • ⚠️ Unbounded Task.WhenAll — overwhelms downstream.

Further reading