Every async method that returns Task<T> allocates a Task<T> object on the heap — even when the method completes synchronously. For methods that frequently complete without awaiting (cache hits, buffered reads, short-circuit logic), ValueTask<T> can eliminate this allocation entirely.
The Problem with Task<T>
Consider a caching layer:
public async Task<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return user; // synchronous path — but still allocates a Task<User>
user = await _database.GetUserAsync(id);
_cache[id] = user;
return user;
}
When the cache hits (the common case), this method completes synchronously. But Task<User> is a reference type — returning one always allocates on the heap. Under high throughput, these allocations create measurable GC pressure.
ValueTask<T> to the Rescue
ValueTask<T> is a struct that wraps either a T value (for synchronous completion) or a Task<T> (for asynchronous completion):
public ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return new ValueTask<User>(user); // zero allocation
return GetUserSlowAsync(id);
}
private async ValueTask<User> GetUserSlowAsync(int id)
{
var user = await _database.GetUserAsync(id);
_cache[id] = user;
return user;
}
On the synchronous path, ValueTask<User> wraps the result directly — no heap allocation. On the asynchronous path, it wraps a Task<User> internally, so there is no additional overhead compared to returning Task<User>.
When ValueTask Helps
ValueTask<T> is beneficial when:
- The method frequently completes synchronously. Cache lookups, buffered I/O, and pooled connections often return immediately.
- The method is called at very high frequency. The allocation savings compound at thousands of calls per second.
- Profiling shows Task allocations as a bottleneck. Do not optimise speculatively.
When to Stick with Task
Task<T> is safer and more flexible. Prefer it unless you have a specific reason to use ValueTask<T>:
Task<T>can be awaited multiple times.ValueTask<T>cannot.Task<T>can be stored and awaited later.ValueTask<T>should be awaited immediately.Task<T>can be used withTask.WhenAll.ValueTask<T>requires conversion first.
// SAFE — Task can be awaited multiple times
Task<int> task = GetValueAsync();
int a = await task;
int b = await task; // fine
// DANGEROUS — ValueTask must only be awaited once
ValueTask<int> vt = GetValueAsync();
int a = await vt;
int b = await vt; // UNDEFINED BEHAVIOUR
The Rules of ValueTask
The .NET documentation is explicit about ValueTask<T> constraints:
- Never await a ValueTask more than once.
- Never use
GetAwaiter().GetResult()before the ValueTask completes. - Never use
.Resulton an incomplete ValueTask. - If you need to do any of these things, call
.AsTask()first.
// If you need Task-like flexibility, convert immediately
ValueTask<int> vt = GetValueAsync();
Task<int> task = vt.AsTask(); // now it's a regular Task
IValueTaskSource: Advanced Pooling
For maximum performance, you can implement IValueTaskSource<T> to pool the underlying state machine objects. This is what System.IO.Pipelines does internally:
public class PooledOperation : IValueTaskSource<int>
{
private ManualResetValueTaskSourceCore<int> _core;
public int GetResult(short token) => _core.GetResult(token);
public ValueTaskSourceStatus GetStatus(short token) =>
_core.GetStatus(token);
public void OnCompleted(
Action<object?> continuation,
object? state,
short token,
ValueTaskSourceOnCompletedFlags flags) =>
_core.OnCompleted(continuation, state, token, flags);
public void SetResult(int result) => _core.SetResult(result);
public void Reset() => _core.Reset();
public ValueTask<int> AsValueTask() =>
new(this, _core.Version);
}
This is advanced territory — only Socket, Pipe, and similar high-throughput infrastructure typically need this.
Benchmark Comparison
[MemoryDiagnoser]
public class TaskVsValueTask
{
[Benchmark]
public async Task<int> WithTask() => 42;
[Benchmark]
public async ValueTask<int> WithValueTask() => 42;
[Benchmark]
public ValueTask<int> WithValueTaskSync() => new(42);
}
Typical results:
| Method | Mean | Allocated |
|---|---|---|
| WithTask | ~25 ns | 88 B |
| WithValueTask | ~25 ns | 88 B |
| WithValueTaskSync | ~0 ns | 0 B |
The key insight: async ValueTask<T> still allocates a state machine when it actually awaits. The win comes only from the synchronous path where you return new ValueTask<T>(result) directly.
Summary
Use ValueTask<T> when a method frequently completes synchronously and is called at high frequency. Use Task<T> everywhere else. Never await a ValueTask more than once. And always benchmark — the allocation savings from ValueTask<T> are only meaningful in genuinely hot paths.