ValueTask<T> is a struct alternative to Task<T> for async methods that frequently finish without ever going asynchronous. The win is allocation: a Task<T> is a reference type that lives on the heap, and on a hot path that mostly returns a cached value, those allocations add up. This lesson covers why ValueTask exists, how it works, the strict rules you have to follow when using it, and when it's actually worth using.
Task and Task<T> are reference types. Every time an async method returns one, the runtime has to put an object on the managed heap. That's fine for most code. A network call, a database query, a file read, all of those run for milliseconds at minimum, and one extra heap allocation per call disappears into the noise.
The problem shows up on hot paths that mostly complete synchronously. Consider a product cache lookup. The first request reads from a database. Every request after that for the same product comes from an in-memory dictionary, which finishes in nanoseconds. The async method still returns a Task<Product> because its signature says so, and the runtime still allocates a Task object every single time, even though no awaiting actually happened.
The first call hits the database. Calls two and three hit the cache and finish synchronously, yet each one still produces a fresh Task<Product> on the heap. For a cache that gets hit thousands of times per second per server, those allocations stack up into measurable GC pressure.
Returning Task<T> from a method that often completes synchronously allocates a Task<T> object per call. Under load, that translates to higher Gen 0 collection frequency and pauses that show up in tail latency.
Task.FromResult doesn't help much here. It still allocates a brand-new Task<T> for every call. The runtime does cache a few common values (a completed Task, Task<bool> for true and false, a small range of ints, and a few others), but anything outside that small set allocates fresh.
ValueTask<T> is a value type (a readonly struct) that holds one of two things: a result value directly, or a reference to a Task<T> that hasn't completed yet. When a method finishes synchronously, the struct stores the result inline and no heap allocation happens. When the method actually needs to suspend and resume later, the struct wraps a Task<T> underneath, and you pay the same allocation cost as plain Task<T> would have paid.
The fast path is the point. If the method finishes without yielding, the returned ValueTask<T> carries the result inside the struct, the caller awaits it, and the awaiter sees "already complete" and returns the value without going through the heap. Zero allocations on that branch.
On the slow path, ValueTask<T> doesn't avoid the allocation. It still has to wrap a real Task<T> (or an IValueTaskSource<T>, which advanced libraries use for object pooling). The savings come only from the calls that genuinely complete synchronously.
There are two variants: ValueTask<T> for methods that return a value, and the non-generic ValueTask for methods that return nothing. Both follow the same rules.
Both methods are awaitable. From the caller's point of view, nothing changes. The difference is only visible to a profiler counting allocations.
The canonical use case for ValueTask<T> is "check a fast in-memory source first; fall back to an async source on miss." Cache lookups, dictionary reads, lazy initialization, buffered I/O, all of these share the same shape. The fast path returns immediately. The slow path awaits.
The product cache rewritten with ValueTask<Product>:
The first call goes to LoadAndCacheAsync, which allocates a Task<Product> underneath the ValueTask. Calls two and three hit the dictionary, build a ValueTask<Product> from the cached Product directly, and never touch the heap on the return path.
The outer method is not marked async. That's deliberate. If you write public async ValueTask<Product> GetAsync(...), the compiler generates an async state machine, and that state machine wraps the work in a builder that may itself allocate. For the synchronous fast path to be truly allocation-free, the outer method needs to return a ValueTask<T> directly without going through the async machinery. The async keyword goes on the inner helper that actually does the awaiting.
A typical product-cache benchmark shows roughly an order-of-magnitude drop in allocations per call on cache hits when switching from Task<T> to ValueTask<T> on this pattern. The exact numbers vary, but the direction is consistent: fewer allocations on the synchronous branch, identical cost on the asynchronous one.
ValueTask<T> is more dangerous than Task<T>, and the danger comes from a single design choice: a ValueTask<T> may be backed by an IValueTaskSource<T> that the implementation pools and reuses. The runtime can recycle the backing object as soon as you've awaited it once. That recycling is what makes high-throughput libraries fast, and it's also what makes the rules below non-negotiable.
The official guidance from the .NET team is short: await a `ValueTask<T>` exactly once, and don't do anything else with it. The expanded version:
Rule 1: Await it at most once.
The first await consumes the ValueTask. By the time the second await runs, the underlying IValueTaskSource<T> could already be serving a different operation, and what comes back is undefined: a wrong value, an exception, or a deadlock.
Rule 2: Don't store it.
A ValueTask is meant to be awaited at the call site, immediately. Storing it in a field, returning it from one method and awaiting it in another a few seconds later, putting it in a list, all of these break the "use it once, right now" contract.
Rule 3: Don't await it concurrently.
Two callers racing to await the same ValueTask is the same problem as awaiting it twice: the backing source can only serve one consumer.
Rule 4: Don't call `.GetAwaiter().GetResult()` more than once.
.GetAwaiter().GetResult() is the synchronous-blocking extraction path. It also consumes the ValueTask. Calling it once is fine if the value is already available; calling it twice is the same race as Rule 1.
Rule 5: Don't touch `.Result` before completion.
ValueTask<T>.Result is only valid after the task is complete. If you read it before then, on a ValueTask<T> backed by an in-flight operation, behavior is undefined. The same goes for the non-generic ValueTask.GetAwaiter().GetResult() while the task is still running.
If any of these rules feel hard to follow, that's the signal that Task<T> fits your use case better. Task<T> is forgiving: you can await it any number of times, store it for later, hand it to multiple consumers, and check .Result any time after completion. ValueTask<T> trades that flexibility for one specific optimization.
The pattern is always the same: call, await, done. No intermediate variables that outlive the await.
Task<T> is the default. Use ValueTask<T> only when all three of these are true:
If any of those three is missing, use Task<T>. Specifically:
Task<T>.ValueTask saves nothing and costs you flexibility.Task<T> callers can do whatever they want.ValueTask<T> is library code's tool, not application code's tool. The .NET runtime team uses it in places like Stream.ReadAsync and Socket.ReceiveAsync because those APIs serve buffered reads from a pre-filled buffer the vast majority of the time. They benchmarked, measured, and decided the rules were worth the gain.
| Aspect | Task<T> | ValueTask<T> |
|---|---|---|
| Kind | Reference type (class) | Value type (struct) |
| Allocation on sync completion | One Task<T> on the heap | None |
| Allocation on async completion | One Task<T> on the heap | One Task<T> (or IValueTaskSource<T>) on the heap |
| Can be awaited multiple times | Yes | No, exactly once |
| Can be stored in a field | Yes | No |
| Can be awaited concurrently | Yes | No |
Works with Task.WhenAll / WhenAny directly | Yes | No, must call .AsTask() first |
| Default choice | Yes | Only when measured |
The flexibility on the Task<T> side comes from being a long-lived heap object. Anyone can hold a reference to it, anyone can await it, the result lives there permanently. ValueTask<T> is the opposite: short-lived, single-use, low-overhead.
When you need to do anything beyond awaiting once at the call site, convert the ValueTask<T> to a Task<T> with .AsTask(). The classic case is wanting to combine several async operations with Task.WhenAll:
Task.WhenAll requires Task instances. Calling .AsTask() on each ValueTask<Product> materializes a Task<Product> that the combinator can hold onto. If the underlying source was already a Task<T>, .AsTask() returns that same instance. If the source was a synchronous result, .AsTask() wraps it in a freshly allocated Task<T>.
Calling .AsTask() is your "I need real Task semantics now" escape hatch. After conversion, the resulting Task<T> follows all the normal Task<T> rules: await it as many times as you want, store it, share it with other consumers. The cost is that the synchronous-fast-path allocation savings are gone for that call.
.AsTask() allocates a Task<T> when the ValueTask<T> was carrying a synchronous result. If you find yourself calling .AsTask() on every result, the method should have returned Task<T> in the first place.
The non-generic ValueTask has the same .AsTask() method for the same purpose. There's also ValueTask<T>.Preserve(), which returns a new ValueTask<T> that you can safely await multiple times by materializing the backing task internally. Preserve is rare in practice. If you need multi-await semantics, return Task<T> from the start.
Treat the numbers below as illustrative. Real numbers depend on the runtime version, the workload, and the measurement harness. The shape, though, is consistent across every published benchmark.
| Scenario | Task<T> allocations | ValueTask<T> allocations |
|---|---|---|
| Method completes synchronously (cache hit) | 1 per call | 0 per call |
| Method completes asynchronously (cache miss) | 1 per call | 1 per call |
| 1,000,000 calls with 99% cache hit ratio | ~1,000,000 task allocations | ~10,000 task allocations |
The right-hand column doesn't show "100x faster" because allocation cost isn't the only factor. The struct has to be copied, the await machinery still runs, and the wall-clock time on a cache hit is dominated by the dictionary lookup either way. What does change is GC pressure and tail latency: fewer Gen 0 collections, shorter pauses, more predictable latency under load.
If you want to confirm the numbers for your own code, the standard approach is BenchmarkDotNet with [MemoryDiagnoser] enabled, comparing the two implementations side by side over a million-call sample. The output will show Allocated per operation, which makes the difference concrete.
10 quizzes