Task over ValueTask<>
11/09/2025 | 5 minutes to read
I recently ran into some code that extensively used ValueTask types along with Task. I became curious if it is a good idea to mix async ValueTask<>s and Tasks. Moreover, .NET allows decorating methods returning ValueTask<> types with [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] that adds pooling behavior, which reduces allocations.
There are many ways to invoke async methods from another async method. The invoked async method can return a Task, ValueTask<>, or a pooled ValueTask<>; the calling method can also return any of these response types, and be a sync or async method. In this post, I create combinations of these methods to measure their performance footprint. The inner method may complete synchronously, asynchronously, or with a probability set between 0..1, where 1 means synchronous completion.
In this blog post, 'sync' completion refers to returning the result of an underlying async method without awaiting it, or without
.Result/.GetAwaiter().GetResult()calls. It does not refer to the 'sync-over-async' anti-pattern.
I used BenchmarkDotNet to measure the performance and allocations of these method combinations. Please note that the allocations show 'non-round' numbers due to BenchmarkDotNet's aggregation when the probability falls between 0 and 1 exclusively. Async completions invoke Task.Yield(); - which yields execution of the current task, allowing other tasks to execute. While there should be no other tasks running in the benchmark, the Mean performance results include a non-trivial waiting duration, that is for the task continuation to execute.
The tables below show a guidance to choose certain designs, however, they must not be taken for granted. Actual allocations and performance must be measured in the production environment of production codebase, that reflects the real probabilty of sync/async completions.
Not all combinations are applicable in all situations. For example, it is intriguing to make the method at the callsite sync (ie. it returns the invoked method result without awaiting it), this might not be possible in all cases.
These benchmarks apply to .NET 10 Preview 7, at the time of writing the .NET team is working on a runtime implementation of these async concepts, where these results will likely not be applicable.
Measurement Results
Inner method returns Task:
Method | Probability | Mean | Error | StdDev | Median | Gen0 | Allocated |
|---|---|---|---|---|---|---|---|
TaskOverTask | 0 | 655.691 ns | 8.7030 ns | 7.7150 ns | 657.775 ns | 0.0162 | 104 B |
AsyncTaskOverTask | 0 | 771.259 ns | 8.9512 ns | 8.3730 ns | 770.903 ns | 0.0324 | 208 B |
AsyncValueTaskOverTask | 0 | 795.772 ns | 15.7088 ns | 28.7243 ns | 808.156 ns | 0.0343 | 216 B |
TaskOverTask | 0.7 | 210.112 ns | 2.4135 ns | 2.1395 ns | 210.149 ns | 0.0048 | 31 B |
AsyncTaskOverTask | 0.7 | 221.908 ns | 1.4695 ns | 1.3026 ns | 221.495 ns | 0.0098 | 62 B |
AsyncValueTaskOverTask | 0.7 | 251.400 ns | 4.9236 ns | 5.6700 ns | 251.458 ns | 0.0100 | 65 B |
TaskOverTask | 1 | 4.745 ns | 0.0711 ns | 0.0594 ns | 4.730 ns | - | - |
AsyncTaskOverTask | 1 | 9.519 ns | 0.0939 ns | 0.0878 ns | 9.529 ns | - | - |
AsyncValueTaskOverTask | 1 | 11.429 ns | 0.1802 ns | 0.1686 ns | 11.426 ns | - | - |
Inner method returns ValueTask<>:
Method | Probability | Mean | Error | StdDev | Median | Gen0 | Allocated |
|---|---|---|---|---|---|---|---|
AsyncTaskOverValueTask | 0 | 698.018 ns | 4.4714 ns | 3.7338 ns | 698.640 ns | 0.0353 | 224 B |
TaskOverValueTask | 0 | 636.997 ns | 12.5737 ns | 18.0328 ns | 639.814 ns | 0.0172 | 112 B |
AsyncValueTaskOverValueTask | 0 | 778.559 ns | 10.4735 ns | 9.2845 ns | 776.305 ns | 0.0362 | 232 B |
ValueTaskOverValueTask | 0 | 632.201 ns | 9.8670 ns | 8.7468 ns | 630.164 ns | 0.0172 | 112 B |
AsyncTaskOverValueTask | 0.7 | 229.884 ns | 1.7155 ns | 1.6046 ns | 230.216 ns | 0.0105 | 67 B |
TaskOverValueTask | 0.7 | 192.642 ns | 2.6676 ns | 2.3648 ns | 192.177 ns | 0.0052 | 34 B |
AsyncValueTaskOverValueTask | 0.7 | 249.126 ns | 2.3743 ns | 2.1048 ns | 249.379 ns | 0.0110 | 69 B |
ValueTaskOverValueTask | 0.7 | 221.527 ns | 4.2711 ns | 5.7019 ns | 222.980 ns | 0.0052 | 34 B |
AsyncTaskOverValueTask | 1 | 9.015 ns | 0.0743 ns | 0.0659 ns | 9.005 ns | - | - |
TaskOverValueTask | 1 | 7.113 ns | 0.1049 ns | 0.0876 ns | 7.117 ns | - | - |
AsyncValueTaskOverValueTask | 1 | 12.030 ns | 0.2674 ns | 0.2627 ns | 11.969 ns | - | - |
ValueTaskOverValueTask | 1 | 8.038 ns | 0.1870 ns | 0.2226 ns | 8.065 ns | - | - |
Inner method returns Pooled ValueTask<>:
Method | Probability | Mean | Error | StdDev | Median | Gen0 | Allocated |
|---|---|---|---|---|---|---|---|
AsyncTaskOverPooledValueTask | 0 | 664.594 ns | 6.5432 ns | 5.8004 ns | 663.004 ns | 0.0343 | 222 B |
TaskOverPooledValueTask | 0 | 626.785 ns | 4.8582 ns | 4.3067 ns | 627.650 ns | 0.0315 | 199 B |
AsyncValueTaskOverPooledValueTask | 0 | 808.679 ns | 12.5851 ns | 11.7721 ns | 811.347 ns | 0.0362 | 230 B |
ValueTaskOverPooledValueTask | 0 | 626.790 ns | 9.8143 ns | 9.1803 ns | 628.191 ns | - | - |
AsyncTaskOverPooledValueTask | 0.7 | 223.083 ns | 3.9836 ns | 3.7263 ns | 225.211 ns | 0.0105 | 67 B |
TaskOverPooledValueTask | 0.7 | 211.490 ns | 3.4034 ns | 3.1836 ns | 212.761 ns | 0.0095 | 60 B |
AsyncValueTaskOverPooledValueTask | 0.7 | 252.595 ns | 2.0967 ns | 1.8586 ns | 252.913 ns | 0.0110 | 70 B |
ValueTaskOverPooledValueTask | 0.7 | 196.758 ns | 3.5091 ns | 3.2824 ns | 198.201 ns | - | - |
AsyncTaskOverPooledValueTask | 1 | 9.963 ns | 0.1269 ns | 0.1125 ns | 10.007 ns | - | - |
TaskOverPooledValueTask | 1 | 7.449 ns | 0.1580 ns | 0.1478 ns | 7.484 ns | - | - |
AsyncValueTaskOverPooledValueTask | 1 | 12.031 ns | 0.2713 ns | 0.2405 ns | 12.127 ns | - | - |
ValueTaskOverPooledValueTask | 1 | 7.902 ns | 0.1924 ns | 0.1889 ns | 7.976 ns | - | - |
Outer method returns ValueTask inner method returns Pooled ValueTask<>:
Method | Probability | Mean | Error | StdDev | Gen0 | Allocated |
|---|---|---|---|---|---|---|
AsyncNgValueTaskOverValueTask | 0 | 873.42 ns | 17.418 ns | 21.390 ns | 0.0353 | 224 B |
AsyncNgValueTaskOverPooledValueTask | 0 | 779.27 ns | 11.096 ns | 10.379 ns | 0.0353 | 223 B |
AsyncNgValueTaskOverValueTask | 0.7 | 281.29 ns | 2.312 ns | 2.162 ns | 0.0105 | 67 B |
AsyncNgValueTaskOverPooledValueTask | 0.7 | 241.65 ns | 2.119 ns | 1.879 ns | 0.0105 | 67 B |
AsyncNgValueTaskOverValueTask | 1 | 11.07 ns | 0.218 ns | 0.204 ns | - | - |
AsyncNgValueTaskOverPooledValueTask | 1 | 12.30 ns | 0.178 ns | 0.167 ns | - | - |
Analysis
Being synchronous provides the best performance.
Sync outer methods (not sync-over-async) reduce allocations
Inner methods completing synchronously typically avoid allocations altogether
When the inner method returns
ValueTask<>and the outer method is sync, the return type (TaskorValueTask<>) doesn't impact performanceFor asynchronous completion with async outer methods:
ValueTask<>allocates 8 bytes (1 reference) more thanTaskAsync
Taskshows slightly better Mean execution performance
Inner
Pooled ValueTask<>completely eliminates allocations when the outer method also avoids allocations by:Completing synchronously and returning
ValueTask<>
Async
ValueTask<>with an inner method ofPooled ValueTask<>is the slowest overall solution; using an asyncTaskappears to be a better choiceAsync case when converts a
ValueTask<>toValueTaskthe performance more-or-less matches the non-coverting one with slightly less allocations
Conclusion
The data highlights the trade-offs between performance, allocations, and code complexity when choosing between Task, ValueTask<>, and Pooled ValueTask<>. There is no one-size-fits-all solution, and the optimal choice depends on the specific requirements of the application.
Source Code Reference
[SimpleJob, MemoryDiagnoser] public class Benchmarks { [Params(0, 0.7, 1)] public double Probability { get; set; } [Benchmark] public async Task<bool> AsyncTaskOverValueTask() => await GetValue(); [Benchmark] public Task<bool> TaskOverValueTask() => GetValue().AsTask(); [Benchmark] public Task<bool> TaskOverTask() => GetTaskValue(); [Benchmark] public async Task<bool> AsyncTaskOverTask() => await GetTaskValue(); [Benchmark] public async ValueTask<bool> AsyncValueTaskOverTask() => await GetTaskValue(); [Benchmark] public async ValueTask<bool> AsyncValueTaskOverValueTask() => await GetValue(); [Benchmark] public async Task<bool> AsyncTaskOverPooledValueTask() => await GetValuePooled(); [Benchmark] public Task<bool> TaskOverPooledValueTask() => GetValuePooled().AsTask(); [Benchmark] public async ValueTask<bool> AsyncValueTaskOverPooledValueTask() => await GetValuePooled(); [Benchmark] public ValueTask<bool> ValueTaskOverPooledValueTask() => GetValuePooled(); [Benchmark] public ValueTask<bool> ValueTaskOverValueTask() => GetValue(); [Benchmark] public async ValueTask AsyncNgValueTaskOverValueTask() => await GetValue(); [Benchmark] public async ValueTask AsyncNgValueTaskOverPooledValueTask() => await GetValuePooled(); [AsyncMethodBuilder(typeof(PoolingAsyncValueTask<>MethodBuilder<>))] public async ValueTask<bool> GetValuePooled() { if (Random.Shared.NextDouble() > Probability) await Task.Yield(); return true; } public async ValueTask<bool> GetValue() { if (Random.Shared.NextDouble() > Probability) await Task.Yield(); return true; } public async Task<bool> GetTaskValue() { if (Random.Shared.NextDouble() > Probability) await Task.Yield(); return true; } }