Rule of thumb
If your action waits on something external, make it async. If it’s instant CPU, keep it sync; for expensive CPU, offload.
The core idea
- Async shines for I/O-bound work (DB calls, HTTP calls, queues, files). It frees the request thread while waiting, so the server can serve more requests with the same thread pool.
- Sync is fine for trivial, short CPU work (formatting, small calculations) where you’re not awaiting anything and the handler returns in a few milliseconds.
When to choose async
- You call EF Core (
SaveChangesAsync,ToListAsync), HttpClient, Azure SDK (ServiceBusClient,BlobClient), file I/O, or any API withAsyncmethods. - You expect latency from a dependency (tens–hundreds of ms).
- You need cancellation and timeouts (propagate
HttpContext.RequestAborted).
When sync is acceptable
- The action is pure CPU and trivial (e.g., quick math, mapping, input validation) and returns immediately.
- There are no I/O waits and no benefit from freeing the thread.
If it’s CPU-heavy (image processing, big JSON transforms), do not just make it async—offload to a background queue/worker or a separate compute service. Async won’t make CPU faster.
Pitfalls to avoid
- Don’t block async: never use
.Result/.Wait()on Tasks (deadlocks/thread-pool starvation). - Async all the way down: if the controller is async, downstream calls should be too.
- Don’t fake async: returning
Task.Runaround synchronous I/O just burns threads. - Keep concurrency bounded when fanning out to multiple I/O calls.
Mini decision checklist
-
Any I/O? → Use async (end-to-end).
-
Pure CPU?
- Tiny (≤ a few ms) → Sync is fine.
- Heavy/variable → Offload to background worker; controller returns 202/Location or uses a queue.
ASP.NET Core examples
Async (I/O-bound) — recommended
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
private readonly OrdersDbContext _db;
private readonly HttpClient _http;
public OrdersController(OrdersDbContext db, IHttpClientFactory f)
{
_db = db;
_http = f.CreateClient("catalog");
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> Get(string id, CancellationToken ct)
{
var order = await _db.Orders.FindAsync([id], ct);
if (order is null) return NotFound();
// Call another service
var resp = await _http.GetAsync($"/inventory/{order.Sku}", ct);
resp.EnsureSuccessStatusCode();
var inv = await resp.Content.ReadFromJsonAsync<InventoryDto>(cancellationToken: ct);
return Ok(new OrderDto(order, inv));
}
}
Sync (trivial CPU) — acceptable
[HttpGet("ping")]
public ActionResult<string> Ping() => "pong"; // no I/O, returns immediately
CPU-heavy work — offload rather than “asyncifying”
// Controller: accept request, enqueue, return 202
[HttpPost("render")]
public async Task<IActionResult> Render(RenderRequest req, [FromServices] IBackgroundQueue queue)
{
var jobId = Guid.NewGuid().ToString("N");
await queue.EnqueueAsync(jobId, req);
return Accepted($"/render/{jobId}");
}
// Hosted service: single place that uses CPU, bounded concurrency
public class RenderWorker : BackgroundService
{
private readonly IBackgroundQueue _q;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in _q.DequeueAllAsync(stoppingToken))
{
// CPU-bound work here (e.g., image/video), controlled degree of parallelism
}
}
}
Tuning & tips
- Pass
CancellationTokenfromHttpContext.RequestAborted. - Use
IHttpClientFactoryand async Azure SDKs. - For streaming, expose
IAsyncEnumerable<T>or chunked responses (async). - In ASP.NET Core there’s no UI SynchronizationContext, so
ConfigureAwait(false)is usually unnecessary.