Task vs ValueTask in C#: When to Choose Each One

When writing async code in C#, most of us just use Task and Task<T> without thinking twice. But there’s this other option called ValueTask that can actually speed up your code in some situations. Let’s look at what makes these two different and how to pick the right one for your code.

The Basics: Task and ValueTask

Let’s start with what these two types actually are.

What is Task?

A Task is basically a promise that some work will finish eventually. It’s been around since .NET Framework 4 and is the backbone of async programming in C#:

public async Task DoSomethingAsync()
{
    await Task.Delay(100); // Simulate work
}

public async Task<int> GetValueAsync()
{
    await Task.Delay(100); // Simulate work
    return 42;
}

The key thing to know is that Task is a class (reference type), so every time you create one, you’re allocating memory on the heap.

What is ValueTask?

A ValueTask is the newer, more efficient cousin introduced in .NET Core 2.0. It does the same job as Task but works differently under the hood:

public async ValueTask DoSomethingAsync()
{
    if (DataIsAlreadyAvailable())
        return; // No allocation needed!

    await FetchDataAsync();
}

public ValueTask<int> GetValueAsync()
{
    if (ResultIsInCache)
        return new ValueTask<int>(_cachedResult); // No allocation!

    return new ValueTask<int>(ComputeResultAsync());
}

How They’re Different

Here’s what sets these two types apart:

1. Memory Usage

TaskValueTask
Memory allocationAlways creates an object on the heapAvoids creating objects when completing right away
Garbage collectionCreates work for the garbage collectorOnly creates garbage when truly async
Storage locationAlways on the heapLives on the stack as a local variable (it’s a struct)

2. Speed Differences

TaskValueTask
Await performanceQuick to await (runtime is optimized for it)Slightly slower when used asynchronously
CachingHas built-in caching for common results (like Task.CompletedTask)No built-in caching mechanism
Best scenarioWorks best for truly async operationsShines when operations finish right away
Memory pressureHigher when doing lots of operationsLower when doing lots of operations

3. Usage Rules

TaskValueTask
Multiple awaitsAwait it as many times as you wantOnly await it once
StorageSafe to save for later useDon’t save it for later use
Concurrent useMultiple code paths can await the same taskDon’t let multiple parts of your code await it

3. How You Can Use Them

Task is very flexible in how you can use it. You can await a Task multiple times without any issues, store it in a variable for later use, or even have different parts of your code await the same Task instance. This flexibility is one of the key advantages of Task, making it a safer choice for most scenarios where you’re not sure how the result will be consumed.

ValueTask, however, comes with important restrictions. You should only await it once, attempting to await it multiple times can lead to unexpected behavior or errors. You shouldn’t store it as a field or property for later use, and you shouldn’t allow multiple parts of your code to await the same ValueTask instance concurrently. These limitations exist because ValueTask may wrap a recyclable object that could be reused after the first await completes.

When ValueTask Makes Sense

ValueTask works best in these situations:

  1. Code paths that run all the time
  2. Methods that usually finish right away
  3. When your profiler shows too many Task objects being created
  4. Performance-critical code where garbage collection matters

Real-World Example

Let’s look at something practical: a cache for user data. First with Task:

public class DataService
{
    private readonly Dictionary<string, User> _userCache = new();

    public async Task<User> GetUserAsync(string userId)
    {
        // Check if in cache first
        if (_userCache.TryGetValue(userId, out var user))
        {
            // This creates a Task<User> even though we already have the answer!
            return user;
        }

        // Get from database
        user = await _databaseClient.GetUserAsync(userId);
        _userCache[userId] = user;
        return user;
    }
}

Now with ValueTask:

public class DataService
{
    private readonly Dictionary<string, User> _userCache = new();

    public ValueTask<User> GetUserAsync(string userId)
    {
        // Check if in cache first
        if (_userCache.TryGetValue(userId, out var user))
        {
            // No extra objects created! Just wraps the value
            return new ValueTask<User>(user);
        }

        // Only create a Task when we really need one
        return new ValueTask<User>(GetUserFromDatabaseAsync(userId));
    }

    private async Task<User> GetUserFromDatabaseAsync(string userId)
    {
        var user = await _databaseClient.GetUserAsync(userId);
        _userCache[userId] = user;
        return user;
    }
}

In the ValueTask version, we skip creating objects when we hit the cache. This might not seem like much, but in a busy app that serves thousands of requests, it adds up fast.

Using ValueTask Correctly

ValueTask is tricky, you have to follow some rules. Here’s what to do (and not do):

The Right Way

// Good: Just await it directly
User user = await userService.GetUserAsync(id);

// Good: With ConfigureAwait
User user = await userService.GetUserAsync(id).ConfigureAwait(false);

// Good: Turn it into a Task when needed
Task<User> task = userService.GetUserAsync(id).AsTask();

The Wrong Way (Don’t Do These!)

// BAD: Awaiting more than once
ValueTask<User> userTask = userService.GetUserAsync(id);
User user1 = await userTask; // This works
User user2 = await userTask; // DANGER! Might break or return wrong data

// BAD: Awaiting from different places at once
ValueTask<User> userTask = userService.GetUserAsync(id);
Task t1 = Task.Run(async () => await userTask);
Task t2 = Task.Run(async () => await userTask);

// BAD: Blocking on it
ValueTask<int> vt = ProcessAsync();
int result = vt.GetAwaiter().GetResult(); // Might lock up your app

If you need to do these things, turn your ValueTask into a Task first:

// Turn it into a Task if you need to await multiple times
Task<User> userTask = userService.GetUserAsync(id).AsTask();
User user1 = await userTask; // This is fine
User user2 = await userTask; // This is also fine

How ValueTask Works Under the Hood

A ValueTask<T> can actually be three different things:

  1. Just a wrapper around a value (when operation finished right away)
  2. A wrapper around a Task<T> (when operation is truly async)
  3. A wrapper around an IValueTaskSource<T> (for reusing objects)

The regular ValueTask works the same way, just without storing a result.

Which One Should You Pick?

Here’s a simple guide:

Go with Task when:

You should stick with Task as your default choice in most situations. Task works best when your code mostly runs asynchronously rather than completing immediately. It’s also the right choice when your method might be awaited multiple times or when you need to save the result for later use in your application. If you’re not sure which to choose, Task is always the safer option, it might be less efficient but won’t introduce subtle bugs. Task is particularly advantageous when your method returns void or bool since the runtime already caches these common results, eliminating the main benefit of ValueTask.

Try ValueTask when:

ValueTask should be viewed as a targeted optimization, not a default choice. Consider it when your code often completes immediately without needing to do actual asynchronous work. It provides the most benefit in methods that run thousands of times in performance-critical paths of your application. Before making the switch, ensure you’ve actually measured and confirmed too many Task objects are being created and causing performance issues, don’t optimize prematurely. Most importantly, only use ValueTask when you’re confident you’ll follow all the usage rules described earlier, as incorrect usage can lead to difficult-to-diagnose bugs.

For the Performance Nerds: Custom IValueTaskSource

If you’re building extremely high-performance code, .NET Core 2.1 added IValueTaskSource<T> for object pooling:

public class SocketOperations
{
    private readonly SocketAsyncEventArgs _sendArgs = new();
    private readonly SocketAsyncEventArgs _receiveArgs = new();

    public ValueTask<int> ReceiveAsync(Memory<byte> buffer)
    {
        // Uses the same objects over and over
        // Simplified -> real code would be more complex
        if (_socket.ReceiveAsync(_receiveArgs))
            return new ValueTask<int>(_asyncReceiveOperation);

        return new ValueTask<int>(_receiveArgs.BytesTransferred);
    }
}

This is how .NET itself handles network operations and file I/O internally.

Wrapping Up

For most code, just stick with Task. It’s simpler and safer.

Only reach for ValueTask when you need that extra performance boost, have confirmed it makes a difference in your specific case, and can follow the rules.

Don’t optimize prematurely. Start with Task, measure your app’s performance, and only switch to ValueTask if you see memory problems from too many Task allocations.