ValueTask C# Double-Await Bug — Duplicate Payments
Awaiting a pooled ValueTask twice silently corrupts data, causing duplicate payments.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- ValueTask
is a struct that eliminates heap allocations on synchronous fast paths - Internals: stores T directly in struct fields; async path wraps Task or IValueTaskSource
- Performance: synchronous return is 0 bytes allocated vs ~96 bytes for Task.FromResult
- Production trap: awaiting more than once causes InvalidOperationException or silent data corruption
- Use it for cache-hit-heavy methods, not for always-async code
Imagine a restaurant kitchen where every order form is a heavy metal tray that must be washed after each use. Task<T> uses a new tray every time, even if the order is just a glass of water. ValueTask is a reusable paper napkin for simple orders — fast and no cleanup — but if you hand that same napkin to two waiters, the second one gets yesterday's scribbles. In a payment system, that means charging a customer twice for the same purchase.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every .NET application that does any I/O — database queries, HTTP calls, file reads — leans heavily on async/await. Task<T> is the workhorse of that system, and it works brilliantly. But there's a hidden cost baked into every Task: a heap allocation. For the vast majority of application code, that cost is irrelevant. For high-throughput library code — think ASP.NET Core's Kestrel web server, gRPC pipelines, or a caching layer handling millions of requests per second — those allocations become the bottleneck that separates 50,000 RPS from 500,000 RPS.
ValueTask was introduced in .NET Core 2.0 (and backported via the System.Threading.Tasks.Extensions NuGet package for .NET Standard) specifically to solve the 'synchronous fast path' problem. When a method is async but frequently returns a cached or already-computed result without ever actually suspending, wrapping that result in a full Task object is wasteful. ValueTask is a struct — it lives on the stack or inline in another object — so when the result is synchronous, there's zero heap allocation at all. When the result truly is asynchronous, ValueTask can delegate to a pooled IValueTaskSource, avoiding a fresh heap allocation even in the async path.
By the end of this article you'll understand exactly how ValueTask works at the struct and IValueTaskSource level, when to reach for it versus Task, how to benchmark the difference yourself, and — critically — the three production mistakes that will cause hard-to-diagnose bugs if you get them wrong.
What ValueTask Actually Does — And Why Double-Await Breaks Payments
ValueTask
In practice, ValueTask
Use ValueTask
AsTask() if you need multiple awaits or storage.AsTask() when you need to await, cache, or pass the operation more than once.How Task Allocates and Why That Hurts at Scale
Before ValueTask makes sense, you need to feel the pain it solves. Every time you write return Task.FromResult(value), the runtime allocates a new Task
In a hot path — say, a method called 100,000 times per second where 95% of calls hit an in-memory cache — you're creating 95,000 Task objects per second that immediately become garbage. Each collection pause, however brief, adds latency jitter. Kestrel's design documents explicitly cite this as why ValueTask was adopted throughout the pipeline.
The struct nature of ValueTask is the key. A struct value type doesn't need a heap allocation on its own — it can sit inside another struct, on the stack frame, or inline in a class field. When your method returns synchronously, ValueTask
ValueTask Internals — The Struct Layout and IValueTaskSource
ValueTask<T> is defined in the BCL as a readonly struct with three fields: an object? _obj, a T _result, and a short _token. This tiny layout is the key to understanding every rule about using it correctly.
When _obj is null, the value is synchronous and _result holds the answer directly — zero indirection, zero heap lookup. When _obj is a Task<T>, you're wrapping a standard task — same allocation as before, but at least the API stays uniform. When _obj implements IValueTaskSource<T>, you're holding a reference to a pooled object — this is the advanced path used by .NET's own I/O pipelines via AwaitableSocketAsyncEventArgs and similar types.
IValueTaskSource<T> is the interface that enables the pooling trick. An object implementing it can be returned from a pool, used for one await cycle, then returned to the pool. The _token field is a version counter — it increments each time a pooled source is recycled. This is why the 'only await once' rule exists: if you await a ValueTask a second time after the source has been recycled and reissued to another caller, the _token will have changed and you'll either get an InvalidOperationException (if the runtime checks it) or silent data corruption (if it doesn't). This is not hypothetical — it's a documented, real bug class.
.AsTask() if you need to await more than once.IValueTaskSource.GetResult().AsTask() converts to safe Task.Task vs ValueTask — Decision Rules You Can Actually Apply in Code Reviews
The single biggest mistake developers make with ValueTask is using it everywhere because it sounds 'better'. It isn't always. ValueTask introduces real constraints: no awaiting twice, no blocking with .Result or ., and a struct-copy footgun. If you misuse it, you don't get a compiler error — you get a runtime bug under load.GetAwaiter().GetResult()
Here's the mental model: ValueTask earns its keep when a method has a synchronous fast path that's hit significantly more often than the async path. The classic examples are cache lookups, buffer reads from a pipe that's already filled, and semaphore acquisitions that rarely actually wait. The BCL uses it for Stream.ReadAsync, Socket.ReceiveAsync, and all of System.IO.Pipelines for exactly this reason.
Task is the right choice when: the method is almost always genuinely async, when multiple callers will await the same result (fan-out), when you need to call .Result or .Wait() synchronously (don't, but sometimes you inherit legacy code), or when the method is simple application-layer code where the allocation cost is unmeasurable next to actual I/O latency. Don't let premature optimization drive you to ValueTask in your UserService. Do use it in a high-frequency cache abstraction you're building.
AsTask() immediately. It costs one Task allocation but makes the semantics unambiguous. In library code where you control both sides of the API, you can stay pure ValueTask and guarantee single-await. In application code sharing results between methods, convert early.AsTask() calls.AsTask() immediately.Benchmarking, Async State Machine Impact, and the Non-Generic ValueTask
A ValueTask returned from a non-async method (one that returns new ValueTask<T>(value)) genuinely has zero allocation overhead. But there's a subtlety: if your method is marked async, the compiler generates a state machine struct regardless of whether you use Task or ValueTask. That state machine itself gets heap-allocated when the method suspends. So async ValueTask<T> only avoids the Task wrapper allocation — the state machine allocation still occurs if you await.
This means the allocation win of ValueTask is exclusively on the synchronous, non-awaiting fast path. If your method is always async and always suspends, ValueTask gives you no benefit at all over Task — and adds cognitive overhead with its constraints. Measure before you change.
The non-generic ValueTask (without <T>) was added alongside ValueTask<T> and serves async void-style fire-and-forget operations that should still be awaitable. Think of it as a zero-allocation replacement for Task (not Task<T>) in methods that frequently complete synchronously — like a FlushAsync that's usually a no-op because the buffer is empty. Use [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] on your method in .NET 6+ to also pool the state machine itself, squeezing out even the state machine allocation on the async path.
Task.Delay() will still allocate the state machine heap object — ValueTask only removes the extra Task wrapper allocation on top. The pooled builder ([AsyncMethodBuilder]) removes even that in .NET 6+.ValueTask and AsyncLocal: The Hidden Bug
AsyncLocal<T> flows logical execution context across async boundaries. With Task, the flow is well-understood — AsyncLocal values propagate through the Task's internal execution context. With ValueTask backed by an IValueTaskSource, there's a subtle trap: when the source is recycled, the AsyncLocal values may be stale or belong to a different operation.
Consider a logging framework that stores a CorrelationId in AsyncLocal<string>. If you await a recycled IValueTaskSource, the continuation may run on a different logical context, and the AsyncLocal value might be from the previous invocation that recycled the source. This leads to log correlation failures — the typical symptom is logs showing inconsistent correlation IDs across the same request.
The fix is to capture the logical call context before creating the ValueTask if you must reuse sources, or simply avoid storing ValueTask across awaits. This is another reason why the single-await rule is critical: each await should be the only one on that ValueTask, and the AsyncLocal context flows correctly because the continuation is tied to the original caller's execution context.
ExecutionContext.Capture() and run the continuation under the original context. Better yet, avoid storing ValueTask in any shared state.ValueTask and Object Pools: You're Probably Still Allocating on Every Call
Most engineers think switching from Task to ValueTask fixes heap allocations. It doesn't. ValueTask only removes allocation when it completes synchronously. If your method awaits anything, you still burn memory — sometimes worse because ValueTask wraps a Task internally when it hits the slow path. The real win comes from combining ValueTask with object pooling via IValueTaskSource. Without it, you're just paying the struct tax for no benefit. Here's the production reality: if your cache or database call completes synchronously 90% of the time, ValueTask saves heap pressure. If it's async more than half the time, you're actually hurting throughput. Pooling swaps the allocation from per-call to a reusable slot — but you must reset state manually or corrupt the next caller. Never pool without a completion callback. Never reuse a ValueTask that's still being awaited. That's how you get silent data corruption in production. The benchmark numbers look great in isolation. They hide the memory thrash you'll see under sustained load.
Reset() before each SetResult(). Forget this and the next awaiter gets the previous call's data. We caught this in a cache layer after three hours of partial read corruptions. Every pool consumer must treat the source as dirty after use.The AsyncLocal Leak: Why Your Thread Pool Becomes a Poison Cabinet
AsyncLocal flows state across async calls. It's convenient for request IDs or tenant context. But ValueTask breaks in ways Task never did. Because Task
Definition — ValueTask Bakes the Task Contract Into a Struct
ValueTask is a struct wrapper that can hold either a Task or a result directly, enabling zero-allocation returns when the result is synchronously available. Unlike Task<T>, which always requires a heap allocation, ValueTask<TResult> stores the result inline when it completes synchronously — a critical difference for high-throughput code paths. The struct layout contains a TResult field, a short circuit flag, an IValueTaskSource, and a Task. When the async method completes without suspension, the consumer retrieves the value directly from the struct, skipping the GC entirely. If the method suspends, the ValueTask wraps a promise object — either a Task or an IValueTaskSource — and the consumer must await exactly once. The non-generic ValueTask follows the same pattern but returns no result. This design directly attacks allocation pressure: a synchronous hot path that produces a ValueTask<int> allocates zero bytes, while Task<int> allocates at least 24 bytes per call. Understanding this fundamental difference lets you predict allocation patterns before running a profiler.
Properties — IsCompletedSuccessfully Is the Only Safe Read
ValueTask<TResult> exposes four properties: IsCompletedSuccessfully, IsCompleted, IsFaulted, and IsCanceled. IsCompletedSuccessfully is the only property safe to call without consuming the instance — it returns true when the operation completed synchronously and the result is ready. The other three properties (IsCompleted, IsFaulted, IsCanceled) internally call GetResult on the backing source, which invalidates the promise for reuse. Reading them before awaiting throws InvalidOperationException with IValueTaskSource-backed implementations, or silently corrupts pooled source reuse in object-pool patterns. The synchronous path is safe: IsCompletedSuccessfully checks the _source field for null and the _task for completion without side effects. Non-generic ValueTask lacks properties entirely — you only get the .Preserve() method. In code reviews, flag any use of IsFaulted or IsCanceled on ValueTask — they force allocation and break pooling. Prefer direct await with try/catch, or call Preserve() to convert to a Task if you need multiple inspections.
IValueTaskSource for Pooling ValueTasks
The IValueTaskSource<T> interface enables pooling of ValueTask<T> instances, eliminating allocations entirely. Instead of creating a new ValueTask<T> each time an async method is called, you reuse a pooled object that implements IValueTaskSource<T>. This is critical in high-throughput scenarios like payment processing where every allocation adds GC pressure.
To implement pooling, create a class that implements IValueTaskSource<T> and manages its own state. Use ManualResetValueTaskSourceCore<T> as a helper to handle the core logic. The pool typically uses ObjectPool<T> from Microsoft.Extensions.ObjectPool.
Example: ```csharp public class PooledValueTaskSource<T> : IValueTaskSource<T> { private ManualResetValueTaskSourceCore<T> _core;
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 T GetResult(short token) => _core.GetResult(token);
public void Reset() => _core.Reset(); public void SetResult(T result) => _core.SetResult(result); public void SetException(Exception exception) => _core.SetException(exception); }
// Usage with pool var pool = new DefaultObjectPool<PooledValueTaskSource<PaymentResult>>( new DefaultPooledObjectPolicy<PooledValueTaskSource<PaymentResult>>()); var source = pool.Get(); source.SetResult(paymentResult); return new ValueTask<PaymentResult>(source, source.Version); ```
Key points: Always reset the source before returning it to the pool. Use a version token to detect double-await. Never await a pooled ValueTask<T> more than once—it corrupts the state for the next consumer.
ValueTask vs Task: When Each Is Appropriate
Choosing between ValueTask<T> and Task<T> depends on the expected completion pattern and allocation sensitivity.
Use Task<T> when: - The method is likely to complete asynchronously (e.g., I/O-bound). - The method will be awaited multiple times (e.g., Task.WhenAll). - The method is part of a public API where consumers may cache or combine the task. - You need to pass the task to other methods that expect Task<T>.
Use ValueTask<T> when: - The method frequently completes synchronously (e.g., cached results, fast operations). - The method is called at high frequency and you want to avoid allocations. - The method is internal or you control all consumers. - You are implementing an async method that returns a pooled object.
Decision rules for code reviews: 1. If the method returns a Task<T> and is synchronous in the common case, consider ValueTask<T>. 2. If the method returns ValueTask<T>, ensure it is awaited only once and never stored in a field or passed to Task.WhenAll. 3. If the method is part of a library, prefer Task<T> unless you document the restrictions.
Example: ```csharp // Good: synchronous completion, high-frequency call public ValueTask<int> GetCachedCountAsync() { if (_cache.TryGetValue(out int count)) return new ValueTask<int>(count); return new ValueTask<int>(LoadCountAsync()); }
// Bad: ValueTask used in public API without restrictions public ValueTask<User> GetUserAsync(int id) // Consumers may misuse ```
Benchmark: ValueTask<T> with synchronous completion allocates 0 bytes vs Task.FromResult<T> which allocates a Task<T> object (approx 40 bytes).
ValueTask Restrictions and Common Mistakes
ValueTask<T> is a struct that can wrap either a T result, a Task<T>, or an IValueTaskSource<T>. This design imposes several restrictions that developers often overlook.
Restrictions: 1. Single await only: A ValueTask<T> instance must be awaited exactly once. Awaiting it twice throws an InvalidOperationException or returns corrupted data (especially with pooled sources). 2. No concurrent access: Do not await the same ValueTask<T> from multiple threads simultaneously. 3. No caching or storing: Do not store a ValueTask<T> in a field or collection. Convert to Task<T> if you need to cache. 4. No blocking: Do not call .Result or .GetAwaiter().GetResult() on a ValueTask<T> that wraps an async operation—it may deadlock.
Common mistakes: - Using ValueTask<T> in a Task.WhenAll or Task.WhenAny call. These methods expect Task<T> and will box the struct. - Returning ValueTask<T> from a method that is always asynchronous, negating the allocation benefit. - Forgetting that ValueTask<T> can wrap a Task<T>; if the task is not completed, the struct still allocates.
How to avoid mistakes: - Use analyzers like CA2012 (Use ValueTasks correctly). - In code reviews, flag any ValueTask<T> that is stored, used in Task.WhenAll, or awaited more than once. - Convert to Task<T> with . if you need to break the restrictions.AsTask()
Example of misuse: ```csharp // Bad: stored in field private ValueTask<int> _cachedTask; public async Task UseAsync() { var result = await _cachedTask; // First await var result2 = await _cachedTask; // Second await - BUG! }
// Good: convert to Task<T> for caching private Task<int> _cachedTask; ```
The Million-Dollar Null Reference: Awaiting ValueTask Twice in a Retry Loop
AsTask() before any sharing or retry logic. Or use a custom IValueTaskSource that strictly validates the token every time.- Never store a ValueTask for later use unless you guarantee single-await.
- Call .
AsTask()immediately if there's any chance the operation will be awaited more than once. - Always test ValueTask-returning methods under concurrent load — unit tests won't catch token recycling bugs.
dotnet-stack ps + dotnet-stack report <pid> to see the call stack where exception occurredSearch code for: await <valueTaskVar> — count occurrencesAsTask(); await task;| File | Command / Code | Purpose |
|---|---|---|
| AllocationComparison.cs | using System; | How Task |
| IValueTaskSourceDemo.cs | using System; | ValueTask Internals |
| CacheWithValueTask.cs | using System; | Task vs ValueTask |
| ValueTaskBenchmark.cs | using System; | Benchmarking, Async State Machine Impact, and the Non-Generi |
| AsyncLocalBug.cs | using System; | ValueTask and AsyncLocal |
| PooledValueTaskSource.csharp | using System; | ValueTask and Object Pools |
| AsyncLocalLeakDemo.csharp | using System; | The AsyncLocal Leak |
| ValueTaskDefinition.cs | public struct ValueTask | Definition |
| ValueTaskProperties.cs | ValueTask | Properties |
| PooledValueTaskSource.cs | public class PooledValueTaskSource | IValueTaskSource for Pooling ValueTasks |
| DecisionGuide.cs | public ValueTask | ValueTask |
| ValueTaskMistakes.cs | ValueTask | ValueTask Restrictions and Common Mistakes |
Key takeaways
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] to also pool the async state machine, eliminating even the state machine allocation on genuinely async paths.Interview Questions on This Topic
What is the structural difference between Task
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
That's C# Advanced. Mark it forged?
10 min read · try the examples if you haven't