ValueTask: when to use it and when not to

ยท 1 min read

ValueTask<T> can save an allocation on hot paths. It also comes with rules that Task doesn't have, and breaking them causes bugs that are hard to trace.

ValueTask<T> shows up in more and more .NET APIs, and it's tempting to swap every Task<T> for it in the name of performance. Usually that's a mistake.

What problem it solves

Task<T> is a class, so returning one allocates, even when the result is already available. For most code that allocation is irrelevant. On a hot path that completes synchronously most of the time, it adds up.

The classic example is a cache:

public ValueTask<Product> GetProductAsync(int id, CancellationToken ct)
{
    if (_cache.TryGetValue(id, out var product))
        return ValueTask.FromResult(product);              // no allocation

    return new ValueTask<Product>(LoadAndCacheAsync(id, ct)); // the slow path still uses a Task
}

When the cache hit rate is high, most calls allocate nothing. ValueTask<T> can also be backed by a pooled IValueTaskSource, which is how sockets and pipelines in .NET avoid allocations even when they really are asynchronous.

The rules

A Task can be awaited as often as you like. A ValueTask can't, because the object behind it may be reused for a different operation as soon as you've consumed it. With a ValueTask, never:

  • await it more than once
  • await it concurrently from two places
  • call .Result or .GetAwaiter().GetResult() before it has completed
var pending = repository.GetProductAsync(id, ct);
var a = await pending;
var b = await pending;   // undefined behavior with ValueTask

If you need any of those, convert it once with .AsTask() and use the Task.

When to use it

  • A library or framework hot path, called very frequently, that usually completes synchronously
  • You've measured the allocations and they matter

When not to

  • Ordinary application code: controllers, handlers, services that call a database or an API. Those calls are asynchronous every time, so you allocate anyway and only gain the rules.
  • Public APIs where callers might reasonably await twice or combine results with Task.WhenAll.

Takeaway

Default to Task and Task<T>. Reach for ValueTask<T> on measured hot paths that often complete synchronously, and when you consume one, await it exactly once.