Task vs ValueTask: When the Default Starts Costing You

Profile a busy .NET service and sort the allocations by type. If Task<T> shows up near the top, look at which methods created it. Often they never waited for anything: a cache hit or a buffered read, with the value in memory the whole time.
Nobody wrote a bug. They wrote async Task<T>, which is what the templates and docs tell you to write. For most code, that is the right call.
But Task<T> is a class. Every time an async method finishes synchronously with a result the runtime hasn’t cached, you pay for a heap object you didn’t need. On a path that runs a few times per request, nobody notices. On a path that runs a million times per second, the garbage collector notices for you.
ValueTask<T> exists for that case, and the advice around it is split. Stephen Toub, who designed much of it, says the default is still Task. Marc Gravell, author of Dapper and StackExchange.Redis, says to prefer ValueTask always. Both agree that ValueTask puts a contract on every caller, and that contract decides whether switching is a win.
TL;DR
Task<T>is the default: flexible and hard to misuse.Task<T>is a class, so a method that completes synchronously with an uncached result still allocates. On hot paths, that turns into GC pressure.ValueTask<T>is a struct. When the result is already available, it carries the value directly and allocates nothing.- The price is a stricter contract: await it once, never concurrently and never block on it.
- Switch only when you have measured allocation pressure and your callers only ever
awaitthe result directly.
Task is built for sharing, and you pay for that
A Task<T> is a promise object on the heap. Once it completes, it never changes again. That one property buys a lot of flexibility:
- any number of callers can await it, at the same time or an hour later
- you can store it in a dictionary and use it as a cache of in-flight work
- you can block on
.Resultwhen you have no other option Task.WhenAllandTask.WhenAnywork on it directly
Most call sites use none of this. They await once, right away, then drop the task:
decimal price = await priceService.GetPriceAsync(sku);
The runtime already removes the cheapest allocations. An async Task method that completes synchronously returns the cached Task.CompletedTask. A Task<bool> returns one of two cached instances. Task<int> has a cache too, but only for the values -1 through 8 (see TaskCache.cs in dotnet/runtime). Return 42 synchronously and you get a fresh allocation, just like Task.FromResult(42).
Some libraries go further. MemoryStream.ReadAsync remembers the last Task<int> it returned and hands it back when the byte count matches. That works because streams tend to return the same count over and over. It does not help a cache that returns a different price on every call.
The cost hides in the synchronous path
The asynchronous part is fine. A method that goes to the network needs an object that outlives the call, and paying for that object is fair.
The problem is the method that usually doesn’t go anywhere. A cache lookup is the classic case:
public sealed class PriceCache(IPriceApi api)
{
private readonly ConcurrentDictionary<string, decimal> _prices = new();
public async Task<decimal> GetPriceAsync(string sku, CancellationToken ct = default)
{
if (_prices.TryGetValue(sku, out var price))
{
return price; // cache hit: completes synchronously, still allocates a Task<decimal>
}
price = await api.FetchPriceAsync(sku, ct);
_prices[sku] = price;
return price;
}
}
If 99% of calls hit the cache, 99% of calls allocate a Task<decimal> to carry a value that was already in memory. On .NET 10, BenchmarkDotNet measures that at 80 bytes per cache hit. Small on its own. Multiply it by every lookup in a busy service and you get more gen0 collections and more CPU time spent cleaning up objects nobody needed.
Buffered readers, parsers, protocol handlers and middleware share the same shape: a fast synchronous path with an occasional asynchronous refill.
ValueTask carries the result when it is already there
ValueTask<T> is a readonly struct that wraps one of three things:
- a
T, when the method completed synchronously. Nothing is allocated. - a
Task<T>, when the method completed asynchronously or threw. - an
IValueTaskSource<T>, a reusable object that can be pooled.
Change the return type of the cache method and the hit path becomes free:
public async ValueTask<decimal> GetPriceAsync(string sku, CancellationToken ct = default)
{
if (_prices.TryGetValue(sku, out var price))
{
return price; // cache hit: no allocation
}
price = await api.FetchPriceAsync(sku, ct);
_prices[sku] = price;
return price;
}
Same benchmark, same machine: zero bytes per cache hit. The caller’s code does not change. await cache.GetPriceAsync(sku) compiles and behaves the same way.
This only helps the synchronous path. When the method has to wait, the default async machinery still allocates a Task<T> behind the ValueTask<T>. The savings come from giving something up: a ValueTask<T> can do less than a Task<T>, and its rules are stricter.
The BCL uses it where that trade pays off. Stream.ReadAsync(Memory<byte>) returns ValueTask<int>. Socket.ReceiveAsync and Socket.SendAsync return ValueTask<int> and reuse pooled objects, so they don’t allocate even when they complete asynchronously. IAsyncEnumerator<T>.MoveNextAsync() returns ValueTask<bool> and IAsyncDisposable.DisposeAsync() returns ValueTask.
A non-generic ValueTask exists too, since .NET Core 2.1. For synchronous completion it buys you nothing, because Task.CompletedTask is already free. It only matters once pooling enters the picture.
ValueTask comes with a stricter contract
A Task never changes after it completes. A ValueTask might be backed by an object that has already gone back to a pool and been handed to another caller. That changes what callers are allowed to do. Never:
- await the same
ValueTasktwice - await it from two places concurrently
- call
.Resultor.GetAwaiter().GetResult()before you know it has completed
Each of these works with a Task. With a pooled ValueTask, they can throw or worse, return another caller’s result. And because most ValueTask instances are backed by a plain value or a Task, the bug can sit in your code for months until someone switches the implementation to pooling.
The rule is short: either await the ValueTask directly (optionally with ConfigureAwait(false)) or call .AsTask() on it once. After that, never touch it again.
// Fine: await directly
decimal price = await cache.GetPriceAsync(sku);
// Fine: convert once when you need Task semantics
Task<decimal> shared = cache.GetPriceAsync(sku).AsTask();
await Task.WhenAll(shared, otherWork);
// Broken: consumes the same ValueTask twice
ValueTask<decimal> pending = cache.GetPriceAsync(sku);
decimal first = await pending;
decimal second = await pending;
Most of these bugs start with a ValueTask stored in a local variable. Treat that as a code review flag.
On the hottest paths, one shortcut is legal: check IsCompletedSuccessfully, read .Result when it is true and await otherwise. SocketsHttpHandler does this to skip registering for cancellation when a read completes synchronously. It works because the instance is consumed once on either branch. Save it for code a profiler has pointed you to.
The compiler can enforce the rules. Code analysis rule CA2012, “Use ValueTasks correctly”, flags double awaits, discarded ValueTasks and early .Result access. It ships as a suggestion, so it does not show up in a normal build: a plain dotnet build of the broken snippet above reports zero warnings. Raise the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.CA2012.severity = error
Pooling removes the async allocation, at a price
For methods that have to wait, the third option comes into play: an IValueTaskSource<T> that can be reused across calls. Since .NET 6 and C# 10 you can opt an ordinary async method into pooled sources by swapping its builder:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
private static async ValueTask<int> ReadNextAsync()
{
await Task.Yield();
return 42;
}
In a .NET 6 benchmark, this cut the allocation from 112 bytes per call to zero and made the call about 2% slower. Microsoft left it opt-in on purpose. Pooling turns the once-only rule from advice into a hard requirement, and pooled objects live long enough to make gen0 collections more expensive. Keep it for the few methods at the top of your profile, backed by a benchmark that shows a win in time as well as bytes.
Task and ValueTask side by side
Task<T> | ValueTask<T> | |
|---|---|---|
| Type | Class | Struct |
| Synchronous completion | Allocates, unless the result is cached | No allocation |
| Asynchronous completion | Allocates a Task<T> | Allocates a Task<T>, unless backed by a pooled IValueTaskSource<T> |
| Await more than once | Yes | No |
| Concurrent awaits | Yes | No |
.Result before completion | Blocks until done | Not supported |
Task.WhenAll, caching, sharing | Directly | Only after .AsTask() |
| Raw await cost | Slightly lower | Slightly higher |
| Size in the async state machine | One reference | A few fields |
| Easy to use correctly | Yes | Needs discipline and CA2012 |
| Available since | .NET Framework 4 | .NET Core 2.0 (generic), 2.1 (non-generic and IValueTaskSource) |
Task is still the right default
Task<T> remains the right return type when:
- the method is asynchronous most of the time (a network call, a database round-trip) with no cache in front
- the result is already cached for free:
Task,Task<bool>, small integers - callers combine, store, share or block on the result
- it is application code where a few bytes per call will never show up in a profile
- nobody has measured anything
Switch to ValueTask<T> when a profile shows allocations from a method that usually completes synchronously, and its callers only ever await it.
Measure before you change a signature
In development, BenchmarkDotNet with the memory diagnoser shows allocated bytes per operation next to the timing:
[MemoryDiagnoser]
public class PriceCacheBenchmarks
{
private readonly PriceCache _cache = new(new FakePriceApi());
[GlobalSetup]
public async Task Setup() => await _cache.GetPriceAsync("sku-42");
[Benchmark(Baseline = true)]
public Task<decimal> CacheHit_Task() => _cache.GetPriceAsTaskAsync("sku-42");
[Benchmark]
public ValueTask<decimal> CacheHit_ValueTask() => _cache.GetPriceAsync("sku-42");
}
The benchmark methods return the task directly instead of awaiting it. An async Task<decimal> wrapper would allocate its own Task<decimal> and hide the difference you are trying to measure.
The run behind this article, on .NET 10:
| Method | Allocated per call |
|---|---|
CacheHit_Task | 80 B |
CacheHit_ValueTask | 0 B |
In production, watch the allocation rate and gen0 collection count with dotnet-counters monitor --process-id <pid> --counters System.Runtime before and after the change. If neither moves, you paid the contract cost for nothing.
Make the decision early. Changing a public method from Task<T> to ValueTask<T> is a binary breaking change, and it also changes what callers may do with the result. That is cheap on day one and expensive after the API ships.
Return types are contracts
The return type of an async method is a promise about how callers may use the result.
Task<T> promises a lot: share it, store it, combine it, block on it. ValueTask<T> promises less, and that smaller promise is what lets the implementation reuse memory. Choosing between them is an API design decision. It deserves the same review as choosing between IEnumerable<T> and List<T> in a public signature.
For a team, that means defaulting to Task and asking for a measurement before a ValueTask lands on a public API.
Choose deliberately
Task<T> costs you a few bytes on the fast path. ValueTask<T> saves those bytes and hands the risk to every caller.
Before you switch, answer two questions. Have you measured the allocation pressure? Can every caller follow the contract? If either answer is no, keep Task.
Sources
- Understanding the Whys, Whats, and Whens of ValueTask, Stephen Toub, .NET Blog (2018)
- Performance Improvements in .NET 6, Stephen Toub, .NET Blog (2021), section on async ValueTask pooling
- Prefer ValueTask to Task, always; and don’t await twice, Marc Gravell (2019)
- Task<T> vs ValueTask<T>, r/csharp discussion (2025)
- What is ValueTask? Why ValueTask Could Be Your Key to Faster Async Code?, Spyros Ponaris, dev.to
- CA2012: Use ValueTasks correctly, Microsoft Learn
- ValueTask<TResult> Struct, Microsoft Learn
- PoolingAsyncValueTaskMethodBuilder<TResult> Struct, Microsoft Learn
- TaskCache.cs, dotnet/runtime source (cached
Task<bool>andTask<int>values) - dotnet-counters, Microsoft Learn
- BenchmarkDotNet, project documentation
Thank you for reading.
-Bruno





