Stop sprinkling async on everything in ASP.NET Core

Async is amazing for IO bound work in ASP.NET Core, but it is not a seasoning you add to every method. This post breaks down a simple model for deciding when to await and when to stay synchronous. You will see small, runnable examples with minimal APIs, EF Core and background services. We will also cover why Task.Run in controllers rarely helps and when a background queue makes more sense.

If you have ever watched a team sprinkle async like parmesan on every controller, service and helper, you know the feeling. The code looks modern. The words sound advanced. Yet the app feels heavier and debugging gets trickier. Today we will flip the script and learn when async is the right tool, when it is just noise and what to do instead.

A quick mental model

Picture a barista. When the espresso machine is running, the barista can start the next drink. That is IO bound work with waiting time. Now imagine the barista hand whisking matcha. There is no waiting, only work. That is CPU bound. Async helps with the first case because the thread can serve another request while the machine does its thing. It does little for the second case because there is no idle time to reclaim.

Where async shines in ASP.NET Core

The winners are calls that leave your process and take time to respond like databases, HTTP, file storage, and queues. In those paths, async unlocks scalability because the request thread returns to the pool instead of sitting still.

Let’s wire a minimal API endpoint that uses EF Core correctly.

app.MapGet("/users/{id:int}", async (int id, AppDbContext db) =>
{
var user = await db.Users.FindAsync(id);
return user is null ? Results.NotFound() : Results.Ok(user);
});

The database call is the long pole, so awaiting it keeps the server responsive under load.

Where async adds cost without benefit

If your method performs work in-memory only, making it async usually adds indirection and allocations. Returning Task for something that finishes immediately is like gift wrapping a donut. It looks fancy, but you still get sticky fingers.

int AddNumbers(int a, int b) => a + b;

Short, clear, fast. No Task, no await, no confusion.

A common pitfall - wrapping CPU work in Task.Run inside a request

Developers sometimes move heavy calculations to Task.Run and feel good about it. Inside ASP.NET Core, that rarely helps because your code is already running on a thread pool thread. You only pay extra scheduling cost and keep the CPU just as busy.

app.MapGet("/score", async () =>
{
return await Task.Run(() => CrunchNumbers());
});

A better approach is to keep the handler synchronous if it must run now, or push the work to a background pipeline if it can run later.

static int CrunchNumbers()
{
var sum = 0;
for (int i = 0; i < 1_000_000; i++) sum += i;
return sum;
}

Keep CPU only endpoints synchronous when appropriate

If an endpoint does a quick calculation or maps objects in memory, a synchronous signature is fine. Just don’t block on IO with .Result or .Wait.

app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTimeOffset.UtcNow }));

Offload long CPU work to a background service

When heavy processing doesn’t need to finish inside the HTTP request, send it to a background worker and respond immediately. Channels make a simple in process queue.

public sealed class CrunchingService : BackgroundService
{
private readonly Channel<int> _queue;
public CrunchingService(Channel<int> queue) => _queue = queue;
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var n in _queue.Reader.ReadAllAsync(ct))
_ = Task.Run(() => CrunchNumbers(n), ct);
}
}

Register and enqueue from minimal APIs.

builder.Services.AddSingleton(Channel.CreateUnbounded<int>());
builder.Services.AddHostedService<CrunchingService>();
app.MapPost("/crunch/{n:int}", (int n, Channel<int> q) =>
{
q.Writer.TryWrite(n);
return Results.Accepted();
});

This pattern frees the request quickly, keeps the server responsive and places CPU burn where it belongs.

A word on ValueTask

If a method often completes synchronously and you are shaving allocations in a hot path, ValueTask can help. Use it sparingly and only after measuring, since it has tradeoffs.

static ValueTask<string?> TryGetJediFromCacheAsync(IMemoryCache cache, string key)
{
return cache.TryGetValue<string>(key, out var jedi)
? new ValueTask<string?>(jedi)
: new ValueTask<string?>((string?)null);
}

Practical checklist

  • Does the method wait on IO like HTTP, EF Core, files or queues? Use async.
  • Is the method purely CPU and quick? Keep it synchronous.
  • Is the work CPU heavy and not user critical? Queue it to a background service.
  • Never block on async with .Result or .Wait inside ASP.NET Core.
  • Avoid Task.Run in controllers for CPU work. Prefer background processing when you can.
  • ASP.NET Core does not have a request SynchronizationContext, so ConfigureAwait(false) is usually unnecessary.

Wrap up

Async is incredible when you are waiting. It is overhead when you are working. Designing endpoints with that distinction in mind leads to cleaner code, simpler debugging and happier servers. The best trick is not to reach for async first, but to ask a small question before every method: will this code spend meaningful time waiting on something outside my process? If yes, await it. If not, keep it simple.