//JorgenHoc
← All articles
Async C#7 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: ~80 bytes on the heap
  • 10,000 cache hits/second × 80 bytes = ~800 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:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
 
[MemoryDiagnoser]
[SimpleJob]
public class TaskVsValueTaskBenchmark
{
    private readonly Dictionary<int, string> _cache = new() { [1] = "cached" };
 
    [Benchmark(Baseline = true)]
    public async Task<string?> WithTask()
    {
        if (_cache.TryGetValue(1, out var val))
            return val;
 
        await Task.Delay(1);
        return null;
    }
 
    [Benchmark]
    public ValueTask<string?> WithValueTask()
    {
        if (_cache.TryGetValue(1, out var val))
            return ValueTask.FromResult<string?>(val);
 
        return SlowPathAsync();
    }
 
    private async ValueTask<string?> SlowPathAsync()
    {
        await Task.Delay(1);
        return null;
    }
}

Typical results (cache hit path):

MethodMeanAllocated
WithTask28.4 ns80 B
WithValueTask4.2 ns0 B

For the async path (no cache hit), both allocate similarly.

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 UserTask(user!.Id, user.Name, user.Email);
}

3. Methods Awaited Multiple Times

This is a correctness issue, not just a performance one:

// UNDEFINED BEHAVIOR — ValueTask can only be awaited once
var vt = cache.GetUserAsync(42);
var a = await vt;  // OK
var b = await vt;  // WRONG — may read garbage or crash
 
// Safe — convert to Task if you need multiple awaits
var task = cache.GetUserAsync(42).AsTask();
var a = await task;
var b = await task;  // Safe

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.