📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is ValueTask, when should you use it instead of Task, and what rules must callers follow?

ValueTask and `ValueTask<T>`: allocation-free synchronous completion, IValueTaskSource pooling, consumption rules and when Task is still the better choice.

Senior Theory Advanced allocations async performance valuetask

🎯 Key points

  • ValueTask<T> is a struct wrapping a result, a Task<T> or an IValueTaskSource<T>
  • It avoids allocations when methods usually complete synchronously
  • Await once; never await concurrently; no .Result before completion
  • Use .AsTask() to store, combine or await more than once
  • Pooled sources make misuse return wrong results
  • Default to Task; use ValueTask on measured hot paths or where the API requires it

⏱ 30-second answer

ValueTask<T> is a struct that holds either a result, a Task<T> or an IValueTaskSource<T>. It avoids allocating a task when a method usually completes synchronously (cache hits, buffered reads) and lets libraries pool the async case. The cost is stricter rules: await it once, never await it concurrently, and never read .Result before it completes; call .AsTask() if you need any of that. Use Task by default and choose ValueTask for measured hot paths or when an interface already uses it, like Stream.ReadAsync(Memory<byte>) or IAsyncDisposable.DisposeAsync.

🗣 Full answer (senior)

How I would answer this in an interview

"ValueTask<T> is a struct that can hold one of three things: the result itself, a Task<T>, or an IValueTaskSource<T> plus a token. It exists for two reasons: to avoid allocating a Task<T> when a method usually completes synchronously (a buffered read, a cache hit, a channel that already has data), and to let libraries reuse one source object for many asynchronous operations. The price is stricter consumption rules: await it once, never await it from two places, never read .Result before it's complete, and if you need to store it, combine it with WhenAll or await it twice, call .AsTask() once and use the task. My default is Task. I choose ValueTask on measured hot paths with frequent synchronous completion, or where the API already requires it, like Stream.ReadAsync(Memory<byte>), IAsyncEnumerator<T>.MoveNextAsync and IAsyncDisposable.DisposeAsync."


1. Task vs ValueTask

Aspect Task<T> ValueTask<T>
Kind Class (heap object) Struct (wraps result / Task<T> / IValueTaskSource<T>)
Synchronous completion Allocates (except a few runtime-cached results) No allocation
Asynchronous completion Allocates the task (+ state machine box) Allocates unless backed by a pooled IValueTaskSource
Await multiple times Yes No
Concurrent awaits Yes No
.Result before completion Blocks Not allowed
WhenAll / caching / storing Yes Only after .AsTask()
Size when passed around One reference Larger struct (copy cost)
Default choice Yes Hot paths / API-mandated

2. The consumption rules

Do Don't
var x = await GetAsync(); var vt = GetAsync(); await vt; await vt;
var t = GetAsync().AsTask(); then await/combine/store t Store a ValueTask in a field and await it later from elsewhere
Check vt.IsCompletedSuccessfully then read vt.Result Read .Result / GetAwaiter().GetResult() on an incomplete ValueTask
Convert immediately before Task.WhenAll Convert late after already awaiting it

Why the rules exist: a pooled IValueTaskSource is reset and reused for the next operation once you've consumed the result. A second await on the old ValueTask can throw, hang, or, worst of all, return another operation's result.

3. When it pays off

Scenario Choice Reason
Buffered reader / parser where most calls hit the buffer ValueTask<T> Synchronous fast path is allocation-free
Cache-first lookups on hot paths ValueTask<T> Hits don't allocate
Implementing Stream, IAsyncDisposable, IAsyncEnumerator<T> ValueTask Required by the interface
Typical service method doing a DB/HTTP call every time Task<T> Always async: no saving, stricter rules for nothing
Results combined with WhenAll or cached Task<T> Composability
Extreme-throughput infrastructure (sockets, channels) IValueTaskSource<T> / pooling builder Reuse across async completions; measured

4. Pooling async methods

Since .NET 6 / C# 10 you can opt a method into pooled state-machine boxes:

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<int> ReadAsync() { ... }

That removes allocations even when the method completes asynchronously, at the cost of pool management overhead and memory retained in pools. Benchmark under realistic concurrency before adopting it.

Production example

Our [market data gateway] parsed frames with Task<Frame> ReadFrameAsync() at [about 3 million calls a minute], and [around 90%] of frames were already in the read buffer. dotnet-counters showed a high Gen 0 GC rate, and allocation profiling put Task<Frame> near the top. A BenchmarkDotNet run with [MemoryDiagnoser] confirmed one task allocation per call on the buffered path. I changed it to ValueTask<Frame> with a synchronous fast path that returns the parsed frame directly, then reviewed every caller to make sure each awaited it exactly once (one caller stored it in a field, which I changed to .AsTask()). I enabled analyser CA2012 as an error. Allocations on that path dropped to [nearly zero] and Gen 0 GCs fell by [about 40%].

🔍 What the interviewer is checking

Checks performance-aware async knowledge and discipline about trade-offs.

Green flags

  • Explains the synchronous-completion allocation saving.
  • States the once-only consumption rules and .AsTask().
  • Mentions IValueTaskSource<T> pooling.
  • Recommends measuring and defaulting to Task.

Red flags

  • "Always use ValueTask, it is faster."
  • Unaware of the consumption rules.

📚 Learn in depth

1. The struct's shape

public readonly struct ValueTask<TResult>
{
    internal readonly object? _obj;        // null, Task<TResult>, or IValueTaskSource<TResult>
    internal readonly TResult? _result;    // used when _obj is null (synchronous result)
    internal readonly short _token;        // identifies the operation for an IValueTaskSource
    internal readonly bool _continueOnCapturedContext;
}

The awaiter checks _obj: null → return _result; Task<T> → behave like a task; IValueTaskSource<T> → call GetStatus(token), OnCompleted(...), GetResult(token).

2. IValueTaskSource<T> and token checks

source (reused object)
  op #1: token = 5 -> ValueTask(source, 5) -> consumer awaits -> GetResult(5) -> source resets
  op #2: token = 6 -> ValueTask(source, 6) -> ...
old ValueTask(source, 5) awaited again -> GetResult(5) on a source now at token 6
  -> InvalidOperationException (if checked) or wrong data (if the implementation doesn't check)

ManualResetValueTaskSourceCore<T> (a mutable struct helper) implements the bookkeeping: version/token, continuation, result, RunContinuationsAsynchronously. Sockets (SocketAsyncEventArgs-based AwaitableSocketAsyncEventArgs), System.IO.Pipelines and Channel<T> readers use this pattern.

3. A safe fast-path pattern

public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
    if (_buffered.Length > 0)
    {
        int n = CopyFromBuffer(buffer.Span);
        return new ValueTask<int>(n);              // synchronous, no allocation
    }
    return ReadSlowAsync(buffer, ct);              // async path (async ValueTask<int> method)
}

private async ValueTask<int> ReadSlowAsync(Memory<byte> buffer, CancellationToken ct)
{
    await FillBufferAsync(ct).ConfigureAwait(false);
    return CopyFromBuffer(buffer.Span);
}

Splitting the fast path into a non-async method also avoids state machine overhead entirely when synchronous.

4. Consuming on a hot path

ValueTask<int> vt = reader.ReadAsync(buf, ct);
int n = vt.IsCompletedSuccessfully ? vt.Result : await vt;   // read once, either branch

This is the only legitimate case of touching .Result: after confirming completion, and never touching vt again.

5. Analysers

Rule Catches
CA2012 ValueTask used incorrectly (awaited twice, stored, discarded)
CA1849 / VSTHRD103 (VS threading analyzers) Blocking calls in async methods

6. Version notes

Version Change
.NET Core 2.0 ValueTask<T> (result or Task<T>)
.NET Core 2.1 IValueTaskSource<T>, non-generic ValueTask
.NET Core 3.0 ManualResetValueTaskSourceCore<T>; IAsyncEnumerable<T>/IAsyncDisposable use ValueTask
.NET 5 ValueTask.FromResult, ValueTask.CompletedTask, FromException, FromCanceled
.NET 6 / C# 10 PoolingAsyncValueTaskMethodBuilder, method-level [AsyncMethodBuilder]
.NET 10 Same rules

7. Try it yourself

  1. Benchmark Task<int> vs ValueTask<int> for a cache hit with [MemoryDiagnoser]; note allocated bytes.
  2. Repeat for a method that always awaits Task.Yield(); observe no saving.
  3. Add [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] to the always-async version and re-measure.
  4. Await a Channel<int>.Reader.ReadAsync() result twice and see what happens.
  5. Enable CA2012 and introduce a double-await to see the warning.
  6. Implement a tiny IValueTaskSource<int> using ManualResetValueTaskSourceCore<int> and reuse it for 10 operations.

💻 Code

using System.Collections.Concurrent;

var repo = new PriceRepository();

// Correct: await once.
decimal p1 = await repo.GetPriceAsync("ABC");

// Need it twice or in WhenAll? Convert once.
Task<decimal> t = repo.GetPriceAsync("XYZ").AsTask();
await Task.WhenAll(t, Task.Delay(10));
Console.WriteLine($"{p1} {t.Result}");

public sealed class PriceRepository
{
    private readonly ConcurrentDictionary<string, decimal> _cache = new();

    public ValueTask<decimal> GetPriceAsync(string sku)
    {
        // Fast path: synchronous, no allocation.
        if (_cache.TryGetValue(sku, out var price))
            return ValueTask.FromResult(price);

        return new ValueTask<decimal>(LoadAsync(sku));
    }

    private async Task<decimal> LoadAsync(string sku)
    {
        await Task.Delay(20); // stands in for a database call
        decimal price = sku.Length * 10m;
        _cache[sku] = price;
        return price;
    }
}

Wrong usage to avoid:

ValueTask<decimal> vt = repo.GetPriceAsync("ABC");
var a = await vt;
var b = await vt; // undefined behaviour with pooled sources

⚠ Common mistakes

1. Using ValueTask everywhere "for performance"

What happens: every service method returns ValueTask<T>. Why it is wrong: if the method is usually async, there's no saving, and callers get stricter rules and a larger struct. Do instead: default to Task; switch for measured hot paths with frequent synchronous completion.

2. Awaiting the same ValueTask twice

What happens: await vt; in two places or in a retry loop. Why it is wrong: pooled sources may be reused, giving exceptions or wrong results. Do instead: await once, or .AsTask() first.

3. Storing a ValueTask in a field or collection

What happens: _pending = reader.ReadAsync(); to await later. Why it is wrong: the backing source may be reset before you consume it. Do instead: store reader.ReadAsync().AsTask().

4. Reading .Result before completion

What happens: var x = vt.Result; on an incomplete ValueTask. Why it is wrong: not supported for IValueTaskSource backing; can break the source. Do instead: await, or check IsCompletedSuccessfully first.

5. Passing ValueTasks to Task.WhenAll late

What happens: awaiting some, then converting others for WhenAll. Why it is wrong: conversion after any consumption violates the once-only rule. Do instead: call .AsTask() immediately when you know you'll combine.

6. Discarding a ValueTask

What happens: _ = stream.WriteAsync(data);. Why it is wrong: errors are lost and pooled sources may never be returned. Do instead: await it (CA2012 flags this).

7. Concurrent awaits on one ValueTask

What happens: two consumers await the same instance from different threads. Why it is wrong: IValueTaskSource supports a single continuation. Do instead: .AsTask() and share the task.

8. Adopting pooling builders without measuring

What happens: PoolingAsyncValueTaskMethodBuilder added to many methods. Why it is wrong: pools add overhead and retained memory; gains depend on workload. Do instead: benchmark under realistic concurrency first.

9. Returning ValueTask from public application APIs consumers will compose

What happens: a service interface exposes ValueTask<T> and callers use WhenAll everywhere. Why it is wrong: pushes complexity and risk to every caller. Do instead: use Task<T> for general-purpose application APIs.

10. Using .GetAwaiter().GetResult() on a ValueTask at sync boundaries

What happens: blocking on an incomplete ValueTask. Why it is wrong: not supported for source-backed instances. Do instead: .AsTask().GetAwaiter().GetResult() if you truly must block.

🏛 Senior insight: trade-offs

ValueTask is a library-author tool. I use it in infrastructure code on paths that profiling shows are hot and usually synchronous, and I keep Task in application services where readability and composability matter more.

Scenario Choice
Buffered reader, cache hit common ValueTask<T>
Implementing Stream, IAsyncDisposable, IAsyncEnumerator<T> ValueTask (required by the API)
Typical service method doing I/O Task<T>
Result combined with WhenAll Task<T>
Extreme throughput, pooled IValueTaskSource<T> or pooling builder, measured

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares with Task of T Why: Checks result access rules
    How to answer

    Task<T> derives from Task and adds a Result of type T. The safe way to get it is await, which unwraps the value and rethrows the original exception. .Result blocks the calling thread and wraps failures in AggregateException; GetAwaiter().GetResult() also blocks but throws the original exception. Because Task<T> is a class, it is not covariant: a Task<string> is not a Task<object>. Return Task.FromResult for synchronous results and use TaskCompletionSource<T> to turn callbacks into a Task<T>.

  2. When: Candidate mentions method builders or pooling Why: Probes the async state machine
    How to answer

    The compiler turns an async method into a small stub plus a generated type implementing IAsyncStateMachine. The type holds a state integer, a method builder (such as AsyncTaskMethodBuilder<T>), awaiter fields and any locals that live across an await. The stub initialises it and calls builder.Start, which runs MoveNext synchronously. MoveNext is a switch on state: it runs until an incomplete await, stores the awaiter, sets state, and asks the builder to call MoveNext again on completion. In Release builds the machine is a struct that is only boxed to the heap the first time the method actually suspends, so synchronously completing async methods are cheap.

  3. When: Candidate talks about gen0 allocations Why: Connects to GC pressure
    How to answer

    GC pressure is how much work your code forces the GC to do: driven by allocation rate (how fast you allocate), survival (how much of that stays alive and is promoted) and heap size (how much there is to mark). Measure it with dotnet-counters (% Time in GC, allocation rate, Gen 0/1/2 counts, heap sizes) and GC traces. Reduce it by allocating less in hot paths (spans, pooling, streaming, avoiding boxing and LINQ in tight loops), shortening or stabilising lifetimes, and shrinking long-lived object graphs. Separately, GC.AddMemoryPressure tells the GC about native memory owned by small managed objects so it collects them in time; pair it with RemoveMemoryPressure.

🛠 Real-world problems: how you handle them

Reducing allocations in a frame parser
Situation
A [market data gateway] parsed frames with `Task<Frame> ReadFrameAsync()` at [3 million calls a minute]; most frames were already buffered.
Signals
dotnet-counters showed a high gen0 GC rate; allocation profiling put `Task<Frame>` near the top.
Investigation
BenchmarkDotNet with `MemoryDiagnoser` confirmed one task allocation per call on the buffered path.
Resolution
Changed the method to return `ValueTask<Frame>` with a synchronous fast path and reviewed all callers for the once-only rule.
Prevention
Added an analyser check (CA2012) for ValueTask misuse and kept the benchmark in CI.

How to tell it (STAR)

In our [market data gateway] gen0 GCs were high, and allocation profiling pointed at the `Task<Frame>` from a read method that usually completed synchronously. I switched it to `ValueTask<Frame>` with a fast path, checked every caller awaited it once, and enabled CA2012. Allocations on that path dropped to [almost zero].

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me