Recently, some members of the engineering team raised questions about the usefulness of C#’s async/await. That discussion highlighted that many developers still misunderstand why asynchronous programming matters.
Yes, you’ll find articles claiming “async is slow” or “async controllers perform worse.” But that’s only true when measured in synthetic, isolated microbenchmarks. In real-world applications, async isn’t about shaving off milliseconds per call—it’s about ensuring your system survives under load. [10], [3], [1]
The crucial takeaway isn’t “drop async”—it’s “use async correctly and tune the bottlenecks.” Mastering asynchronous programming is essential for building responsive, efficient, and scalable .NET applications. [1]
Why Async Still Matters
Async ≠ raw speed. Async = scalability.
The point of async/await isn’t to make an individual operation faster—it’s to let your application handle more concurrent work with the same hardware. By freeing threads during I/O waits (DB queries, API calls, queue operations), async dramatically improves throughput. [1], [3]
Yes, async introduces a small overhead (state machines, context switching). But synchronous code collapses under pressure. [7]
📊 Example: In one real-world load test, synchronous code showed a 14s 50th percentile latency under load. The async version handled the same load at
410ms median latency. [1]
Most “slow async” problems are tuning issues.
If your async app performs poorly under load, the culprit is usually thread pool starvation or undersized DB connection pools, not async itself. The fix is tuning infrastructure—not abandoning async. [7], [13], [12]
Blocking calls kills scalability.
Mixing .Result or .Wait() with async defeats the entire model. These sync-over-async patterns block threads, prevent continuations from running, and can spiral into thread pool exhaustion or even deadlocks. [9], [15], [6]
We’re increasingly I/O-bound.
Modern apps lean heavily on APIs, microservices, and distributed systems. Async is the only sustainable way forward. [1], [3]
Actionable Guidance for Developers
Understanding why async matters is only the first step—putting it into practice consistently is what delivers scalability and resilience.
1. Go end-to-end async.
Don’t block on async calls. Let async propagate up the call chain. Use async Task (not async void) to ensure proper exception handling. [5], [9]
2. Always pass CancellationTokens.
Cancellation enables graceful exits and resource cleanup. ASP.NET Core automatically passes a CancellationToken into controllers—use it! In loops or long tasks, call token.ThrowIfCancellationRequested(). Adding it later is a breaking change—make it part of your method signatures from day one. [4], [19]
3. Open DB connections late, close them early.
Use await connection.OpenAsync(token)—even with pooling. It prevents blocking when the pool is cold, exhausted, or needs re-auth. [13], [12], [8]
Always Dispose or Close connections quickly. Don’t let the GC decide. [13]
Size connection pools for peak load. Too small, and you’ll hit timeouts. Too big, and you may overwhelm the DB. [13]
4. Measure before debating sync vs async.
Focus on metrics:
Thread Pool: Watch dotnet.thread_pool.thread.count. Healthy async apps hover around vCores × 2. Higher numbers with low CPU often mean hidden blocking. [17], [7]
Connection Pools: Track utilization. If maxed out, either raise the cap or throttle demand with rate limiting. [13]
ConfigureAwait: When (and When Not) to Use It
ConfigureAwait(false) tells continuations not to return to the captured context. [6]
ASP.NET Core apps: Usually skip it. Core apps don’t have a custom SynchronizationContext, so the default is fine.
Reusable libraries: Always use ConfigureAwait(false). Libraries shouldn’t assume the caller’s context. This avoids deadlocks and improves perf. [6]
UI apps/legacy ASP.NET: Use ConfigureAwait(false) everywhere, except where you must get back to the UI thread or request context. [6]
Copy-Paste Patterns (Illustrative Code)
Opinionated, production-minded snippets for ASP.NET Core, Postgres (Npgsql), and HttpClient. All support cancellation and avoid sync-over-async.
Required usings (for copy‑paste compilation)
using System.Net.Http;
using System.Net.Http.Json; // ReadFromJsonAsync
using System.Text.Json; // JsonSerializer
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Npgsql; // NpgsqlConnection
Define a DTO that matches your external payload:
public sealed record Dto(string Foo);
1) End-to-End Async Controller (I/O bound)
Conventional constructor (compatible with C# 8–12). Uses HttpClient DI and Npgsql with late open/tight scope.
[ApiController]
[Route("api/[controller]")]
public sealed class WeatherController : ControllerBase
{
private readonly HttpClient _httpClient;
private readonly string _conn;
public WeatherController(HttpClient httpClient, IConfiguration cfg)
{
_httpClient = httpClient;
_conn = cfg.GetConnectionString("AppDb")!;
}
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
{
// External HTTP call (non-blocking)
using var req = new HttpRequestMessage(HttpMethod.Get, "https://example.com/data");
using var resp = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
var payload = await resp.Content.ReadFromJsonAsync<Dto>(cancellationToken: ct);
// DB work: open late, close early
await using var conn = new NpgsqlConnection(_conn);
await conn.OpenAsync(ct); // non-blocking
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT now()"; // demo
var serverNow = await cmd.ExecuteScalarAsync(ct); // async I/O
return Ok(new { payload, serverNow });
}
}
Notes: OpenAsync(ct) prevents blocking when the pool is cold/exhausted or requires re-auth. Always dispose connections promptly to return them to the pool. [13]
2) Cancellation in Long-Running Work
using System.Runtime.CompilerServices; // for WithCancellation
public static async Task ProcessBatchesAsync(IAsyncEnumerable<Item> source, CancellationToken ct)
{
await foreach (var item in source.WithCancellation(ct))
{
ct.ThrowIfCancellationRequested();
await DoWorkAsync(item, ct); // ensure all callees accept ct
}
}
Guideline: Make CancellationToken part of all async method signatures from day one. Retrofitting is a breaking change. [19]
3) Avoid Sync-over-Async (.Result / .Wait) — Anti-Pattern
// ❌ Anti-pattern: blocks thread & risks deadlock
var data = SomeAsync().Result; // or: SomeAsync().GetAwaiter().GetResult();
// ✅ Non-blocking, resilient
var dataOk = await SomeAsync();
See the detailed rationale and deadlock scenarios. [9], [15], [6]
4) Library Code and ConfigureAwait(false)
// In reusable libraries, do NOT assume a synchronization context.
public static async Task<T> FetchAsync<T>(HttpClient client, string url, CancellationToken ct)
{
using var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<T>(json)!;
}
Why: Prevents deadlocks when callers block, and avoids context capture costs. [6]
5) Observability: Measure Before You Argue
# Live counters (local dev): requires dotnet-counters
dotnet-counters monitor System.Runtime Microsoft.AspNetCore.Hosting
# Key ones to watch (docs list many):
# - threadpool-thread-count (spikes w/ low CPU => hidden blocking) [17]
# - threadpool-queue-length (permanent > 0 => starvation) [7]
# - completed-items-count
# - timer-count
# ASP.NET Core built-ins (metrics endpoints / exporters)
# See Microsoft Learn guidance. [2]
Kubernetes: logging/collecting counters in-cluster
Pick one of the patterns below. Prefer dotnet-monitor sidecar for production.
Option A — Ephemeral debug container (quick one-off capture)
# 1) Start an ephemeral container in the target pod (no image rebuild needed)
# Requires Kubernetes 1.18+ and "Ephemeral Containers" feature enabled
kubectl debug -it pod/<pod-name> --target=<app-container> \
--image=mcr.microsoft.com/dotnet/sdk:8.0 \
-- bash -lc 'dotnet-counters collect \
--process-id 1 \
--counters System.Runtime,Microsoft.AspNetCore.Hosting \
--format csv --duration 00:05:00'
Option B — Sidecar with dotnet-monitor (recommended)
# Add a dotnet-monitor sidecar to the deployment and scrape metrics/logs
spec:
template:
spec:
containers:
- name: app
image: <your-app-image>
# expose app port as usual
- name: dotnet-monitor
image: mcr.microsoft.com/dotnet/monitor:8
args: ["collect"]
env:
- name: DOTNETMONITOR_Urls
value: http://0.0.0.0:52323
- name: DOTNETMONITOR_DiagnosticPort__ConnectionMode
value: Listen
ports:
- name: monitor
containerPort: 52323
Now you can scrape or curl the sidecar (or wire it to Prometheus/OpenTelemetry) to export counters/metrics. Pair with a ServiceMonitor or OTEL Collector if you already run Prometheus/Grafana.
Option C — Run dotnet-counters inside your app image (cron/sidecar pattern)
# Run as a short-lived sidecar/Job and write to stdout (picked up by your log pipeline)
dotnet-counters collect \
--process-id 1 \
--counters System.Runtime,Microsoft.AspNetCore.Hosting \
--format csv --duration 00:05:00
Tip: In Kubernetes, stdout is the simplest sink (picked up by your existing log agent). For long-term trending, export via OpenTelemetry metrics (Prometheus, OTLP) rather than raw CSV.
Note on CPU/threads in containers
.NET uses cgroup limits to infer available CPUs per pod. If you start pods with very small CPU limits/requests (e.g., 100m–250m), the runtime will initialize with a low ThreadPool capacity, and it may not scale up quickly under burst. This can look like starvation during sudden load even when the node has headroom. [7]
Recommendations
Give each pod a sane baseline CPU (e.g., requests: 500m–1 CPU for I/O-heavy APIs under burst) and use HPA to scale replicas out.
Avoid sync-over-async so the pool isn’t blocked while hill‑climbing.
If you must handle short spikes with tiny CPU pods, consider raising minimum worker threads at startup:
// In Program.cs, as a tactical knob (measure before/after!)
ThreadPool.GetMinThreads(out var minW, out var minIo);
ThreadPool.SetMinThreads(Math.Max(minW, 2 * Environment.ProcessorCount), minIo);
6) SQL Connection Pool Tuning (ADO.NET / Npgsql)
Prefer default pooling; tune Maximum Pool Size based on load tests.
Keep transactions short; hold connections for the minimum time.
If queues build up waiting for a pooled connection, raise the cap or apply rate limiting/back-pressure at the edge. [13]
When reading results, prefer await reader.ReadAsync(ct) instead of reader.Read() to avoid blocking threads during I/O. This keeps the call fully asynchronous and consistent with the rest of your async code. [14]
Bottom Line
Async/await isn’t optional overhead—it’s essential for scaling .NET apps in today’s I/O-heavy world. [1], [7], [13]
Use async end-to-end.
Eliminate sync-over-async calls.
Pass cancellation tokens.
Tune your DB and thread pool settings.
Use ConfigureAwait(false) wisely.
Done right, async makes your system handle load gracefully instead of crumbling.
Sources
5 .NET Concurrency Patterns That Actually Scale in Production - Level Up Coding
ASP.NET Core built-in metrics - Microsoft Learn
Async Versus Sync Code in ASP.NET APIs | Know Your Toolset
Cancellation Tokens In C#: Best Practices For .NET Core Applications - Nile Bits
Common Asynchronous Programming Mistakes in C# - NashTech Blog
ConfigureAwait FAQ - .NET Blog
Debug ThreadPool Starvation - .NET - Microsoft Learn
How do .NET SQL connection pools scale in a multi-node environment? - Stack Overflow
How to justify using await instead of .Result() or .Wait() in .NET Core?
Not much difference between ASP.NET Core sync and async controller actions
Optimize Oracle Data Access Performance with Visual Studio and .NET
Redesign the SqlClient Connection Pool to Improve Performance and Async Support · Issue #3356 - GitHub
SQL Server Connection Pooling - ADO.NET - Microsoft Learn
Sql Open connection async or not async : Reddit r/csharp
Stop using .Result or .Wait() on tasks and why it's dangerous - Simeon Nenov's Blog
Understanding and Using ConfigureAwait in Asynchronous Programming - DEV Community
Well-known EventCounters in .NET - Microsoft Learn
What to do if I see permanent ThreadPool Queue Length? : Reddit r/dotnet
When should I add a CancellationToken to an async interface method? - Stack Overflow
vasalis/dotnetcorethrouput: Detecting ThreadPool Starvation on .NET Core using Application Insights - GitHub