ValueTask and `ValueTask<T>`: allocation-free synchronous completion, IValueTaskSource pooling, consumption rules and when Task is still the better choice.
ValueTask<T> is a struct that holds either a result, a Task<T> or an IValueTaskSource<T>. It avoids allocating a task when a method usually completes synchronously (cache hits, buffered reads) and lets libraries pool the async case. The cost is stricter rules: await it once, never await it concurrently, and never read .Result before it completes; call .AsTask() if you need any of that. Use Task by default and choose ValueTask for measured hot paths or when an interface already uses it, like Stream.ReadAsync(Memory<byte>) or IAsyncDisposable.DisposeAsync.
"
ValueTask<T>is a struct that can hold one of three things: the result itself, aTask<T>, or anIValueTaskSource<T>plus a token. It exists for two reasons: to avoid allocating aTask<T>when a method usually completes synchronously (a buffered read, a cache hit, a channel that already has data), and to let libraries reuse one source object for many asynchronous operations. The price is stricter consumption rules: await it once, never await it from two places, never read.Resultbefore it's complete, and if you need to store it, combine it withWhenAllor await it twice, call.AsTask()once and use the task. My default isTask. I chooseValueTaskon measured hot paths with frequent synchronous completion, or where the API already requires it, likeStream.ReadAsync(Memory<byte>),IAsyncEnumerator<T>.MoveNextAsyncandIAsyncDisposable.DisposeAsync."
| Aspect | Task<T> |
ValueTask<T> |
|---|---|---|
| Kind | Class (heap object) | Struct (wraps result / Task<T> / IValueTaskSource<T>) |
| Synchronous completion | Allocates (except a few runtime-cached results) | No allocation |
| Asynchronous completion | Allocates the task (+ state machine box) | Allocates unless backed by a pooled IValueTaskSource |
| Await multiple times | Yes | No |
| Concurrent awaits | Yes | No |
.Result before completion |
Blocks | Not allowed |
WhenAll / caching / storing |
Yes | Only after .AsTask() |
| Size when passed around | One reference | Larger struct (copy cost) |
| Default choice | Yes | Hot paths / API-mandated |
| Do | Don't |
|---|---|
var x = await GetAsync(); |
var vt = GetAsync(); await vt; await vt; |
var t = GetAsync().AsTask(); then await/combine/store t |
Store a ValueTask in a field and await it later from elsewhere |
Check vt.IsCompletedSuccessfully then read vt.Result |
Read .Result / GetAwaiter().GetResult() on an incomplete ValueTask |
Convert immediately before Task.WhenAll |
Convert late after already awaiting it |
Why the rules exist: a pooled IValueTaskSource is reset and reused for the next operation once you've consumed the result. A second await on the old ValueTask can throw, hang, or, worst of all, return another operation's result.
| Scenario | Choice | Reason |
|---|---|---|
| Buffered reader / parser where most calls hit the buffer | ValueTask<T> |
Synchronous fast path is allocation-free |
| Cache-first lookups on hot paths | ValueTask<T> |
Hits don't allocate |
Implementing Stream, IAsyncDisposable, IAsyncEnumerator<T> |
ValueTask |
Required by the interface |
| Typical service method doing a DB/HTTP call every time | Task<T> |
Always async: no saving, stricter rules for nothing |
Results combined with WhenAll or cached |
Task<T> |
Composability |
| Extreme-throughput infrastructure (sockets, channels) | IValueTaskSource<T> / pooling builder |
Reuse across async completions; measured |
Since .NET 6 / C# 10 you can opt a method into pooled state-machine boxes:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<int> ReadAsync() { ... }
That removes allocations even when the method completes asynchronously, at the cost of pool management overhead and memory retained in pools. Benchmark under realistic concurrency before adopting it.
Our [market data gateway] parsed frames with Task<Frame> ReadFrameAsync() at [about 3 million calls a minute], and [around 90%] of frames were already in the read buffer. dotnet-counters showed a high Gen 0 GC rate, and allocation profiling put Task<Frame> near the top. A BenchmarkDotNet run with [MemoryDiagnoser] confirmed one task allocation per call on the buffered path. I changed it to ValueTask<Frame> with a synchronous fast path that returns the parsed frame directly, then reviewed every caller to make sure each awaited it exactly once (one caller stored it in a field, which I changed to .AsTask()). I enabled analyser CA2012 as an error. Allocations on that path dropped to [nearly zero] and Gen 0 GCs fell by [about 40%].
Checks performance-aware async knowledge and discipline about trade-offs.
Green flags
.AsTask().IValueTaskSource<T> pooling.Task.Red flags
public readonly struct ValueTask<TResult>
{
internal readonly object? _obj; // null, Task<TResult>, or IValueTaskSource<TResult>
internal readonly TResult? _result; // used when _obj is null (synchronous result)
internal readonly short _token; // identifies the operation for an IValueTaskSource
internal readonly bool _continueOnCapturedContext;
}
The awaiter checks _obj: null → return _result; Task<T> → behave like a task; IValueTaskSource<T> → call GetStatus(token), OnCompleted(...), GetResult(token).
IValueTaskSource<T> and token checkssource (reused object)
op #1: token = 5 -> ValueTask(source, 5) -> consumer awaits -> GetResult(5) -> source resets
op #2: token = 6 -> ValueTask(source, 6) -> ...
old ValueTask(source, 5) awaited again -> GetResult(5) on a source now at token 6
-> InvalidOperationException (if checked) or wrong data (if the implementation doesn't check)
ManualResetValueTaskSourceCore<T> (a mutable struct helper) implements the bookkeeping: version/token, continuation, result, RunContinuationsAsynchronously. Sockets (SocketAsyncEventArgs-based AwaitableSocketAsyncEventArgs), System.IO.Pipelines and Channel<T> readers use this pattern.
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
if (_buffered.Length > 0)
{
int n = CopyFromBuffer(buffer.Span);
return new ValueTask<int>(n); // synchronous, no allocation
}
return ReadSlowAsync(buffer, ct); // async path (async ValueTask<int> method)
}
private async ValueTask<int> ReadSlowAsync(Memory<byte> buffer, CancellationToken ct)
{
await FillBufferAsync(ct).ConfigureAwait(false);
return CopyFromBuffer(buffer.Span);
}
Splitting the fast path into a non-async method also avoids state machine overhead entirely when synchronous.
ValueTask<int> vt = reader.ReadAsync(buf, ct);
int n = vt.IsCompletedSuccessfully ? vt.Result : await vt; // read once, either branch
This is the only legitimate case of touching .Result: after confirming completion, and never touching vt again.
| Rule | Catches |
|---|---|
| CA2012 | ValueTask used incorrectly (awaited twice, stored, discarded) |
| CA1849 / VSTHRD103 (VS threading analyzers) | Blocking calls in async methods |
| Version | Change |
|---|---|
| .NET Core 2.0 | ValueTask<T> (result or Task<T>) |
| .NET Core 2.1 | IValueTaskSource<T>, non-generic ValueTask |
| .NET Core 3.0 | ManualResetValueTaskSourceCore<T>; IAsyncEnumerable<T>/IAsyncDisposable use ValueTask |
| .NET 5 | ValueTask.FromResult, ValueTask.CompletedTask, FromException, FromCanceled |
| .NET 6 / C# 10 | PoolingAsyncValueTaskMethodBuilder, method-level [AsyncMethodBuilder] |
| .NET 10 | Same rules |
Task<int> vs ValueTask<int> for a cache hit with [MemoryDiagnoser]; note allocated bytes.Task.Yield(); observe no saving.[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] to the always-async version and re-measure.Channel<int>.Reader.ReadAsync() result twice and see what happens.IValueTaskSource<int> using ManualResetValueTaskSourceCore<int> and reuse it for 10 operations.using System.Collections.Concurrent;
var repo = new PriceRepository();
// Correct: await once.
decimal p1 = await repo.GetPriceAsync("ABC");
// Need it twice or in WhenAll? Convert once.
Task<decimal> t = repo.GetPriceAsync("XYZ").AsTask();
await Task.WhenAll(t, Task.Delay(10));
Console.WriteLine($"{p1} {t.Result}");
public sealed class PriceRepository
{
private readonly ConcurrentDictionary<string, decimal> _cache = new();
public ValueTask<decimal> GetPriceAsync(string sku)
{
// Fast path: synchronous, no allocation.
if (_cache.TryGetValue(sku, out var price))
return ValueTask.FromResult(price);
return new ValueTask<decimal>(LoadAsync(sku));
}
private async Task<decimal> LoadAsync(string sku)
{
await Task.Delay(20); // stands in for a database call
decimal price = sku.Length * 10m;
_cache[sku] = price;
return price;
}
}
Wrong usage to avoid:
ValueTask<decimal> vt = repo.GetPriceAsync("ABC");
var a = await vt;
var b = await vt; // undefined behaviour with pooled sources
ValueTask everywhere "for performance"What happens: every service method returns ValueTask<T>.
Why it is wrong: if the method is usually async, there's no saving, and callers get stricter rules and a larger struct.
Do instead: default to Task; switch for measured hot paths with frequent synchronous completion.
ValueTask twiceWhat happens: await vt; in two places or in a retry loop.
Why it is wrong: pooled sources may be reused, giving exceptions or wrong results.
Do instead: await once, or .AsTask() first.
ValueTask in a field or collectionWhat happens: _pending = reader.ReadAsync(); to await later.
Why it is wrong: the backing source may be reset before you consume it.
Do instead: store reader.ReadAsync().AsTask().
.Result before completionWhat happens: var x = vt.Result; on an incomplete ValueTask.
Why it is wrong: not supported for IValueTaskSource backing; can break the source.
Do instead: await, or check IsCompletedSuccessfully first.
ValueTasks to Task.WhenAll lateWhat happens: awaiting some, then converting others for WhenAll.
Why it is wrong: conversion after any consumption violates the once-only rule.
Do instead: call .AsTask() immediately when you know you'll combine.
ValueTaskWhat happens: _ = stream.WriteAsync(data);.
Why it is wrong: errors are lost and pooled sources may never be returned.
Do instead: await it (CA2012 flags this).
ValueTaskWhat happens: two consumers await the same instance from different threads.
Why it is wrong: IValueTaskSource supports a single continuation.
Do instead: .AsTask() and share the task.
What happens: PoolingAsyncValueTaskMethodBuilder added to many methods.
Why it is wrong: pools add overhead and retained memory; gains depend on workload.
Do instead: benchmark under realistic concurrency first.
ValueTask from public application APIs consumers will composeWhat happens: a service interface exposes ValueTask<T> and callers use WhenAll everywhere.
Why it is wrong: pushes complexity and risk to every caller.
Do instead: use Task<T> for general-purpose application APIs.
.GetAwaiter().GetResult() on a ValueTask at sync boundariesWhat happens: blocking on an incomplete ValueTask.
Why it is wrong: not supported for source-backed instances.
Do instead: .AsTask().GetAwaiter().GetResult() if you truly must block.
ValueTask is a library-author tool. I use it in infrastructure code on paths that profiling shows are hot and usually synchronous, and I keep Task in application services where readability and composability matter more.
| Scenario | Choice |
|---|---|
| Buffered reader, cache hit common | ValueTask<T> |
Implementing Stream, IAsyncDisposable, IAsyncEnumerator<T> |
ValueTask (required by the API) |
| Typical service method doing I/O | Task<T> |
Result combined with WhenAll |
Task<T> |
| Extreme throughput, pooled | IValueTaskSource<T> or pooling builder, measured |
Task<T> derives from Task and adds a Result of type T. The safe way to get it is await, which unwraps the value and rethrows the original exception. .Result blocks the calling thread and wraps failures in AggregateException; GetAwaiter().GetResult() also blocks but throws the original exception. Because Task<T> is a class, it is not covariant: a Task<string> is not a Task<object>. Return Task.FromResult for synchronous results and use TaskCompletionSource<T> to turn callbacks into a Task<T>.
The compiler turns an async method into a small stub plus a generated type implementing IAsyncStateMachine. The type holds a state integer, a method builder (such as AsyncTaskMethodBuilder<T>), awaiter fields and any locals that live across an await. The stub initialises it and calls builder.Start, which runs MoveNext synchronously. MoveNext is a switch on state: it runs until an incomplete await, stores the awaiter, sets state, and asks the builder to call MoveNext again on completion. In Release builds the machine is a struct that is only boxed to the heap the first time the method actually suspends, so synchronously completing async methods are cheap.
GC pressure is how much work your code forces the GC to do: driven by allocation rate (how fast you allocate), survival (how much of that stays alive and is promoted) and heap size (how much there is to mark). Measure it with dotnet-counters (% Time in GC, allocation rate, Gen 0/1/2 counts, heap sizes) and GC traces. Reduce it by allocating less in hot paths (spans, pooling, streaming, avoiding boxing and LINQ in tight loops), shortening or stabilising lifetimes, and shrinking long-lived object graphs. Separately, GC.AddMemoryPressure tells the GC about native memory owned by small managed objects so it collects them in time; pair it with RemoveMemoryPressure.
How to tell it (STAR)
In our [market data gateway] gen0 GCs were high, and allocation profiling pointed at the `Task<Frame>` from a read method that usually completed synchronously. I switched it to `ValueTask<Frame>` with a fast path, checked every caller awaited it once, and enabled CA2012. Allocations on that path dropped to [almost zero].
Last updated 31 Oct 2026.