ValueTask and IAsyncEnumerable<T> are two of the most misused async APIs in modern .NET, because their syntax looks almost identical to Task and IEnumerable<T> while their contracts are stricter and easier to violate silently. Senior interviews use them to test whether a candidate understands why an API exists, not just how to call it: when a ValueTask genuinely avoids an allocation versus when it just adds complexity, and how an async stream keeps memory bounded by pulling data lazily instead of buffering it all up front. At the 10-to-20-year level, interviewers expect you to recognize the failure modes — awaiting a ValueTask twice, forgetting [EnumeratorCancellation], assuming context propagates through a loop it does not — because those bugs pass code review and only surface in production. Expect the conversation to move quickly from API mechanics into performance trade-offs and correctness guarantees.
Q1 When does returning ValueTask<T> instead of Task<T> actually pay off, and when does it backfire?#
Short answer: It pays off when a method completes synchronously on the common path and is called often enough that a Task<T> allocation per call shows up in a profiler, such as a cache lookup or a buffered stream read; it backfires when the method almost always completes asynchronously, since you then pay a larger state machine for no allocation benefit.
ValueTask<T> is effectively a union of a synchronous result, a Task<T>, and an IValueTaskSource<T>. When the operation finishes synchronously, no Task<T> is allocated at all — the result sits inline in the struct. That is the entire value proposition, and it only applies when synchronous completion is common. The cost is real: ValueTask<T> carries multiple fields, so it is physically larger than a Task<T> reference, and any async method that awaits one stores that larger struct in its generated state machine. If a method almost always completes asynchronously anyway, you get a bigger state machine and no allocation savings, because a Task<T> still gets created internally on the actually-async path.
The recommended default is Task<T>, treating ValueTask<T> as a targeted optimization applied only after profiling shows an allocation hot spot, typically deep in a hot loop such as a per-request cache hit or a buffered Stream.ReadAsync call. The BCL follows this pattern itself: Stream.ReadAsync returns ValueTask<int> precisely because buffered reads complete synchronously most of the time.
What interviewers look for: An answer grounded in "profiler first," not "ValueTask is always faster." Understanding the state-machine size trade-off is a strong signal of real usage.
Common mistakes:
- Applying
ValueTask<T>to every async method as a blanket performance change. - Not knowing that the non-generic
ValueTaskis even less broadly useful thanValueTask<T>, since it has no result to justify saving an allocation beyond theTaskitself.
Q2 What are the hard rules for consuming a ValueTask, and what happens if you break them?#
Short answer: Await it exactly once, never concurrently, never read .Result or call .GetAwaiter().GetResult() before it has completed, and never mix consumption styles on the same instance — breaking any of these is undefined behavior, not just wasted work.
These rules exist because a ValueTask<T> can be backed by a pooled IValueTaskSource<T> under the hood. Once you await it, the underlying source may be reset and handed back to a pool for an entirely different operation. Awaiting the same ValueTask<T> a second time can observe a different operation's result, throw, or corrupt state — unlike re-awaiting a Task, which is merely wasteful, re-awaiting a ValueTask is genuinely unsafe. The same applies to reading .Result before checking completion, or calling .AsTask() more than once on the same instance.
If you need to await the same logical operation from more than one place — cache it, pass it into Task.WhenAll, retry it — convert it once and reuse the result:
ValueTask<int> vt = ReadCachedAsync(key);
// Safe: convert once, then treat it like any other Task.
Task<int> asTask = vt.AsTask();
var results = await Task.WhenAll(asTask, OtherWorkAsync());AsTask() always allocates a Task<T>; Preserve() is the alternative when you want to keep it as a ValueTask<T> but still be able to await it more than once — it avoids the allocation when the original operation already completed synchronously and successfully.
What interviewers look for: Explaining why the rules exist, not just reciting "don't await twice." Knowing AsTask() and Preserve() as escape hatches is a strong senior signal.
Q3 How does IValueTaskSource<T> let a ValueTask avoid allocating at all, and where is it used in the BCL?#
Short answer: IValueTaskSource<T> lets a type act as the backing store for a ValueTask<T> without allocating a Task<T>, by implementing a small state machine — get status, get result, register a continuation — that the runtime can pool and reuse across many logical async operations.
A ValueTask<T> can be constructed directly from an IValueTaskSource<T> plus a short token, roughly new ValueTask<int>(source, token). The token lets the source detect a stale instance being awaited again after it has already been reset and reused, which is part of what makes the "await exactly once" rule enforceable rather than purely a documentation convention. The BCL provides ManualResetValueTaskSourceCore<T> as a reusable building block for implementing IValueTaskSource<T> correctly, handling continuation registration and completion signaling so you do not hand-write that state machine yourself.
This machinery is exactly how the highest-throughput I/O paths in .NET avoid allocating a Task per operation: socket APIs and System.IO.Pipelines both use pooled IValueTaskSource implementations internally, because a server handling many concurrent connections cannot afford a Task<int> allocation for every read. Application code rarely implements IValueTaskSource<T> directly — it is a tool for library authors building the lowest layer of an I/O stack, not something typical business logic reaches for.
What interviewers look for: Recognizing this as an allocation-avoidance mechanism used by low-level I/O libraries, and roughly how token-based reuse works, without necessarily having implemented one.
Follow-up questions:
- Why does the token parameter matter for correctness, not just as an implementation detail?
- Why would you avoid
IValueTaskSource<T>for a typical application-level async method?
Q4 How do you write a cancellable async stream, and what does [EnumeratorCancellation] actually do?#
Short answer: [EnumeratorCancellationAttribute] on a CancellationToken parameter of an async-iterator method tells the compiler to route the token passed to GetAsyncEnumerator(CancellationToken) into that parameter, so a consumer calling .WithCancellation(token) actually reaches the loop body instead of being silently ignored.
async IAsyncEnumerable<Reading> StreamReadingsAsync(
Sensor sensor,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var raw in sensor.SubscribeAsync(cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
yield return Transform(raw);
}
}
await foreach (var reading in StreamReadingsAsync(sensor).WithCancellation(cts.Token))
{
Consume(reading);
}Without the attribute, the method still compiles — cancellationToken is just an ordinary parameter as far as the language is concerned — but a token supplied through WithCancellation on the consumer side never reaches it, because the generated GetAsyncEnumerator override only wires the token into that parameter when the attribute is present. This is one of the easiest cancellation bugs to ship, since the code looks correct and compiles cleanly, and only fails to cancel at runtime. ThrowIfCancellationRequested inside the loop is still worth keeping even with the attribute in place, because yield return points are the only places an iterator is cooperatively interrupted — if work between yields is expensive, check the token explicitly.
What interviewers look for: Knowing this attribute exists and precisely what breaks without it — most candidates know async streams support cancellation in principle but cannot explain the wiring.
Common mistakes:
- Adding a
CancellationTokenparameter and forgetting the attribute, then being confused when cancellation appears to do nothing. - Assuming the token is filled in automatically like dependency injection — it must be attributed.
Q5 What is the difference between passing a token to GetAsyncEnumerator directly and using WithCancellation?#
Short answer: WithCancellation(token) is sugar that calls GetAsyncEnumerator(token) for you inside an await foreach; calling GetAsyncEnumerator yourself is the lower-level path used when manually driving MoveNextAsync/Current, and the two must agree on which token wins when a method also declares its own explicit token parameter.
IAsyncEnumerable<T>.GetAsyncEnumerator(CancellationToken) is the actual interface method; WithCancellation is a TaskAsyncEnumerableExtensions extension method that wraps the source so the compiler-generated await foreach code calls GetAsyncEnumerator with that token instead of the default. You call GetAsyncEnumerator directly only when hand-writing enumeration logic outside a foreach, which is rare in application code. The interplay to watch: if an iterator method takes an explicit CancellationToken parameter and also receives one through [EnumeratorCancellation] from GetAsyncEnumerator, the attributed parameter is overwritten by whichever token the enumerator receives once a caller also chains WithCancellation. In practice, pick one path per method — either an explicit token argument and never WithCancellation, or no explicit argument and rely entirely on WithCancellation — to avoid ambiguity about which one actually wins.
What interviewers look for: Understanding that these are two views of the same mechanism, plus the specific gotcha about which token wins when both are supplied — a real source of subtle pipeline bugs.
Follow-up questions:
- Why does
[EnumeratorCancellation]only work on a parameter, not on the method itself? - In code review, what would you flag about a method that accepts both an explicit token and expects
WithCancellationto be used?
Q6 Why and how would you use ConfigureAwait(false) with await foreach over an async stream?#
Short answer: .ConfigureAwait(false) on an async stream is the TaskAsyncEnumerableExtensions.ConfigureAwait extension method, and it does for every MoveNextAsync and DisposeAsync call in the loop what a regular .ConfigureAwait(false) does for a single await: it stops each continuation from being marshaled back onto a captured SynchronizationContext.
await foreach (var item in ProduceItemsAsync().ConfigureAwait(false))
{
await ProcessAsync(item).ConfigureAwait(false);
}Without it, every iteration of an await foreach running under a context-sensitive environment would hop back to that context after each MoveNextAsync, which is wasteful when the loop body has no need to run there — the same reasoning that applies to ordinary await in library code. The rule of thumb carries over directly: library and infrastructure code with no reason to resume on a specific context should use ConfigureAwait(false) throughout, including on the stream expression and on every await inside the loop body; UI-facing code that needs to marshal back after each item should not. One detail worth stating explicitly in an interview: ConfigureAwait(false) on the stream only affects the MoveNextAsync and DisposeAsync calls the compiler generates for the loop machinery — it does not automatically propagate to await calls written inside the loop body, which each need their own ConfigureAwait(false).
What interviewers look for: Recognizing this as the same context-capture concern as ordinary await, correctly scoped to library code, and knowing it must be applied per-await inside the body too.
Common mistakes: Assuming ConfigureAwait(false) on the stream expression covers every await inside the loop body automatically.
Q7 How do you implement backpressure and buffering with async streams?#
Short answer: A hand-written async IAsyncEnumerable<T> is naturally pull-based and has no inherent buffer — the producer body only runs up to the next yield return when the consumer calls MoveNextAsync — so single-stream backpressure exists for free; buffering and multi-consumer fan-out are exactly the cases where you drop a Channel<T> underneath the stream instead.
await foreach consuming an async iterator is inherently one item at a time: the iterator body suspends at yield return until the consumer asks for the next item, so a slow consumer automatically slows the producer's execution — there is no queue building up in between, because there is no queue in the simplest case. That pull-based model breaks down once the producer needs to run ahead of the consumer, buffer a bounded amount of work, or serve multiple independent consumers from one producer. At that point, the right move is for the producer to write into a Channel<T> and expose channel.Reader.ReadAllAsync() as the public IAsyncEnumerable<T>: you get bounded buffering and explicit BoundedChannelFullMode backpressure behavior, and callers still just see an IAsyncEnumerable<T> and use await foreach exactly as before. A common senior-level design layers these two concerns deliberately: an internal channel handles buffering and backpressure between a background producer and the outside world, while the public surface stays a plain async stream.
What interviewers look for: Recognizing that plain async iterators have no buffer at all — a feature, not a limitation — and knowing exactly when to layer a channel underneath for buffering.
Follow-up questions:
- What changes if two different consumers need to iterate the same logical stream independently?
- How would you cap memory if the producer can run arbitrarily far ahead of a slow consumer?
Q8 How would you unit test code that consumes IAsyncEnumerable<T> together with cancellation?#
Short answer: Materialize only as much of the stream as you need to assert on, drive cancellation with a CancellationTokenSource you control from the test, and assert both on the items produced before cancellation and on the exact exception type thrown after it.
A typical test drives the enumerator manually rather than relying on helpers that buffer everything: getting an enumerator with a controlled token, calling MoveNextAsync to assert on individual items, then cancelling between calls and asserting the next call throws OperationCanceledException rather than hanging or returning stale data. For a producer built on yield return with an [EnumeratorCancellation] parameter, test that cancellation actually reaches the method body — not just that a token was accepted — by having whatever the method awaits internally observe cancellation deterministically, such as a controllable TaskCompletionSource rather than a real timer, so the test stays fast and not flaky. If the stream is layered over a Channel<T>, test the two layers somewhat independently: verify the channel's completion and cancellation behavior directly against ChannelReader<T>, and separately verify the public IAsyncEnumerable<T>-returning method forwards its token correctly, which is a much smaller surface than exercising the whole pipeline for every case. Avoid asserting on wall-clock timing to prove cancellation happened quickly — assert on the sequence of observed items and the final exception type instead.
What interviewers look for: A concrete, deterministic testing strategy — manual enumerator control and exception-type assertions — rather than a vague "write a unit test for it."
Q9 What performance pitfalls have you seen with async streams and ValueTask-returning APIs in hot paths?#
Short answer: The recurring pitfalls are forcing a ValueTask<T> into a reference-typed context and allocating anyway, treating ValueTask<T> as free instead of profiling it, stacking many small async-iterator layers so each adds its own state-machine overhead, and calling AsTask() repeatedly instead of once.
Calling a method that returns ValueTask<T> through an interface reference does not itself box the struct, but patterns that store a ValueTask<T> in a field, pass it as an object, or otherwise force it into a reference-typed context do allocate, which defeats the entire purpose of using it. The value stays allocation-free only if it is consumed immediately, in place, exactly once. Async-iterator methods compile to a state machine just like regular async methods, and every yield return is effectively a suspension point with its own bookkeeping; a stream built from several small transformations chained together — each wrapping one IAsyncEnumerable<T> around another — can end up with more overhead than a single hand-written loop, because each layer is its own state machine and its own enumerator. Repeatedly calling AsTask() on what should be a single logical ValueTask<T>, for example inside a retry loop, reallocates a Task<T> every time and usually signals the code should call AsTask() or Preserve() once and reuse the result. The only reliable way to know whether any of this matters is a profiler or a microbenchmark; assuming ValueTask<T> or async streams are faster without measuring is itself the most common mistake.
What interviewers look for: Nuanced understanding that ValueTask<T> does not avoid allocations in every usage pattern, and that layered async iterators carry real per-layer overhead.
Q10 When should an API return IAsyncEnumerable<T> instead of Task<List<T>>?#
Short answer: Return IAsyncEnumerable<T> when the consumer can start useful work before the full result set exists and memory matters, such as large or unbounded result sets; return Task<List<T>> when the caller needs the whole set anyway to do its job, such as sorting, counting, or building a lookup, where streaming buys nothing but added complexity.
Streaming gives a much better time-to-first-item and a flat memory profile independent of result size — an export process reading a very large result set should not buffer all of it in memory first if it does not have to. It also composes naturally with cancellation: a consumer that stops iterating early, because a client disconnected or only the first few items were needed, never pays the cost of producing the rest. Materializing is simpler to reason about, easier to retry as a whole, and unavoidable once the consumer's own logic genuinely needs random access, an upfront count, or the full set for correctness, such as deduplicating or sorting by a computed key. Forcing a streaming API onto that kind of consumer usually just means the consumer materializes it anyway, adding async-iterator overhead for no benefit. A useful interview heuristic: ask whether the first item changes what the caller does before the rest arrive — if yes, stream; if the caller's logic is identical regardless of arrival order, materializing is usually simpler and just as fast until result sets get genuinely large.
What interviewers look for: A design-level answer connecting the API shape to actual consumer behavior, not a blanket claim that streaming is always better.
Follow-up questions:
- How does this decision change for a public API consumed by clients you do not control?
- What does exposing
IAsyncEnumerable<T>do to your ability to retry a failed call safely?
Quick-Fire Round#
| Question | Answer |
|---|---|
Can you await a ValueTask<T> twice? | No, undefined behavior if it is backed by a pooled source. |
How do you make a ValueTask<T> safe to await repeatedly? | Call AsTask() or Preserve(). |
| Attribute that wires a token into an async-iterator parameter? | [EnumeratorCancellationAttribute]. |
Extension that passes a token into await foreach? | WithCancellation. |
Does ConfigureAwait(false) on a stream cover awaits inside the loop body? | No, apply it to each one. |
What backs a pooled, allocation-free ValueTask<T>? | IValueTaskSource<T>. |
| Does a plain async iterator buffer items ahead of the consumer? | No, it is pull-based with no buffer. |
| Recommended default return type for async methods? | Task/Task<T>, with ValueTask<T> as an optimization. |
How to Prepare#
- Implement a small type backed by
ManualResetValueTaskSourceCore<T>, even a toy one, to internalize whatIValueTaskSource<T>pooling actually does. - Write a cancellable async-iterator method with
[EnumeratorCancellation]and be ready to explain what breaks if you drop the attribute. - Practice justifying
IAsyncEnumerable<T>versus a materialized collection for a concrete scenario, not in the abstract. - Review Async/Await in C# and async/await internals so the state-machine cost of both
TaskandValueTaskis second nature. - Know the exact rules for consuming a
ValueTaskwell enough to spot a violation in a code snippet.