ValueTask<TResult> is a lightweight alternative to Task<TResult> for asynchronous methods whose result is very often already available without any real waiting—the classic example being a cache that is hit most of the time and only occasionally has to fall back to a slow, genuinely asynchronous lookup. The core problem it solves is an allocation problem: Task is a reference type, so even a method that returns Task.FromResult(someValue) allocates a new object on the heap every single time it is called, purely to carry a value that was already known. ValueTask<TResult> is instead a struct that can hold either an already known result or a reference to a real, still-running Task<TResult>. When the result is already known, wrapping it in a ValueTask<TResult> costs no heap allocation at all, and the calling code still uses the same await-based API it would use for an ordinary Task.
This benefit comes with a trade-off that every beginner should know before reaching for ValueTask by default: a ValueTask is meant to be consumed only once. It may be awaited exactly one time, or have its Result read exactly one time, and never both, and never twice. Doing so is safe for a Task, because a Task is an ordinary object that can be inspected as many times as needed, but it is not guaranteed to be safe for a ValueTask, because some producers reuse and recycle the same underlying storage between calls for performance reasons. If a value is needed more than once, or has to be combined with Task.WhenAll or Task.WhenAny, the correct approach is to call AsTask() once and keep using the resulting, perfectly ordinary Task<TResult> from then on. Because of this single-use rule and because the allocation savings only matter in code that is called extremely often (hot paths, tight loops, high-throughput libraries), the general guidance from the .NET team is to keep returning plain Task<TResult> from ordinary methods and to switch to ValueTask<TResult> only where measurements show the allocation actually matters.
The examples below are built around this core concept and are split into two parts, following the same structure as every other topic in this project: a core concept example (Run()) that walks through creating a ValueTask directly from a value, wrapping a real Task, mixing both paths inside a small cache, and converting a ValueTask to a reusable Task with AsTask(); and a real-world example (RunUserProfileCacheScenario()) that applies the same idea to a small, realistic user profile cache.
The first part of the core example creates a ValueTask<int> straight from an already known number, using the constructor new ValueTask<int>(7). Immediately after creation its IsCompletedSuccessfully property is already true, because no background work was ever started; awaiting it returns the value right away without ever yielding control back to the caller. The code for this and the rest of Run() is shown here exactly as it appears in the accompanying class file:
/// <summary>
/// Core concept example.
/// A ValueTask<TResult> can hold EITHER an already known value OR a real
/// Task<TResult> that is still running. Because it is a struct, the "already known
/// value" case does not need a heap allocation at all, unlike Task.FromResult.
/// </summary>
public static async Task Run()
{
Console.WriteLine("=== ValueTask: core concept ===");
Console.WriteLine();
// ----------------------------------------------------------------------------
// Part 1: creating a ValueTask directly from an already known value.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 1: creating a ValueTask from a value ---");
// new ValueTask(value) wraps a result that is already known right now.
// Because ValueTask is a struct, this path does not allocate a Task
// object on the heap, unlike Task.FromResult, which still allocates one even
// though it is already completed.
ValueTask<int> instantAnswer = new(7);
Console.WriteLine($"[direct value] IsCompletedSuccessfully={instantAnswer.IsCompletedSuccessfully}");
// Awaiting an already-completed ValueTask returns immediately, without ever
// yielding control back to the caller.
int instantResult = await instantAnswer;
Console.WriteLine($"[direct value] Awaited result={instantResult}");
Console.WriteLine();
// ----------------------------------------------------------------------------
// Part 2: creating a ValueTask that wraps a real, still-running Task.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 2: creating a ValueTask that wraps a Task ---");
// 150 ms stands for real background work that cannot finish synchronously.
Task<int> backgroundWork = Task.Run(() =>
{
Thread.Sleep(150);
return 99;
});
// new ValueTask(task) is used when the work genuinely needs to run in
// the background. In this case ValueTask does not save any allocation - it is
// simply a thin wrapper around the Task - but the calling code still uses the
// same ValueTask API either way.
ValueTask<int> wrappedTask = new(backgroundWork);
Console.WriteLine($"[wrapped task] IsCompletedSuccessfully right after creation={wrappedTask.IsCompletedSuccessfully}");
// Awaiting here frees the calling thread while backgroundWork keeps running,
// instead of blocking it the way reading a synchronous Result would.
int wrappedResult = await wrappedTask;
Console.WriteLine($"[wrapped task] Awaited result={wrappedResult}");
Console.WriteLine();
// ----------------------------------------------------------------------------
// Part 3: a small cache that mixes both paths in one method.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 3: a cache that mixes both paths ---");
// First call for "Paris": the value is not cached yet, so GetTemperatureAsync
// must really start background work and the ValueTask wraps a live Task.
ValueTask<int> firstCall = GetTemperatureAsync("Paris");
Console.WriteLine($"[cache miss] IsCompletedSuccessfully right after the call={firstCall.IsCompletedSuccessfully}");
int firstTemperature = await firstCall;
Console.WriteLine($"[cache miss] Awaited result={firstTemperature}");
// Second call for the same city: the value is now cached, so the ValueTask
// completes synchronously and no background work or Task allocation happens.
ValueTask<int> secondCall = GetTemperatureAsync("Paris");
Console.WriteLine($"[cache hit] IsCompletedSuccessfully right after the call={secondCall.IsCompletedSuccessfully}");
int secondTemperature = await secondCall;
Console.WriteLine($"[cache hit] Awaited result={secondTemperature}");
Console.WriteLine();
// ----------------------------------------------------------------------------
// Part 4: converting a ValueTask to a Task when the value is needed more than once.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 4: converting a ValueTask with AsTask() ---");
// A ValueTask is meant to be consumed only once (one await, or one read of
// Result). If the value is needed more than once, or has to be combined with
// Task.WhenAll/Task.WhenAny, call AsTask() to obtain a regular Task
// that can safely be awaited multiple times.
ValueTask<int> singleUseValue = GetTemperatureAsync("Berlin");
Task<int> reusableTask = singleUseValue.AsTask();
int firstRead = await reusableTask;
int secondRead = await reusableTask;
Console.WriteLine($"[AsTask] First read: {firstRead}");
Console.WriteLine($"[AsTask] Second read: {secondRead}");
Console.WriteLine();
}
/// <summary>
/// Looks up a city's temperature. If the value is already cached, a ValueTask is
/// created directly from it (synchronous path, no Task allocated). Otherwise a real
/// background lookup is started and the resulting Task is wrapped in a ValueTask.
/// </summary>
private static ValueTask<int> GetTemperatureAsync(string city)
{
if (TemperatureCache.TryGetValue(city, out int cachedValue))
{
return new ValueTask<int>(cachedValue);
}
return new ValueTask<int>(FetchAndCacheTemperatureAsync(city));
}
/// <summary>
/// Simulates a slow network call to a weather service and stores the result in the
/// cache so that later lookups for the same city can complete synchronously.
/// </summary>
private static async Task<int> FetchAndCacheTemperatureAsync(string city)
{
// 200 ms stands for the network call; it is short enough to keep the demo fast.
await Task.Delay(200);
// A deterministic fake reading based on the city name keeps the output stable.
int freshValue = city.Length * 3;
TemperatureCache[city] = freshValue;
return freshValue;
}
Reading this walkthrough alongside the code: Part 1 proves that constructing a ValueTask from a value is instant and produces an already-successful task-like object. Part 2 shows the opposite end of the spectrum—wrapping a genuinely background-running Task<int>, where IsCompletedSuccessfully reads false immediately after creation, and await is used instead of a blocking read so the calling thread is freed while the 150 millisecond background work finishes. Part 3 is the heart of the whole idea: GetTemperatureAsync checks an in-memory Dictionary<string, int> cache first. On the first call for "Paris" the city is missing, so it falls through to FetchAndCacheTemperatureAsync, which awaits Task.Delay(200) to simulate a slow network call, computes a deterministic fake reading from the city name's length, stores it in the cache, and returns it—so the returned ValueTask<int> wraps a real, still-running Task and IsCompletedSuccessfully is false right after the call. The very next call for the same city finds the cached value immediately, so GetTemperatureAsync takes the new ValueTask<int>(cachedValue) branch instead, and IsCompletedSuccessfully is already true the moment the call returns. Part 4 then demonstrates the safe way to reuse a value: instead of awaiting the same ValueTask twice (which the type does not guarantee to support), singleUseValue.AsTask() is called exactly once to obtain an ordinary Task<int>, and that Task is the one that gets awaited twice, which is always safe.
Running Run() produces the following output, which is fully deterministic because every delay in the example is a fixed number of milliseconds and every "fake" value is computed from fixed input data:
=== ValueTask: core concept ===
--- Part 1: creating a ValueTask from a value ---
[direct value] IsCompletedSuccessfully=True
[direct value] Awaited result=7
--- Part 2: creating a ValueTask that wraps a Task ---
[wrapped task] IsCompletedSuccessfully right after creation=False
[wrapped task] Awaited result=99
--- Part 3: a cache that mixes both paths ---
[cache miss] IsCompletedSuccessfully right after the call=False
[cache miss] Awaited result=15
[cache hit] IsCompletedSuccessfully right after the call=True
[cache hit] Awaited result=15
--- Part 4: converting a ValueTask with AsTask() ---
[AsTask] First read: 18
[AsTask] Second read: 18
The real-world example applies exactly the same pattern to something almost every application does: look up the same piece of data more than once during a single operation, such as rendering a page that needs a user's profile in several places. RunUserProfileCacheScenario() asks for the profile of user 42 three times in a row. The first lookup is a cache miss and pays the cost of a simulated 250 millisecond database read; the second and third lookups for the same id are cache hits, served directly from a Dictionary<int, string> with no database access and no Task object allocated for either result—precisely the situation ValueTask is designed to make cheap. Here is the code exactly as it appears in the class file:
/// <summary>
/// Real-world example.
/// An application reads the same user's profile several times while rendering a page.
/// The first read loads it from a "database"; every later read for the same user id
/// comes from an in-memory cache and completes synchronously through ValueTask, without
/// a new Task object being allocated each time.
/// </summary>
public static async Task RunUserProfileCacheScenario()
{
Console.WriteLine("=== Real-world scenario: a cached user profile lookup ===");
// Lookup 1: user 42 is not cached yet, so this call really has to wait for the
// simulated database read.
string firstLookup = await GetUserProfileAsync(42);
Console.WriteLine($"Lookup 1 (cache miss): {firstLookup}");
// Lookup 2 and 3: the same user id is now cached, so both calls complete
// synchronously through ValueTask, with no database access and no Task allocated
// for either result.
string secondLookup = await GetUserProfileAsync(42);
Console.WriteLine($"Lookup 2 (cache hit): {secondLookup}");
string thirdLookup = await GetUserProfileAsync(42);
Console.WriteLine($"Lookup 3 (cache hit): {thirdLookup}");
Console.WriteLine();
}
/// <summary>
/// Looks up a user's profile, either from the cache (synchronous path) or, on the
/// first call for a given user id, by loading it from a "database".
/// </summary>
private static ValueTask<string> GetUserProfileAsync(int userId)
{
if (UserProfileCache.TryGetValue(userId, out string? cachedProfile))
{
return new ValueTask<string>(cachedProfile);
}
return new ValueTask<string>(LoadUserProfileFromDatabaseAsync(userId));
}
/// <summary>
/// Simulates a slow database read for a user profile and caches the result so that
/// later lookups for the same user id do not have to query the database again.
/// </summary>
private static async Task<string> LoadUserProfileFromDatabaseAsync(int userId)
{
// 250 ms stands for the database read; long enough to make the cache-miss path
// clearly slower than the cache-hit path in the printed output.
await Task.Delay(250);
string profile = $"User #{userId}";
UserProfileCache[userId] = profile;
return profile;
}
Running RunUserProfileCacheScenario() produces this deterministic output, where every line after the first appears essentially instantly because the profile is already sitting in the cache:
=== Real-world scenario: a cached user profile lookup ===
Lookup 1 (cache miss): User #42
Lookup 2 (cache hit): User #42
Lookup 3 (cache hit): User #42
As a rule of thumb: reach for ValueTask<TResult> when a method is called very frequently and its synchronous, already-cached path is common—caches, pooled resources, and lookups that usually hit memory but occasionally have to fall back to real I/O are the textbook cases, and this is also exactly the shape of API used internally by high-performance building blocks such as PipeReader and modern socket APIs in .NET. Avoid it for ordinary application code where the method is rarely called in a hot loop, where the result may need to be awaited more than once, or where it must be combined with Task.WhenAll/Task.WhenAny without first calling AsTask()—in all of those cases a plain Task<TResult> is simpler, safer, and, for most everyday code, just as fast in practice. When in doubt, start with Task, and only switch a specific hot-path method to ValueTask once profiling shows that its allocations actually matter.
Both methods are called one after another from Program.cs, awaited because both are now async Task methods:
await ValueTaskExample.Run();
await ValueTaskExample.RunUserProfileCacheScenario();