ValueTask vs Task: When ValueTask Provides Benefits and When It Does Not
July 15, 2026
ValueTask vs Task: When ValueTask Provides Benefits and When It Does Not
ValueTask is often described as the allocation-free alternative to Task. However, its benefits are limited to specific scenarios. In other cases, using ValueTask can be less efficient.
You may have heard that ValueTask<T> is faster than Task<T>, so you replace
Task<T> in a performance-critical method. After benchmarking, you notice no
improvement. In some cases, performance may even decrease. This is not a setup
issue; it is a nuance often omitted from tutorials.
The following summarizes the key rule, supported by measurements on .NET 10:
| Your method… | Task<T> | ValueTask<T> |
|---|---|---|
| finishes synchronously (e.g. a cache hit) | 72 B/call | 0 B/call |
| actually goes async (waits on something) | 96 B/call | 104 B/call |
ValueTask only avoids allocation when your method completes synchronously, without awaiting. If the method becomes asynchronous, ValueTask offers no allocation benefit. This is the core concept. The following sections provide further explanation.
Why you'd care
Every async method you write turns into a little bookkeeping object the compiler
generates for you. Task<T> is a class, so returning it always creates a heap
object. In high-throughput services, creating one object per call can quickly
accumulate and increase garbage collection activity.
ValueTask<T> exists to skip that object when you don't need it, which turns out to
be surprisingly often.
The one idea: the allocation only happens if you actually wait
When you write an async method, the compiler rewrites it into a small state
machine — think of it as a struct that remembers "where was I?" so it can pause at
await and resume later.
The key part: that struct lives on the stack and costs nothing — until the method actually has to pause. If your method finishes right away (the data was already there), it never pauses, never gets moved to the heap, and never allocates.
This is where ValueTask provides its benefit:
Task<T>is a class, so it always results in a heap allocation, even if there is nothing to await.ValueTask<T>is a struct, so it can just carry the result back on the stack. Only if the method becomes asynchronous does it allocate.
Therefore, ValueTask is most effective when your method typically completes synchronously.
In a Release build, the compiler generates the state machine as a struct. In a Debug build, it uses a class to support debugging. Confirmed by inspecting the generated
<Method>d__0type on .NET 10.
The code
A common scenario is a lookup that frequently retrieves data from an in-memory cache.
static readonly Dictionary<int, int> _cache = new() { [1] = 10, [2] = 20 };
// Task version: allocates a Task<int> even when the cache is hit.
static Task<int> GetTask(int key)
=> _cache.TryGetValue(key, out var v)
? Task.FromResult(v) // heap object, every hit
: SlowLookup(key);
// ValueTask version: zero allocation on a cache hit.
static ValueTask<int> GetValueTask(int key)
=> _cache.TryGetValue(key, out var v)
? new ValueTask<int>(v) // just wraps the int, no heap
: new ValueTask<int>(SlowLookup(key)); // wraps the Task only when we must
static async Task<int> SlowLookup(int key) { await Task.Yield(); return key; }
Both methods return the same result, but only one allocates. Measurements on .NET 10 over 100,000 cache hits:
Task.FromResult : 72 B/call
ValueTask : 0 B/call
With a 99% cache hit rate, this approach results in minimal overhead.
You can replicate this test; the full sample is approximately 25 lines and requires
no additional packages. See basic/Program.cs in the repository for details.
Potential Drawbacks
Trap #1 — "ValueTask made my method slower"
As shown in the table, on the asynchronous path, ValueTask incurs a higher allocation cost than Task (104 vs 96 bytes). If your method is almost always asynchronous, such as making a network call each time, ValueTask provides no benefit and adds overhead. In such cases, use Task for simplicity.
Guideline: use ValueTask only when synchronous completion is the common case, such as with caches or buffered reads. Otherwise, prefer Task.
Trap #2 — "I awaited it twice, and it threw"
A ValueTask is meant to be awaited once. It is not built to be stored, awaited twice,
or handed around — do that, and it can throw an InvalidOperationException at
runtime.
If you await it immediately, this issue will not occur. If you need the result more
than once, convert it to a Task using AsTask() and reuse that instance.
Task<int> t = GetValueTask(1).AsTask(); // convert once
int a = await t; // fine
int b = await t; // also acceptable; Task is reusable
General guideline: retrieve the ValueTask, await it, and do not store it.
Advanced Considerations (For Performance Optimization)
Consider the following advanced notes if you are focused on minimizing allocations:
- Blocking is not allowed. Do not call
.Resultor.GetAwaiter().GetResult()on a ValueTask — always await it. Blocking works on some ValueTasks and throws on others, which makes for a nasty bug that passes in tests and fails under load. - You can eliminate allocations on the asynchronous path by applying
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]to an async ValueTask method. This pools the state machine object and reduces the async-path allocation to zero bytes per call. Note: the generic type parameter must be unbound, or a compiler error (CS8940) will occur. This is a specialized tool and is not required for most code.
Summary
- By default, use
Task<T>. It is simpler and allows multiple awaits safely. - Use
ValueTask<T>for performance-critical methods that typically complete synchronously, such as caches, buffered I/O, or channel reads. Return it and await it immediately; do not store it. - Need the result twice? Call
AsTask()once and reuse that. - Never block on a ValueTask.
ValueTask isn't "a faster Task." It's a way to skip the allocation when there was nothing to wait for. Used there, it's close to free. Used anywhere else, it's just extra rules.
Consider which high-traffic method in your codebase returns Task but almost always has the result ready. That is a strong candidate for ValueTask — you probably already know which one.