C# ValueTask: When to Use It Instead of Task, and Its Rules
An async method that returns synchronously from a cache allocates 72 bytes per call with Task<int>, and nothing with ValueTask<int>. The Debug versus Release measurement gap, the runtime's small-result cache, the cached and truly asynchronous paths in BenchmarkDotNet, the real InvalidOperationException from awaiting a ValueTask twice on a Channel, AsTask, and when to choose ValueTask.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We wrote a small class that keeps stock data in a cache. The first call goes to the service; the following ones read the result from a dictionary and return without ever reaching an await. The same method comes in two versions: one returns Task<int>, the other ValueTask<int>. With the cache filled, we call each one a million times and measure the memory allocated on that thread. How many bytes per call do you get?
const string Isbn = "978-0441172719";
StockCache cache = new();
await cache.GetStockAsync(Isbn); // the first call goes to the service and fills the cache
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000_000; i++)
{
await cache.GetStockAsync(Isbn);
}
long taskBytes = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000_000; i++)
{
await cache.GetStockValueTaskAsync(Isbn);
}
long valueTaskBytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"Task<int>: {taskBytes / 1_000_000} bytes per call");
Console.WriteLine($"ValueTask<int>: {valueTaskBytes / 1_000_000} bytes per call");
class StockCache
{
private readonly Dictionary<string, int> _stock = new();
public async Task<int> GetStockAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock; // in the cache: no await, completes synchronously
}
stock = await LoadFromServiceAsync(isbn);
_stock[isbn] = stock;
return stock;
}
public async ValueTask<int> GetStockValueTaskAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock;
}
stock = await LoadFromServiceAsync(isbn);
_stock[isbn] = stock;
return stock;
}
private static async Task<int> LoadFromServiceAsync(string isbn)
{
await Task.Delay(100);
return 42;
}
}
Task<T> Is an Object
The output in Release mode:
Task<int>: 72 bytes per call
ValueTask<int>: 0 bytes per call
Every call that returned from the cache allocated 72 bytes in the Task<int> version; over a million calls that is 72 MB, a million short-lived objects for the garbage collector to collect. In the ValueTask<int> version, nothing.
The reason lies in the nature of the types. Task<T> is a class. Even when the method completes synchronously, it has to give the caller a Task object as "the result of the work"; because the result is ready, a completed task is produced, but it is still an object on the heap. ValueTask<T> is a struct and carries one of two things: the result itself if it is ready, or, if it is not, an object that represents the asynchronous work behind it. On the path that returns from the cache, it puts the int inside and allocates nothing.
Think of a restaurant's takeaway counter. If the order is ready, the clerk hands it straight to you. If it is not, they give you a pager and you come back when it buzzes. Task<T> is a counter that gives every customer a pager first, even when the order is ready. ValueTask<T> hands over what is ready and gives you the pager only when you really have to wait.
Watch out for two details when measuring. First, when we ran the same program in Debug mode, Task<int> came out at 136 bytes and ValueTask<int> at 72. In a Debug build the compiler emits the async state machine as a class, to make things easier for the debugger, and it is allocated on every call as well. Always measure allocations in Release. Second, when we changed the value returned by the service from 42 to 4, the Task<int> version also allocated 0 bytes. The runtime keeps completed tasks in a cache for some common results; on the .NET 8 runtime on this machine, that is the case for ints from -1 to 8. This is an implementation detail, not a documented guarantee; do not base your design on it.
Measuring with BenchmarkDotNet
Allocation alone is half the story. To look at time as well, we measured the same paths with BenchmarkDotNet 0.15.8. The second pair is a method that really completes asynchronously on every call: Task.Yield() sends the continuation to the thread pool, so the method never completes synchronously.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StockBenchmarks>();
[InProcess]
[MemoryDiagnoser]
public class StockBenchmarks
{
private const string Isbn = "978-0441172719";
private readonly Dictionary<string, int> _stock = new() { [Isbn] = 42 };
[Benchmark(Baseline = true)]
public Task<int> TaskCacheHit() => GetStockAsync(Isbn);
[Benchmark]
public ValueTask<int> ValueTaskCacheHit() => GetStockValueTaskAsync(Isbn);
[Benchmark]
public Task<int> TaskYield() => YieldTaskAsync();
[Benchmark]
public ValueTask<int> ValueTaskYield() => YieldValueTaskAsync();
private async Task<int> GetStockAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock;
}
await Task.Delay(100);
return 42;
}
private async ValueTask<int> GetStockValueTaskAsync(string isbn)
{
if (_stock.TryGetValue(isbn, out int stock))
{
return stock;
}
await Task.Delay(100);
return 42;
}
// a path that really completes asynchronously on every call
private static async Task<int> YieldTaskAsync()
{
await Task.Yield();
return 42;
}
private static async ValueTask<int> YieldValueTaskAsync()
{
await Task.Yield();
return 42;
}
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------------ |------------:|----------:|----------:|------------:|-------:|--------:|-------:|----------:|------------:|
| TaskCacheHit | 11.01 ns | 0.236 ns | 0.242 ns | 11.04 ns | 1.00 | 0.03 | 0.0086 | 72 B | 1.00 |
| ValueTaskCacheHit | 10.34 ns | 0.186 ns | 0.174 ns | 10.35 ns | 0.94 | 0.03 | - | - | 0.00 |
| TaskYield | 1,178.49 ns | 35.050 ns | 92.336 ns | 1,150.93 ns | 107.13 | 8.66 | 0.0114 | 96 B | 1.33 |
| ValueTaskYield | 1,444.86 ns | 23.279 ns | 54.414 ns | 1,440.75 ns | 131.35 | 5.66 | 0.0114 | 105 B | 1.46 |
The project targets net6.0 and was built in Release mode; since there is no .NET 6 runtime on the machine, it ran in-process on .NET 8.0.25 with DOTNET_ROLL_FORWARD=Major. Absolute numbers vary from machine to machine.
On the path that returns from the cache, the time difference is small: 11.01 ns versus 10.34 ns, about 6 percent. The real difference is in the last two columns: Task<int> allocates 72 bytes on every call, ValueTask<int> allocates nothing. On a path called hundreds of thousands of times per second, that means the garbage collector runs less often. It is not the speed of a single call that changes, but the total memory pressure on the system.
No Gain on the Asynchronous Path
The bottom half of the table is the other side of the story. When the method really waits, both versions allocate: 96 bytes versus 105 bytes. The state machine of an async method that completes asynchronously has to move to the heap, whatever the return type. ValueTask<int> then carries an object inside, and in this run it was even slower: 1.18 µs versus 1.44 µs. The deviation in these rows is high, because most of what is measured is the round trip to the thread pool; but the direction is clear.
The lesson: ValueTask is not a "faster Task." It only pays off on paths where the result is usually ready synchronously. If most calls really wait on a network request or a disk read, you are where you were with Task, and on top of that you have to follow its rules.
The Rules of ValueTask
You can await a Task as many times as you like, put it in a field and wait on it again later, or pass it to WhenAll. None of that is safe with ValueTask. The reason is that the object behind it can be reused. To avoid allocating even on the asynchronous path, high-performance APIs use pooled objects called IValueTaskSource that are used over and over. Once you have taken the result, that object can be handed to the next operation. We can see this behavior inside the BCL itself. ReadAsync on a single-reader Channel returns exactly such an object:
using System.Threading.Channels;
Channel<string> orders = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });
ValueTask<string> read = orders.Reader.ReadAsync(); // the channel is empty, the read is pending
orders.Writer.TryWrite("order-1");
Console.WriteLine($"First await: {await read}");
try
{
Console.WriteLine($"Second await: {await read}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Second await: {ex.Message}");
}
Task<string> readTask = orders.Reader.ReadAsync().AsTask();
orders.Writer.TryWrite("order-2");
Console.WriteLine($"Task, awaited twice: {await readTask}, {await readTask}");
First await: order-1
Second await: The result of the operation was already consumed and may not be used again.
Task, awaited twice: order-2, order-2
The first await gave us the result. When we waited on the same ValueTask a second time, we got an InvalidOperationException: the result had already been consumed. We were lucky this time; the exception came with a clear message. With an IValueTaskSource you wrote yourself, or one from another library, the same bug can also show up as a wrong result, that is, the value of the next operation.
There are three rules. await a ValueTask only once. Do not wait on it from more than one place at the same time. Do not read its result with .Result or .GetAwaiter().GetResult() unless you are sure it has completed. The safest usage is to await the method right on the line where you call it: int stock = await cache.GetStockValueTaskAsync(isbn);.
If you really need to wait on the result more than once, store it, or pass it to Task.WhenAll, convert it to a Task with AsTask(). As you saw in the last part of the example, waiting on a Task twice is fine. AsTask() brings an allocation on the asynchronous path, but an allocation that works correctly beats an optimization that works incorrectly.
When to Choose ValueTask
The default answer is Task. Using ValueTask everywhere fills your code with rules and, most of the time, brings no measurable gain. ValueTask<T> itself is a struct larger than a reference; every copy of it has a small cost.
Choose ValueTask when these conditions hold together: the method is on a hot path, called very often; most calls complete synchronously (a cache, data already in a buffer, a connection that is already open); and you have measured that allocation is a real problem. Another legitimate place is interface design. An interface method like ValueTask<int> GetStockAsync(string isbn, CancellationToken cancellationToken = default) lets a cached implementation return without allocating, while an uncached implementation can satisfy the same signature with a Task behind it.
The BCL itself uses ValueTask in exactly these kinds of places: Stream.ReadAsync(Memory<byte>, ...), ChannelReader<T>.ReadAsync, IAsyncDisposable.DisposeAsync and the MoveNextAsync of async iterators. Async methods can return a type other than Task thanks to the generalized async return types feature that came with C# 7.0; the non-generic ValueTask was added in .NET Core 2.1.
Common Pitfalls
Awaiting a ValueTask twice, or putting it in a field and waiting on it later. The object behind it may have been reused; you get an exception or a wrong result.
Trying to pass ValueTasks straight to Task.WhenAll or Task.WhenAny. These APIs want a Task; convert with AsTask() first.
Switching to ValueTask without measuring. If most calls complete asynchronously, there is no gain, and even a small loss.
Measuring allocations in Debug mode. In a Debug build the state machine itself is allocated too, and the numbers are misleading.
Relying on the cache the runtime uses for small results. It is an implementation detail; it can change from version to version.
A Small Experiment
★☆☆ Run the first example in Debug mode first, then in Release mode (dotnet run -c Release), and compare the bytes per call.
★★☆ Change the value returned by the service from 42 to 8, then to 9, and measure the allocation of the Task<int> version for each. What do you observe? Repeat the experiment with Task<decimal>.
★★★ Add a third scenario to the benchmark: 90 percent of calls return from the cache and 10 percent return asynchronously with Task.Yield(). How does the difference between Task and ValueTask turn out under this mixed load?
With ValueTask we lowered the cost of a single async result. But what if the result is not a single value but a sequence that arrives piece by piece over time? In the next post we look at IAsyncEnumerable<T>, await foreach and async dispose; why MoveNextAsync returns a ValueTask<bool> will fall into place there too.