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
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.
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<T> object on the managed heap. The JIT can cache Task<bool> for true/false, and Task<int> for small integers (0–9 in current runtimes), but anything beyond those tiny pools creates a fresh object. That object then requires garbage collection.
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<T> 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<T> stores the T value directly inside its own fields. No Task object, no GC pressure. When the method truly suspends, ValueTask holds a reference to either a Task or an IValueTaskSource — so you pay the allocation cost only when you genuinely need it.
AllocationComparison.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
usingSystem;
usingSystem.Threading.Tasks;
usingSystem.Runtime.CompilerServices;
publicclassAllocationComparison
{
privatestaticreadonlystring? _cachedGreeting = "Hello, World!";
publicstaticTask<string> GetGreetingWithTask(bool forceAsync)
{
if (!forceAsync && _cachedGreeting is not null)
{
returnTask.FromResult(_cachedGreeting);
}
returnSimulateSlowDatabaseCallAsync();
}
publicstaticValueTask<string> GetGreetingWithValueTask(bool forceAsync)
{
if (!forceAsync && _cachedGreeting is not null)
{
returnnewValueTask<string>(_cachedGreeting);
}
returnnewValueTask<string>(SimulateSlowDatabaseCallAsync());
}
privatestaticasyncTask<string> SimulateSlowDatabaseCallAsync()
{
awaitTask.Delay(50);
return"Hello from database!";
}
publicstaticasyncTaskMain(string[] args)
{
int iterations = 100_000;
long gcBefore, gcAfter;
GC.Collect();
gcBefore = GC.CollectionCount(0);
for (int i = 0; i < iterations; i++)
{
string result = awaitGetGreetingWithTask(forceAsync: false);
_ = result;
}
gcAfter = GC.CollectionCount(0);
Console.WriteLine($"Task path — Gen0 GC collections: {gcAfter - gcBefore}");
GC.Collect();
gcBefore = GC.CollectionCount(0);
for (int i = 0; i < iterations; i++)
{
string result = awaitGetGreetingWithValueTask(forceAsync: false);
_ = result;
}
gcAfter = GC.CollectionCount(0);
Console.WriteLine($"ValueTask path — Gen0 GC collections: {gcAfter - gcBefore}");
}
}
Output
Task path — Gen0 GC collections: 4
ValueTask path — Gen0 GC collections: 0
Why Gen0 Collections Matter:
Gen0 collections are fast but they still stop-the-world (briefly). In a server processing thousands of concurrent requests, frequent Gen0 collections show up as latency spikes in p99 and p999 percentiles — the exact metrics your SLA probably cares about.
Production Insight
In high-throughput services, GC pauses from Task allocations shift p99 latency from 5ms to 25ms.
.NET's own Kestrel web server switched to ValueTask<T> across its pipeline after profiling showed that 90% of reads complete synchronously from a prefetched buffer.
Rule: profile first with GC.CollectionCount and ETW events before optimising.
Key Takeaway
Task<T> allocates on every synchronous return — 96 bytes per call for an int.
ValueTask<T> stores the result inline — 0 bytes on the synchronous fast path.
Measure your sync-hit ratio before deciding. If it's under 30%, Task is fine.
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.
IValueTaskSourceDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
usingSystem;
usingSystem.Threading;
usingSystem.Threading.Tasks;
usingSystem.Threading.Tasks.Sources;
usingSystem.Collections.Generic;
publicclassPooledValueTaskSource : IValueTaskSource<int>
{
privateshort _currentToken;
privateint _result;
privatebool _isCompleted;
privateAction<object?>? _continuation;
privateobject? _continuationState;
publicintGetResult(short token)
{
if (token != _currentToken)
thrownewInvalidOperationException(
"ValueTask was awaited after its underlying source was recycled. " +
"Never await the same ValueTask more than once.");
if (!_isCompleted)
thrownewInvalidOperationException("Result not yet available.");
int capturedResult = _result;
_currentToken++;
_isCompleted = false;
_continuation = null;
Console.WriteLine($"[Pool] Source recycled. New token is {_currentToken}.");
return capturedResult;
}
publicValueTaskSourceStatusGetStatus(short token)
{
if (token != _currentToken)
thrownewInvalidOperationException("Stale token — source has been recycled.");
return _isCompleted ? ValueTaskSourceStatus.Succeeded : ValueTaskSourceStatus.Pending;
}
publicvoidOnCompleted(
Action<object?> continuation,
object? state,
short token,
ValueTaskSourceOnCompletedFlags flags)
{
_continuation = continuation;
_continuationState = state;
}
publicvoidSignalCompletion(int result)
{
_result = result;
_isCompleted = true;
Console.WriteLine($"[Source] Signalled completion with result: {result}");
_continuation?.Invoke(_continuationState);
}
publicValueTask<int> AsValueTask() => newValueTask<int>(this, _currentToken);
}
publicclassIValueTaskSourceDemo
{
publicstaticasyncTaskMain(string[] args)
{
var source = newPooledValueTaskSource();
var backgroundWork = Task.Run(async () =>
{
awaitTask.Delay(100);
source.SignalCompletion(result: 42);
});
ValueTask<int> valueTask = source.AsValueTask();
Console.WriteLine("[Main] Awaiting ValueTask...");
int answer = await valueTask;
Console.WriteLine($"[Main] Result: {answer}");
try
{
int staleAnswer = await valueTask;
Console.WriteLine($"[Main] Stale result: {staleAnswer}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"[Main] Caught expected error: {ex.Message}");
}
await backgroundWork;
}
}
Output
[Main] Awaiting ValueTask...
[Source] Signalled completion with result: 42
[Pool] Source recycled. New token is 1.
[Main] Result: 42
[Main] Caught expected error: ValueTask was awaited after its underlying source was recycled. Never await the same ValueTask more than once.
Watch Out: The Token Is Your Safety Net — Don't Discard It
If you store a ValueTask in a field and await it from two different places — or await it inside a retry loop — you are walking into the token mismatch trap. If the underlying source doesn't check the token (some third-party implementations don't), you'll get silently wrong data instead of an exception. Convert to Task first with .AsTask() if you need to await more than once.
Production Insight
The IValueTaskSource token is a 16-bit short — after 32,767 recyclings, it wraps. Wraparound can cause false positive matches.
Production systems that recycle sources millions of times per hour should use a 64-bit token or periodic pool reset.
Rule: always validate the token in your IValueTaskSource.GetResult().
Key Takeaway
ValueTask stores result inline or wraps Task/IValueTaskSource.
The token in IValueTaskSource prevents double-await — but only if checked.
Single-await rule is mandatory. .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 .GetAwaiter().GetResult(), and a struct-copy footgun. If you misuse it, you don't get a compiler error — you get a runtime bug under load.
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.
[Cache] MISS for https://example.com — initiating HTTP request
[Cache] Stored 1256 chars for https://example.com
First call — got 1256 chars
[Cache] HIT for https://example.com — no allocation, no await suspension
Second call — got 1256 chars
[Cache] HIT for https://example.com — no allocation, no await suspension
Fan-out result 1: <!doctype html>
<html>
<he...
Fan-out result 2 (same task): <!doctype html>
<html>
<he...
Pro Tip: Use AsTask() as Your Safety Valve
Any time you're unsure whether you'll need to await a ValueTask more than once — or pass it to WhenAll, WhenAny, or ContinueWith — call .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.
Production Insight
A team once migrated all their service methods to ValueTask<T> without profiling — GC pressure actually increased because they added hidden Task allocations via .AsTask() calls.
Micro-benchmarks can mislead: measure in the actual hosting environment with realistic concurrency.
Rule: always measure allocation delta with BenchmarkDotNet before switching Task to ValueTask.
Key Takeaway
Use ValueTask only when synchronous fast path >80% of calls.
Default to Task in application code. Library code near I/O can benefit.
When in doubt about single-await, call .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.
If an interviewer asks 'does ValueTask always avoid allocations?', the correct answer is: only when the method returns synchronously without the async keyword on a path that doesn't suspend. An async method that always hits await 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+.
Production Insight
A route handler using async ValueTask<T> that always calls an external API (always async) saw zero allocation improvement but added complexity.
The PoolingAsyncValueTaskMethodBuilder in .NET 6+ pools the state machine — but only when the method truly awaits. For synchronous-only methods it's unused.
Rule: benchmark both sync and async paths separately. Use [MemoryDiagnoser] to see bytes allocated per op.
Key Takeaway
ValueTask avoids allocations only on synchronous fast paths without the async keyword.
If your method is always async, ValueTask adds no benefit.
Use PoolingAsyncValueTaskMethodBuilder in .NET 6+ to also pool state machines in truly async paths.
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.
AsyncLocal + Pooled ValueTask = Log Correlation Nightmare
If you use pooled IValueTaskSource in a system that relies on AsyncLocal for correlation or ambient data, capture the execution context explicitly before calling the source. Use ExecutionContext.Capture() and run the continuation under the original context. Better yet, avoid storing ValueTask in any shared state.
Production Insight
A real incident: an observability team saw 30% of spans with missing parent correlation IDs. Root cause was a pooled ValueTaskSource in an HTTP client that reused sources across requests, causing AsyncLocal flow to cross wires.
Fix: disable pooling in the client, or capture ExecutionContext before yielding.
Rule: test with automated log correlation checks in integration tests.
Key Takeaway
AsyncLocal flows through Task correctly because ExecutionContext is captured.
IValueTaskSource recycles can break AsyncLocal context — capture it manually.
Single-await rule also protects AsyncLocal correctness — only one continuation per source.
● Production incidentPOST-MORTEMseverity: high
The Million-Dollar Null Reference: Awaiting ValueTask Twice in a Retry Loop
Symptom
Intermittent duplicate payments under load. No exceptions logged. Only p50 latency increased slightly before the bug manifested.
Assumption
ValueTask behaves like Task — you can await it as many times as you want because it's just a wrapper around a result.
Root cause
The ValueTask was backed by a pooled IValueTaskSource from Microsoft.Toolkit.HighPerformance. The first await consumed the source and incremented the token. The second await hit GetResult with a mismatched token, but the pool implementation returned the cached int result instead of throwing — because the result was already stored and the token check was omitted in that version.
Fix
Convert ValueTask<int> to Task<int> via .AsTask() before any sharing or retry logic. Or use a custom IValueTaskSource that strictly validates the token every time.
Key lesson
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.
Production debug guideSymptom → Action3 entries
Symptom · 01
InvalidOperationException: 'ValueTask was already awaited or converted'
→
Fix
Search for all references to the ValueTask variable. Ensure each ValueTask is awaited exactly once. Convert to Task if shared.
Symptom · 02
Silent data corruption or stale results under load
→
Fix
Check if IValueTaskSource implementation validates the token. Use a strict implementation that throws on token mismatch. Profile with ETW events for ValueTaskSource recycle timing.
Symptom · 03
High GC gen0 collections in async code despite using ValueTask
→
Fix
Verify the method is actually returning synchronously. Use BenchmarkDotNet's MemoryDiagnoser to confirm zero allocation per call. If the method is always async, ValueTask provides no benefit.
★ Quick Debug Cheat Sheet: ValueTask PitfallsUse these commands and checks to identify ValueTask issues fast.
InvalidOperationException on second await−
Immediate action
Locate the ValueTask variable and ensure it's awaited only once.
Commands
dotnet-stack ps + dotnet-stack report <pid> to see the call stack where exception occurred
Change to: Task<T> task = valueTask.AsTask(); await task;
High GC pressure in async code+
Immediate action
Run BenchmarkDotNet with [MemoryDiagnoser] on the hot path method.
Commands
dotnet run -c Release -- --memory
Check Allocated column: if non-zero on sync path, ValueTask isn't being used correctly.
Fix now
Return new ValueTask<T>(result) instead of Task.FromResult(result) on sync path.
Unexpected blocking when using .Result on a ValueTask+
Immediate action
Stop using .Result immediately. Convert to Task first.
Commands
Find all: .Result on ValueTask — should be none.
If synchronous context required, use: valueTask.AsTask().GetAwaiter().GetResult()
Fix now
Refactor calling code to be async if possible.
Feature / Behaviour
Task<T>
ValueTask<T>
Type
Class (reference type)
Struct (value type)
Heap allocation on sync path
Yes — always allocates a Task object
No — result stored inline in struct
Heap allocation on async path
Yes — Task + state machine
Yes — state machine (Task wrapper removed)
Can be awaited multiple times
Yes — safely reusable
No — undefined behaviour after first await
Works with Task.WhenAll/WhenAny
Yes — directly
No — must call .AsTask() first
Blocking with .Result / .Wait()
Legal (not recommended)
Potentially dangerous — undefined if source recycled
Can be passed to ContinueWith
Yes
No — no ContinueWith method exists on ValueTask
Ideal use case
Always-async methods, fan-out, shared results
Sync-fast-path methods: caches, buffers, pipes
Supported since
.NET Framework 4.5 / C# 5
.NET Core 2.0 / .NET Standard via NuGet
Pooled state machine support
No
Yes — with PoolingAsyncValueTaskMethodBuilder (.NET 6+)
Cognitive / API complexity
Low
Higher — rules around single-await, no blocking
Appropriate for application code
Yes — default choice
Rarely — measure first, optimize deliberately
Key takeaways
1
ValueTask<T> eliminates heap allocation only on the synchronous fast path
the result sits inline in the struct. The moment the method truly suspends, you pay an allocation for the async state machine regardless.
2
Never await the same ValueTask more than once. The underlying IValueTaskSource uses a short token that increments on each recycle
a second await on a recycled source is either an exception or silent data corruption.
3
Task<T> is still the right default for application-layer code. Reach for ValueTask<T> deliberately and measurably
in cache layers, I/O pipelines, or library APIs where the sync-fast-path frequency is proven to be significant.
4
In .NET 6+, annotate your ValueTask-returning methods with [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] to also pool the async state machine, eliminating even the state machine allocation on genuinely async paths.
Common mistakes to avoid
3 patterns
×
Awaiting the same ValueTask twice
Symptom
InvalidOperationException 'ValueTask was already awaited or converted' at runtime under load (not during testing on simple paths)
Fix
If you need to share or re-await a result, call .AsTask() on the ValueTask immediately upon receipt and pass the Task around instead. Tasks are reference-counted and safe for multiple awaits.
×
Using .Result or .GetAwaiter().GetResult() to synchronously block on a ValueTask backed by an IValueTaskSource
Symptom
No exception in dev, silent data corruption in prod when the pooled source is recycled mid-block, or an InvalidOperationException if the token has already been bumped
Fix
Never block synchronously on a ValueTask. If you're in a synchronous method that genuinely cannot be made async, call .AsTask().GetAwaiter().GetResult() to at least get safe Task semantics before blocking.
×
Slapping ValueTask onto every async method as a premature optimisation
Symptom
No measurable allocation improvement (the method is always truly async so the state machine allocates anyway), but you've now introduced single-await constraints that cause latency bugs when a future developer adds a retry loop
Fix
Profile first with BenchmarkDotNet's MemoryDiagnoser. Use ValueTask only when you can demonstrate that the synchronous fast path is both common and measurable. Keep application service methods on Task<T> by default.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the structural difference between Task and ValueTask, and ...
Q02SENIOR
Under what conditions does ValueTask still allocate on the heap despi...
Q03SENIOR
A colleague stores a ValueTask in a class field and awaits it from ...
Q01 of 03SENIOR
What is the structural difference between Task and ValueTask, and why does that difference matter for memory allocation in high-throughput systems?
ANSWER
Task<T> is a reference type — a class — allocated on the managed heap. ValueTask<T> is a value type — a struct — that can be allocated on the stack or inline. When a method returns a synchronous result, ValueTask stores the value directly inside its fields (object _obj, T _result, short _token) with _obj = null, so no heap allocation occurs. Task.FromResult always creates a new Task<T> object (except for JIT-cached small ints and booleans). In high-throughput systems with millions of calls per second, avoiding those allocations reduces GC pressure and latency jitter.
Q02 of 03SENIOR
Under what conditions does ValueTask still allocate on the heap despite being a struct, and how does the PoolingAsyncValueTaskMethodBuilder in .NET 6+ address that?
ANSWER
ValueTask<T> allocates on the heap when the method is marked async and the method actually suspends (awaits something that isn't completed). The async keyword causes the compiler to generate a state machine struct, but that struct is boxed onto the heap when the method suspends for the first time. ValueTask itself avoids the Task wrapper allocation, but the state machine allocation remains. The PoolingAsyncValueTaskMethodBuilder in .NET 6+ pools the state machine box, reusing it across invocations so that even the state machine allocation is eliminated on the async path. However, on the synchronous path (never awaiting), no boxing occurs either way.
Q03 of 03SENIOR
A colleague stores a ValueTask in a class field and awaits it from two different methods in a retry loop. Walk me through exactly what can go wrong, at the IValueTaskSource level, and how you'd fix the code.
ANSWER
When the ValueTask is backed by an IValueTaskSource, the source is recycled after the first await completes. The GetResult method on the source increments a version token. The second await calls GetResult with the old token (stored in the ValueTask at creation), causing either an InvalidOperationException if the source checks tokens, or reading stale data if it doesn't. The retry loop would catch the exception but the data returned from the first await might be lost or corrupted. The fix: convert the ValueTask to a Task immediately by calling .AsTask() on the stored ValueTask, then await the Task multiple times. Alternatively, don't store the ValueTask at all — call the async method fresh on each retry if the operation is idempotent.
01
What is the structural difference between Task and ValueTask, and why does that difference matter for memory allocation in high-throughput systems?
SENIOR
02
Under what conditions does ValueTask still allocate on the heap despite being a struct, and how does the PoolingAsyncValueTaskMethodBuilder in .NET 6+ address that?
SENIOR
03
A colleague stores a ValueTask in a class field and awaits it from two different methods in a retry loop. Walk me through exactly what can go wrong, at the IValueTaskSource level, and how you'd fix the code.
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
Is ValueTask always faster than Task in C#?
No. ValueTask is faster only when a method frequently completes synchronously — returning from a cache hit or a pre-filled buffer, for example. If your method is always genuinely async and always suspends, ValueTask provides no allocation benefit over Task and adds usage constraints (single-await rule, no WhenAll support) that make it strictly harder to use safely.
Was this helpful?
02
Can I use ValueTask with Task.WhenAll in C#?
Not directly. Task.WhenAll requires Task objects. If you have multiple ValueTask instances you want to await together, call .AsTask() on each one first to convert them to Tasks, then pass the Task array to WhenAll. Be aware that .AsTask() allocates a Task object, so if avoiding allocation is your goal, restructure the code so each ValueTask is awaited individually.
Was this helpful?
03
What does IValueTaskSource do and why should I care about it?
IValueTaskSource is the interface that lets an object act as the backing store for a ValueTask without being a Task. Implementations can be pooled — reused across multiple logical operations — which eliminates heap allocations even on the async path. The .NET runtime uses this internally in Socket and PipeReader. You care because it's why the single-await rule exists: once a pooled source has been awaited and its GetResult called, the source may be immediately recycled and issued to another caller, making any further access to your original ValueTask potentially read another operation's data.
Was this helpful?
04
Does ValueTask work with AsyncLocal?
Yes, but with a caveat. When ValueTask wraps a Task, AsyncLocal flows correctly through the Task's ExecutionContext. When it wraps an IValueTaskSource, the continuation may run on a different execution context after the source is recycled, leading to stale or cross-wired AsyncLocal values. If you use pooled ValueTask sources in a system that relies on AsyncLocal for correlation IDs or other ambient data, capture the ExecutionContext explicitly before creating the ValueTask.