ValueTask: when to use it and when not to
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
.Resultor.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.