Reliable asynchronous code answers three separate questions:
- Can the caller ask this operation to stop? Use cooperative cancellation.
- How long may the caller wait? Apply a timeout at the boundary.
- How much work may run at once? Bound concurrency to protect dependencies.
Treating these as one concern is a common interview mistake. A timeout can stop waiting without stopping the underlying operation; a cancellation token is only a request and must be observed; and Task.WhenAll does not impose a concurrency limit.
A production-oriented pattern
public sealed class ProfileAggregator(IProfileClient client)
{
public async Task<IReadOnlyList<Profile>> LoadAsync(
IReadOnlyCollection<Guid> userIds,
CancellationToken requestAborted)
{
using var operationCts =
CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
// The dependency work should observe the same budget.
operationCts.CancelAfter(TimeSpan.FromSeconds(3));
using var gate = new SemaphoreSlim(initialCount: 4);
var tasks = userIds.Select(async userId =>
{
await gate.WaitAsync(operationCts.Token);
try
{
return await client.GetAsync(userId, operationCts.Token);
}
finally
{
gate.Release();
}
});
// WaitAsync bounds the caller's wait. The linked token also asks
// dependency calls to stop, so timed-out work is not simply abandoned.
return await Task.WhenAll(tasks)
.WaitAsync(TimeSpan.FromSeconds(4), requestAborted);
}
}
Why the pieces matter:
CreateLinkedTokenSourcecombines the HTTP-request cancellation signal with an operation-level budget.CancelAfterasks participating dependency calls to stop after three seconds.SemaphoreSlimallows at most four dependency calls to be in flight.finallyguarantees that a permit is returned after success, failure, or cancellation.WaitAsyncprotects the caller from waiting forever. It does not, by itself, guarantee that the original task stops.
Cancellation is cooperative
Passing a token is not enough. Each cancellable operation must observe it by passing it onward, awaiting a token-aware API, or calling ThrowIfCancellationRequested in CPU-bound loops.
When cancellation is expected, preserve OperationCanceledException; do not convert it into a generic 500 error. At an HTTP boundary, distinguish client disconnects from your own service timeout according to the application's response policy and telemetry conventions.
A good library API accepts a CancellationToken as its last parameter and normally defaults it only when cancellation is genuinely optional.
Timeout is a waiting policy
Task.WaitAsync(TimeSpan) creates an asynchronous wait that can end with TimeoutException. The original task may still be running. If continuing work would waste a database connection or remote-call quota, combine the wait with a cancellation mechanism that the underlying operation observes.
Avoid .Result, .Wait(), and blocking sleeps in asynchronous request paths. They occupy threads while waiting and can reduce throughput or deadlock in environments with a synchronization context.
Bound concurrency deliberately
Starting one task per item can overload a downstream API even when every task is properly awaited. Choose a limit from measured dependency capacity, latency, and quota—not from the input size.
Two useful approaches are:
SemaphoreSlimwhen each item needs custom setup, result collection, or error handling.Parallel.ForEachAsyncwhen the operation naturally fits a loop and aParallelOptionspolicy.
Do not use unbounded Task.WhenAll over attacker-controlled or very large input collections.
Failure and observability checklist
In production, record enough context to answer:
- Was the caller's token canceled?
- Did the operation budget expire?
- How many items were queued and in flight?
- Which dependency failed, and was the failure transient?
- Did cleanup run and permits return?
- Was partial success allowed, or is the result all-or-nothing?
If partial results are acceptable, catch failures per item and return an explicit result type. If all results are required, let Task.WhenAll fail and translate the error once at the boundary. Never silently drop exceptions from fire-and-forget tasks.
Interview prompts to practice
- Why does timing out
WaitAsyncnot necessarily stop the original task? - Where should an ASP.NET Core request token be propagated?
- What bug appears when
SemaphoreSlim.Release()is not infinally? - How would you choose and tune a concurrency limit?
- How would you test cancellation deterministically without slow wall-clock sleeps?
A strong answer explains the ownership of each policy: callers own their patience, operations cooperate with cancellation, and the service owns protection of its dependencies.