C# ValueTask: When and How to Use It
c# valuetask: Learn how C# ValueTask reduces async allocations, when to use it, and why it's not a drop-in replacement for Task.
C# ValueTask is a struct-based alternative to Task for async methods that often complete synchronously. It can reduce allocations in high-throughput paths, but it comes with constraints that make it unsuitable for many public APIs. This article explains how ValueTask works, where it helps, and where it can cause subtle problems if used carelessly.
The Problem: Task Allocations in Async Methods
Every async method that returns Task allocates a new object on the heap when it is called. For most applications, this allocation is negligible. But in hot paths—such as network handlers, parsers, or high-frequency service calls—the constant allocation pressure can hurt throughput and increase GC pauses.
Consider a method that often returns a cached value or completes synchronously. With Task, even a synchronous return still allocates a completed Task object unless the method uses Task.FromResult with a cached instance. The async machinery also creates state machine objects, though these are pooled in modern runtimes. The Task object itself remains a per-call allocation.
ValueTask addresses this by being a struct. When the method completes synchronously, no heap allocation occurs. When it completes asynchronously, it wraps a Task internally. This makes ValueTask a useful tool for reducing allocations in specific scenarios.
How ValueTask Works
ValueTask<T> is a struct that can hold either a result value directly or a Task<T> for asynchronous completion. The non-generic ValueTask (available since .NET Core 2.0) similarly wraps a Task or represents a synchronous completion with no result.
The key difference from Task is that ValueTask is not a reference type. It is a value type, so it can be returned without allocating a heap object when the operation completes synchronously. The compiler and runtime handle the conversion when you use await.
public ValueTask<int> GetValueAsync(bool useCache) { if (useCache) { return new ValueTask<int>(42); // no allocation } return new ValueTask<int>(LoadFromDbAsync()); // wraps a Task }
In the synchronous path, the ValueTask<int> holds the value directly. In the asynchronous path, it wraps the Task<int> returned by LoadFromDbAsync. The caller can await either case without knowing which path was taken.
When to Use ValueTask
Use ValueTask when you have a method that frequently completes synchronously and is called often enough that allocation reduction matters. Typical examples include:
- Methods that check a cache before doing I/O.
- Methods that return a default or constant value under common conditions.
- High-throughput service methods where the async state machine is already optimized but the
Taskallocation is measurable.
A good rule of thumb is to profile first. If profiling shows that Task allocations are a bottleneck, ValueTask can be a valid optimization. Without profiling, introducing ValueTask can add complexity without measurable benefit.
When Not to Use ValueTask
ValueTask is not a drop-in replacement for Task. Several restrictions make it dangerous for many public APIs and general-purpose libraries.
First, ValueTask should only be awaited once. Because it is a struct, it may be backed by a pooled Task object. Awaiting it twice, or calling AsTask() and then awaiting the original, can lead to double-consumption and undefined behavior. This is a breaking change from Task, which can be awaited multiple times.
Second, ValueTask should not be used as a general-purpose return type for methods that are expected to be used in LINQ queries, Task.WhenAll, or other combinators. These operations often require a Task and would need conversion, which defeats the purpose.
Third, a ValueTask cannot be cached and reused. If you need to represent an operation that may complete later and be awaited multiple times, stick with Task.
Performance Considerations: Allocation vs. Pooling
The primary benefit of ValueTask is reduced allocation. However, the runtime already pools Task objects for async methods in many cases. The AsyncTaskMethodBuilder reuses cached Task instances for completed tasks, so a synchronous completion with Task may not allocate a new object every time. The situation is nuanced.
For Task<T>, the runtime does not pool all completed tasks. A method that returns Task<int> with a constant value still allocates a new Task<int> unless it explicitly caches it. ValueTask<T> avoids that by storing the value directly.
For Task (non-generic), the runtime caches a single completed task instance. So a synchronous Task-returning method that returns Task.CompletedTask does not allocate. The allocation savings from ValueTask are therefore more significant for generic Task<T> methods.
Another consideration is that ValueTask can be backed by an IValueTaskSource for custom pooling, which is an advanced optimization used in high-performance libraries like System.IO.Pipelines. For most developers, the default behavior is sufficient.
Implementing a ValueTask-Returning Method
When you write a method that returns ValueTask, the compiler generates a state machine similar to Task-returning methods. The difference is that the state machine implements IValueTaskSource or uses the default builder, which avoids the Task allocation when the method completes synchronously.
Here is a realistic example of a method that reads from a cache and falls back to a database:
private readonly Dictionary<string, int> _cache = new(); public ValueTask<int> GetCountAsync(string key) { if (_cache.TryGetValue(key, out int cached)) { return new ValueTask<int>(cached); } return new ValueTask<int>(LoadCountFromDbAsync(key)); } private async Task<int> LoadCountFromDbAsync(string key) { // Simulate async I/O await Task.Delay(10); return key.Length; }
The synchronous cache hit avoids allocating a Task<int>. The miss path still allocates a Task<int> because it must represent the asynchronous operation, but that is unavoidable.
You can also write an async method that returns ValueTask directly:
public async ValueTask<int> GetValueAsync() { await Task.Delay(1); return 42; }
The compiler handles the details. This method will still allocate a Task for the delay, but the final return value is stored in the ValueTask struct without an extra Task<int> allocation.
Common Pitfalls with ValueTask
One common mistake is storing a ValueTask in a field or property for later use. Because a ValueTask may wrap a pooled object, it is not safe to keep it beyond the immediate await. The following pattern is dangerous:
ValueTask<int> task = GetValueAsync(); // Do other work int result = await task; // This may fail if the underlying task was reused
Another pitfall is using ValueTask as a method parameter type. This forces every caller to construct a ValueTask, which often requires wrapping a Task and defeats the purpose. It also complicates the API for callers who have a Task already.
A third issue is blocking on a ValueTask with .Result or .GetAwaiter().GetResult(). This can deadlock in UI or ASP.NET contexts, just like with Task. The same rules about avoiding synchronous blocking apply.
ValueTask and Async Method Contracts
When you change a public method from Task to ValueTask, you introduce a breaking change. Consumers who store the return value as Task will need to update. More importantly, consumers who await the result multiple times will encounter runtime failures.
For library authors, the guidance is to avoid returning ValueTask from public APIs unless you control the usage and can guarantee single-await semantics. Many well-known libraries, including the .NET runtime itself, use ValueTask internally but expose Task publicly to avoid these constraints.
If you do expose ValueTask, document the single-await contract clearly. Consider whether the performance gain justifies the API friction. In most application code, the benefit is small unless profiling shows a real bottleneck.
Compatibility and Versioning Considerations
ValueTask was introduced in .NET Core 2.0 and is also available in .NET Framework 4.6.1 via the System.Threading.Tasks.Extensions NuGet package. If you target older frameworks, you need to install that package. The non-generic ValueTask is available from .NET Core 2.0 onward.
When upgrading a library from Task to ValueTask, you must consider the target frameworks. If your library supports .NET Standard 2.0, the package is required. This dependency is small but still a consideration.
Another compatibility issue is that ValueTask does not implement IAsyncResult, so it cannot be used with methods that expect Task-based patterns, such as Task.WhenAll or Task.WhenAny. You must call .AsTask() first, which allocates a Task and defeats the purpose if you need that functionality.
In practice, use ValueTask only when you have a clear, measurable need and you can enforce the single-await contract. For most async code, Task remains the safer and more flexible choice.