//JorgenHoc
← All articles
Async C#By Jorge CalderónUpdated 13 min read

ValueTask vs Task in C# — Performance Deep Dive

A thorough comparison of ValueTask and Task in C#: heap allocation differences, when ValueTask genuinely improves performance, benchmarks, and when to stick with Task.

#csharp#async#dotnet

ValueTask<T> was added to .NET specifically to eliminate heap allocations in async methods that often complete synchronously. In practice, most developers should use Task<T> almost always. But for performance-critical hot paths, understanding the difference pays off.

The Allocation Problem with Task

Every time you create a Task<T>, .NET allocates an object on the managed heap. For most application code, this is completely fine — the GC handles it. But consider a cache layer called thousands of times per second:

// Task<T> version — always allocates, even on cache hit
public async Task<User?> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var cached))
        return cached;  // Allocates a completed Task<User?> on the heap
 
    var user = await _db.Users.FindAsync(id);  // Async path — Task allocated anyway
    _cache.Set(id, user);
    return user;
}

On every cache hit (which might be 95% of calls), you allocate and immediately discard a Task<User?> object. At high throughput, this adds up:

  • Each Task<T> allocation: 72 bytes on the heap (measured below on .NET 10 x64)
  • 10,000 cache hits/second × 72 bytes = ~720 KB/second of short-lived allocations
  • More GC pressure, more GC pauses

ValueTask: Zero Allocation for Synchronous Paths

ValueTask<T> is a struct. Returning a synchronously completed value costs no heap allocation:

// ValueTask<T> version — zero allocation on cache hit
public ValueTask<User?> GetUserAsync(int id)
{
    if (_cache.TryGetValue(id, out var cached))
        return ValueTask.FromResult(cached);  // Struct — no heap allocation!
 
    return FetchAndCacheAsync(id);  // Async path still uses Task internally
}
 
private async ValueTask<User?> FetchAndCacheAsync(int id)
{
    var user = await _db.Users.FindAsync(id);
    _cache.Set(id, user);
    return user;
}

On cache hits: the struct is created on the stack, returned by value, and goes out of scope — GC never involved.

Benchmarks

Using BenchmarkDotNet to compare the allocation overhead:

[MemoryDiagnoser]
public class TaskVsValueTaskBenchmark
{
    private readonly Dictionary<int, string> _cache = new() { [1] = "cached" };
 
    // ---- Synchronous path: the case ValueTask exists for ----
 
    [Benchmark(Baseline = true, Description = "Task<T>, cache hit")]
    public async Task<string?> TaskCacheHit()
    {
        if (_cache.TryGetValue(1, out var val)) return val;
        return await SlowTaskAsync();
    }
 
    [Benchmark(Description = "ValueTask<T>, cache hit")]
    public async ValueTask<string?> ValueTaskCacheHit()
    {
        if (_cache.TryGetValue(1, out var val)) return val;
        return await SlowValueTaskAsync();
    }
 
    // ---- Asynchronous path: the case where ValueTask buys nothing ----
 
    [Benchmark(Description = "Task<T>, cache miss")]
    public async Task<string?> TaskCacheMiss()
    {
        if (_cache.TryGetValue(999, out var val)) return val;
        return await SlowTaskAsync();
    }
 
    [Benchmark(Description = "ValueTask<T>, cache miss")]
    public async ValueTask<string?> ValueTaskCacheMiss()
    {
        if (_cache.TryGetValue(999, out var val)) return val;
        return await SlowValueTaskAsync();
    }
 
    // Task.Yield rather than Task.Delay: Delay would measure the OS timer instead of
    // the allocation behaviour, and would push every column into milliseconds.
    private static async Task<string?> SlowTaskAsync()
    {
        await Task.Yield();
        return null;
    }
 
    private static async ValueTask<string?> SlowValueTaskAsync()
    {
        await Task.Yield();
        return null;
    }
}

Running that with dotnet run -c Release:

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.9106)
11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.303
  [Host]     : .NET 10.0.11 (10.0.1126.37416), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  DefaultJob : .NET 10.0.11 (10.0.1126.37416), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
 
| Method                     | Mean         | Error      | StdDev     | Ratio  | RatioSD | Gen0   | Allocated | Alloc Ratio |
|--------------------------- |-------------:|-----------:|-----------:|-------:|--------:|-------:|----------:|------------:|
| 'Task<T>, cache hit'       |    11.491 ns |  0.3985 ns |  1.0638 ns |   1.01 |    0.13 | 0.0115 |      72 B |        1.00 |
| 'ValueTask<T>, cache hit'  |     6.676 ns |  0.2023 ns |  0.2630 ns |   0.59 |    0.06 |      - |         - |        0.00 |
| 'Task<T>, cache miss'      | 1,172.718 ns | 23.3628 ns | 23.9919 ns | 102.90 |    9.45 | 0.0305 |     195 B |        2.71 |
| 'ValueTask<T>, cache miss' | 1,238.435 ns | 11.6302 ns | 10.8789 ns | 108.66 |    9.79 | 0.0362 |     229 B |        3.18 |
BenchmarkDotNet results for Task versus ValueTask on .NET 10: cache hit allocates 72 bytes for Task and nothing for ValueTask, cache miss allocates 195 bytes for Task and 229 bytes for ValueTask, with the environment header and a bimodal distribution warning.
The run itself, environment header and all — including the bimodal warning discussed below.

Read the Allocated column, not Mean. On the synchronous path the result is what you would expect: Task<T> allocates 72 bytes per call, ValueTask<T> allocates nothing at all. That is the case ValueTask was designed for, and it delivers.

⚠️

Look at the cache-miss rows. On the genuinely async path ValueTask<T> allocated more than Task<T> — 229 B against 195 B. Once an async ValueTask<T> method actually suspends, its builder still has to heap-allocate something to hold the state machine, and the struct wrapper sits on top of that rather than replacing it. The saving only exists when the method returns without ever suspending.

That last point is the one that matters when deciding whether to convert an API. ValueTask is not a free upgrade — it is a trade that wins on the synchronous path and loses on the asynchronous one. If your cache hit rate is 95%, it is clearly worth it. If it is 30%, you have made things slightly worse while also taking on the single-await restriction described below.

Two caveats on the timings. BenchmarkDotNet flagged the Task<T>, cache hit row as bimodal (mValue 3.7) and discarded 17 outliers from it, so of the four rows that is the one to trust least — at single-digit nanoseconds you are measuring scheduling noise alongside the work. The Ratio column says ValueTask is 0.59 of the baseline on a hit, but do not carry a "1.7x faster" headline away from that; it is a handful of nanoseconds on a bimodal distribution.

The allocation column has no such problem. Across three separate runs on this machine the timings moved by up to 4% while Allocated came back byte-identical every time — 72 B, 0, 195 B, 229 B. That is the difference between a measurement that is deterministic and one that is statistical, and it is why the allocation figures carry the argument here.

These figures still come from one laptop. The shape should reproduce anywhere; the absolute nanoseconds will not, which is why the ratio of hits to misses in your own workload is the thing worth measuring.

Against a Real Repository

The benchmark above is synthetic — it uses Task.Yield() for the async path so that timing noise does not swallow the allocation difference. Put the same two shapes in front of an actual EF Core query and measure allocated bytes instead of time:

PathTask<User?>ValueTask<User?>
Cache hit160 B/call0 B/call
Cache miss18,748 B/call18,775 B/call

The hit row is the promise delivered: nothing allocated at all. The miss row is the part that decides whether converting an API is worth it — the two land within 0.15% of each other, because the query pipeline dwarfs anything the wrapper does. Which of the two comes out marginally ahead flips between runs, which tells you the difference is noise.

Look at the ratio between rows: a miss costs about 117 times a hit. So the question is never "is ValueTask faster", it is "what fraction of my calls complete synchronously". At 95% hits the saving is real and compounding. At 30% you have adopted the single-await hazard below in exchange for a rounding error.

💡

Measured with GC.GetTotalAllocatedBytes(precise: true), not GC.GetAllocatedBytesForCurrentThread(). The per-thread counter is the obvious choice and the wrong one: a cache miss suspends at await and resumes on a different thread-pool thread, so those allocations are never counted. Using it here produced a difference of more than 10x between the two return types that was pure measurement artifact — and, worse, it looked plausible.

The numbers also differ from the Allocated column above (160 B versus 72 B) because they measure different scopes: BenchmarkDotNet isolates the benchmarked method, while GetTotalAllocatedBytes counts everything the calling loop does. The direction and the ratio reproduce; the absolute figure depends on where you draw the boundary.

Console output measuring allocated bytes per call against a real EF Core repository: 160 bytes for Task and 0 for ValueTask on a cache hit, about 18,750 bytes for both on a cache miss, followed by four double-await results showing that only the pooled builder throws.
Allocation measured against a real repository, plus the double-await results — the pooled builder is the only one that objects.

Both are runnable: samples/valuetask-vs-task-csharp for the allocation figures and the single-await demonstration, and benchmarks/JorgenHoc.Benchmarks for the BenchmarkDotNet run.

When ValueTask Actually Helps

1. High-Frequency Methods with Common Synchronous Path

// Good ValueTask use case — in-memory buffer that fills asynchronously
public class DataBuffer
{
    private readonly Queue<byte[]> _buffer = new();
 
    // Called in a tight loop — often has data, rarely needs to wait
    public ValueTask<byte[]> ReadChunkAsync(CancellationToken ct = default)
    {
        if (_buffer.TryDequeue(out var chunk))
            return ValueTask.FromResult(chunk);  // Zero allocation — common path
 
        return WaitForChunkAsync(ct);
    }
 
    private async ValueTask<byte[]> WaitForChunkAsync(CancellationToken ct)
    {
        await _dataAvailableSignal.WaitAsync(ct);
        return _buffer.Dequeue();
    }
}

2. Interface Methods on Hot Paths

When defining interfaces for performance-critical async components:

// Interface for a serializer used millions of times
public interface IFastSerializer<T>
{
    // ValueTask makes sense here — implementations may complete synchronously
    ValueTask<byte[]> SerializeAsync(T value, CancellationToken ct = default);
    ValueTask<T> DeserializeAsync(byte[] data, CancellationToken ct = default);
}
 
// Implementation that often completes synchronously (small objects)
public class SmallObjectSerializer<T> : IFastSerializer<T>
{
    public ValueTask<byte[]> SerializeAsync(T value, CancellationToken ct = default)
    {
        var data = JsonSerializer.SerializeToUtf8Bytes(value);
        if (data.Length < 1024)
            return ValueTask.FromResult(data);  // Small — synchronous
 
        return WriteLargeAsync(data, ct);
    }
 
    private async ValueTask<byte[]> WriteLargeAsync(byte[] data, CancellationToken ct)
    {
        await Task.Yield();  // Yield to allow other work during large serialization
        return data;
    }
}

3. Already-Completed Operations

// A reader that buffers ahead — frequently has data already in buffer
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
    if (TryCopyFromBuffer(buffer, out var bytesRead))
        return ValueTask.FromResult(bytesRead);  // Buffer hit — no allocation
 
    return ReadFromStreamAsync(buffer, ct);  // Actual I/O — task needed
}

When NOT to Use ValueTask

1. Most Application Code

For typical ASP.NET Core controller/service code, Task<T> is correct:

// Don't use ValueTask here — no hot path, one allocation per request is fine
public async Task<List<Product>> GetProductsAsync(int categoryId)
{
    return await _db.Products
        .Where(p => p.CategoryId == categoryId)
        .ToListAsync();
}

The allocation cost of one Task per HTTP request is completely negligible.

2. Methods That Always Await

// ValueTask provides no benefit here — always goes to the async path
public async ValueTask<UserDto> GetUserDtoAsync(int id)
{
    var user = await _db.Users.FindAsync(id);  // Always async
    return new UserDto(user!.Id, user.Name, user.Email);
}
 
// Use Task<T> instead — simpler, same performance
public async Task<UserDto> GetUserDtoAsync(int id)
{
    var user = await _db.Users.FindAsync(id);
    return new UserDto(user!.Id, user.Name, user.Email);
}

3. Methods Awaited Multiple Times

This is a correctness issue, not just a performance one — and it is worse than a straightforward crash, because most of the time it appears to work.

// UNDEFINED BEHAVIOUR — a ValueTask may only be consumed once
var vt = cache.GetUserAsync(42);
var a = await vt;  // OK
var b = await vt;  // WRONG — but very likely to succeed anyway
 
// Safe — convert to Task if you need to await more than once
var task = cache.GetUserAsync(42).AsTask();
var a = await task;
var b = await task;  // Safe

Running that second await against a normal async ValueTask<T> method gives:

PathSecond await
Cache hit (completed synchronously)Succeeds
Cache miss (completed asynchronously)Succeeds
Method using PoolingAsyncValueTaskMethodBuilderThrows InvalidOperationException
.AsTask()Succeeds — always safe
🚨

Note the first two rows. With the default AsyncValueTaskMethodBuilder the state machine box stays alive after completion, so nothing detects the violation and a second await quietly returns the right answer. "It worked when I tested it" is not evidence here.

It breaks when the box gets recycled: a method annotated with PoolingAsyncValueTaskMethodBuilder, or anything backed by an IValueTaskSource that reuses tokens — Socket, System.IO.Pipelines, SemaphoreSlim.WaitAsync. Which builder the callee uses is an implementation detail you do not control and that can change under you in a library upgrade.

So treat single-consumption as an invariant you enforce, not a rule the runtime enforces for you. Call AsTask() the moment you need the result twice.

4. Storing in Fields or Using with WhenAll

// WRONG — ValueTask cannot be stored and awaited later
ValueTask<string> _pendingTask;
 
public async Task StartAsync()
{
    _pendingTask = DoWorkAsync();  // Can't store and await later reliably
}
 
// WRONG — Task.WhenAll doesn't accept ValueTask
await Task.WhenAll(task1, task2);  // task1/task2 must be Task, not ValueTask
 
// Convert if needed
await Task.WhenAll(vt1.AsTask(), vt2.AsTask());
⚠️

A ValueTask must be awaited exactly once, immediately. If you need to await it multiple times, check it from multiple places, or pass it to Task.WhenAll, convert it with .AsTask() first.

The IValueTaskSource Interface

For ultimate control (used in .NET BCL), implement IValueTaskSource<T> to reuse the task object across multiple calls — eliminating even the minimal overhead of async state machines:

// Advanced pattern — reusable async operation object
// Used internally in Socket, PipeReader, etc.
// Most application code never needs this level of optimization
public class ReusableAsyncOperation : IValueTaskSource<int>
{
    private ManualResetValueTaskSourceCore<int> _core;
 
    public ValueTask<int> GetValueTask() => new ValueTask<int>(this, _core.Version);
 
    public int GetResult(short token) => _core.GetResult(token);
    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 void SetResult(int result) => _core.SetResult(result);
    public void Reset() => _core.Reset();
}

This is deep infrastructure code — you'll likely never write this in application development.

Practical Decision Guide

Is this a hot path? (1000+ calls/second per instance)
├── No → Use Task<T>
└── Yes → Does it frequently complete synchronously?
           ├── No → Use Task<T>
           └── Yes → Use ValueTask<T>
                      └── Does the caller need to await it multiple times?
                               ├── Yes → Use Task<T> or convert with .AsTask()
                               └── No → ValueTask<T> is appropriate

Summary

ValueTask<T> is a specialized optimization tool, not a general replacement for Task<T>. Use it when:

  • The method is on a hot path
  • The synchronous completion case is common (cache hits, buffered reads)
  • The caller will await exactly once and immediately

For everything else — which is most application code — Task<T> is simpler, safer, and carries negligible overhead.

If you are still building the underlying mental model, the C# async/await guide covers how the state machine and the awaiter pattern work — which is what makes ValueTask<T>'s single-await restriction make sense rather than seem arbitrary.

For streaming scenarios, note that IAsyncEnumerable<T> already uses ValueTask internally in MoveNextAsync, which is a good illustration of the intended use case: a hot path where the synchronous completion is common.

Further reading

About the author

Jorge Calderón

Software engineer with over a decade building and operating .NET applications in production — EF Core data layers, async-heavy services, and Azure and container deployments. Every benchmark and sample project in these guides is published in a public GitHub repository so you can rerun it yourself.

GitHub profileLinkedIn ↗Benchmarks & sample code

Related articles