·C#·2 min read·Senior developers

The Real Cost of async/await in C# — When You're Allocating More Than You Think

Every async method generates a state machine. Most of the time it costs nothing. The hot path where it costs you a lot is smaller than people think, and bigger than they want to admit.

async is free until it isn't. The compiler generates a state machine for every async method, and most of the time that machine is a struct that stays on the stack and never allocates anything. That's the happy path. The unhappy path is hotter and more common than people realise.

Specifically: the moment an await sees a Task that hasn't completed yet, the state machine gets boxed onto the heap, the continuation gets captured, and you've just allocated. Multiply by 50k requests per second and the GC starts breathing on your neck.

The fix is ValueTask<T> for hot paths that are usually synchronous:

// Allocates every call if the cache misses or hits — Task<T> is a class.
public async Task<User> GetAsync(int id) =>
    _cache.TryGet(id, out var u) ? u : await _db.LoadAsync(id);

// Allocates only when _db.LoadAsync hasn't completed synchronously.
public ValueTask<User> GetAsync(int id) =>
    _cache.TryGet(id, out var u)
        ? new ValueTask<User>(u)
        : new ValueTask<User>(_db.LoadAsync(id));

BenchmarkDotNet on a real read-through cache (90% hit rate, mock DB returning synchronously on hit):

Method Mean Allocated
Task<User> 78 ns 232 B
ValueTask<User> 12 ns 0 B

Six times faster, zero allocs on the hot path. That's the win.

A few rules I've learned the hard way:

  • ValueTask is awkward to consume. You can only await it once. If you need to await twice or store it, call AsTask() first. Don't try to be clever.
  • Pooled async ([AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]) is real and works, but it's a footgun. Use it on a measured hot path, never as a default.
  • 95% of code shouldn't care about any of this. Optimising allocations in a controller that runs 10 times a second is engineering theatre.

The cardinal rule: don't switch to ValueTask because a blog post told you. Switch when BenchmarkDotNet on your code shows the cost. Otherwise stick to Task<T> and sleep at night.