| name | csharp-async-patterns |
| description | Guides expert-level C# async programming: async/await pitfalls, ValueTask vs Task decision tree, CancellationToken patterns, IAsyncEnumerable, and ConfigureAwait considerations.
Use when the user asks about C# async, await, ValueTask, Task, CancellationToken, IAsyncEnumerable, ConfigureAwait, async patterns.
Do NOT use when the user asks about C# modern idioms (use `csharp-modern-idioms`), C# performance (use `csharp-performance`), C# ASP.NET (use `csharp-aspnet-patterns`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"csharp backend optimization","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
C# Async Patterns
When to Use
Use this skill when the user asks about:
async/await usage, pitfalls, or anti-patterns in C# (deadlocks, .Result/.Wait() blocking, fire-and-forget)
Task<T> vs ValueTask<T> selection -- when to use each, allocation costs, misuse risks
CancellationToken propagation, linked token sources, timeout patterns, cooperative cancellation design
IAsyncEnumerable<T> and await foreach -- streaming data, backpressure, producer design
ConfigureAwait(false) -- when it matters, when it is irrelevant, and why library authors must use it
SynchronizationContext capture behavior and how it interacts with UI frameworks or ASP.NET Classic
TaskCompletionSource<T> for bridging callback-based or event-based APIs to async
- Thread pool starvation in heavily async systems, async-over-sync anti-pattern
async void contracts -- event handlers, fire-and-forget, exception surface
- Parallel async work patterns --
Task.WhenAll, Task.WhenAny, bounded parallelism with SemaphoreSlim
Do NOT use this skill when:
- The user asks about modern C# language idioms unrelated to async (records, pattern matching, file-scoped namespaces) -- use
csharp-modern-idioms
- The user asks about CPU-bound parallel processing with
Parallel.For, PLINQ, or System.Threading.Channels architecture -- use csharp-performance
- The user asks about ASP.NET Core middleware, Minimal API async endpoints, or Blazor async lifecycles in an architectural sense -- use
csharp-aspnet-patterns
- The user is asking a pure .NET threading question about
Monitor, Mutex, ReaderWriterLockSlim, or low-level Interlocked operations -- this is threading, not async
- The user is asking about Rx.NET (Reactive Extensions) observables and subscriptions -- that is a separate reactive programming paradigm
Process
Step 1 -- Classify the Async Problem
Identify which category the user's problem falls into before suggesting any code. Async problems in C# break into four families:
- I/O-bound async -- database queries, HTTP calls, file reads: use
async/await with Task<T>. The thread is returned to the pool during the wait.
- Streaming async data -- reading large result sets, event streams, paginated APIs: use
IAsyncEnumerable<T> and await foreach.
- Parallelism over async work -- fan-out HTTP calls, parallel database batches: use
Task.WhenAll with bounded concurrency via SemaphoreSlim rather than raw parallelism.
- Bridging legacy or callback APIs --
BeginXxx/EndXxx APM, event-based EAP, P/Invoke callbacks: use TaskCompletionSource<T>.
Ask these diagnostic questions:
- Is this library code or application code? Library code must use
ConfigureAwait(false) on every await.
- Does the operation complete synchronously in the common/hot path? If yes, consider
ValueTask<T>.
- Is cancellation required? Yes unless this is a truly fire-and-forget operation (rare).
- Does the caller need results? If no, the return type is
Task (not void), except for event handlers.
Step 2 -- Apply the Task<T> vs ValueTask<T> Decision Tree
This is the single most misunderstood decision in C# async. Use the following criteria:
Use Task<T> (the default) when:
- The operation almost always genuinely awaits (completes asynchronously most of the time)
- The result will be awaited multiple times (multiple
await on same task, .Result access after completion)
- The task will be stored in a field and awaited later
- You are writing a public API and cannot predict caller behavior
- The method has multiple
await points (the allocation cost is amortized over the work)
Use ValueTask<T> when ALL of these are true:
- The operation completes synchronously in the hot/common path (e.g., a cache hit before an async cache miss)
- The result is awaited exactly once and immediately
- You have profiling evidence that
Task<T> allocation is a measurable bottleneck (thousands of calls per second)
- The ValueTask is NOT stored, re-awaited, or converted to a Task without calling
.AsTask()
Hard rules about ValueTask<T> misuse:
- Never
await a ValueTask<T> more than once -- it is undefined behavior after the first await
- Never call
.Result on a ValueTask<T> that is not yet complete
- Never store
ValueTask<T> in a variable and use it after the producing method has been called again (especially from pooled IValueTaskSource<T>)
- The overhead reduction only matters above ~1M calls/second on hot paths -- measure before optimizing
Step 3 -- Design CancellationToken Propagation
CancellationToken is not optional in production code. Every method that performs I/O or long-running work must accept a CancellationToken. Apply these rules:
Parameter position: Always the last parameter with default default for public APIs:
public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken = default)
Propagation rules:
- Pass the token to EVERY awaited async call downstream. Missing even one link breaks the cancellation chain.
- Pass the token to
HttpClient.SendAsync, DbCommand.ExecuteReaderAsync, Stream.ReadAsync, Task.Delay, SemaphoreSlim.WaitAsync -- all accept it.
- If you use
Task.Run for CPU-bound work, pass the token to Task.Run AND check it inside the lambda.
Creating linked sources:
- Use
CancellationTokenSource.CreateLinkedTokenSource(externalToken, internalToken) when you need to impose a local timeout on top of an external cancellation:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(30));
await DoWorkAsync(cts.Token);
- Always
Dispose the CancellationTokenSource -- it holds unmanaged resources. Use using or await using.
Handling cancellation in loops:
while (!cancellationToken.IsCancellationRequested)
{
}
cancellationToken.ThrowIfCancellationRequested();
- Prefer
ThrowIfCancellationRequested() for methods that should surface OperationCanceledException; use IsCancellationRequested only when you need to do cleanup before returning.
Step 4 -- Apply ConfigureAwait Correctly
ConfigureAwait(false) tells the runtime not to capture the current SynchronizationContext or TaskScheduler when resuming after an await. This is one of the most commonly misapplied patterns.
When ConfigureAwait(false) IS required:
- In any library/NuGet package code -- you do not know what context the caller runs in, and failing to use it can deadlock in WinForms, WPF, or classic ASP.NET callers that block on
.Result or .Wait()
- In any code that will run in a UI application's library layer but does not need to return to the UI thread after the await
When ConfigureAwait(false) is NOT needed:
- In ASP.NET Core application code -- ASP.NET Core has no
SynchronizationContext, so ConfigureAwait(false) has no behavioral effect (though it is not harmful)
- In code that explicitly needs to resume on the UI thread (e.g., updating a WPF control after an awaited database call)
- In the outermost application entry points (top-level
await in Main)
The classic deadlock pattern to diagnose and explain:
public string GetData()
{
return GetDataAsync().Result;
}
private async Task<string> GetDataAsync()
{
await Task.Delay(100);
return "data";
}
private async Task<string> GetDataAsync()
{
await Task.Delay(100).ConfigureAwait(false);
return "data";
}
Step 5 -- Design IAsyncEnumerable Streaming
Use IAsyncEnumerable<T> when data is produced incrementally -- do not buffer everything into a List<T> before returning it.
When to use IAsyncEnumerable<T>:
- Database result sets with thousands of rows (use with EF Core's
ToAsyncEnumerable() or Dapper with QueryUnbufferedAsync)
- Paginated API responses where each page requires a separate HTTP call
- Real-time event streams, log tailing, SSE (Server-Sent Events) bridging
- Any producer where results become available before all work is complete
Implementation rules:
public async IAsyncEnumerable<Product> GetProductsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var batch in FetchBatchesAsync(cancellationToken))
{
foreach (var item in batch)
{
cancellationToken.ThrowIfCancellationRequested();
yield return item;
}
}
}
- Always use
[EnumeratorCancellation] attribute on the CancellationToken parameter of an async iterator -- this allows WithCancellation() calls from the consumer to propagate correctly
- The
[EnumeratorCancellation] attribute is on the parameter, not the method
- The compiler generates a state machine;
yield return inside async methods with IAsyncEnumerable<T> return type is valid C# 8+
Consumer pattern:
await foreach (var product in repo.GetProductsAsync()
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
await ProcessProductAsync(product, cancellationToken).ConfigureAwait(false);
}
Step 6 -- Handle Parallel Async with Bounded Concurrency
Task.WhenAll with unbounded parallelism is a common mistake. When fanning out 10,000 tasks simultaneously, you exhaust connection pools, hit rate limits, and cause cascading failures.
Safe fan-out pattern using SemaphoreSlim:
private static async Task<IEnumerable<Result>> ProcessInParallelAsync<T>(
IEnumerable<T> items,
Func<T, CancellationToken, Task<Result>> processor,
int maxConcurrency,
CancellationToken cancellationToken)
{
using var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = items.Select(async item =>
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await processor(item, cancellationToken).ConfigureAwait(false);
}
finally
{
semaphore.Release();
}
});
return await Task.WhenAll(tasks).ConfigureAwait(false);
}
Concurrency limits by resource type:
- HTTP calls to an external API: 4--16 concurrent (respect rate limits; default
HttpClient connection limit is 10 per host)
- SQL database queries: equal to connection pool size / 2 (default pool max is 100, so ~50 concurrent is safe)
- File I/O: 1--4 (disk seeks dominate above this)
- CPU-bound work launched via
Task.Run: Environment.ProcessorCount or Environment.ProcessorCount - 1
Task.WhenAny for timeouts and racing:
var workTask = DoLongWorkAsync(cancellationToken);
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
var completed = await Task.WhenAny(workTask, timeoutTask).ConfigureAwait(false);
if (completed == timeoutTask)
throw new TimeoutException("Operation exceeded 5 second limit.");
return await workTask.ConfigureAwait(false);
Step 7 -- Handle async void and Fire-and-Forget Safely
async void is a trap. Exceptions thrown inside async void methods crash the process (in most runtimes) because there is no Task for the caller to observe.
Legitimate uses of async void:
- Top-level event handlers in WinForms/WPF where the signature is mandated:
private async void Button_Click(object sender, EventArgs e)
{
try
{
await LoadDataAsync(_cts.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "Load failed");
ShowErrorDialog(ex.Message);
}
}
- Always wrap in try/catch -- the event handler is the last line of defense
Fire-and-forget with error handling (never use async void for this):
_ = DoBackgroundWorkAsync();
public static void FireAndForget(
this Task task,
ILogger logger,
CancellationToken cancellationToken = default)
{
task.ContinueWith(
t => logger.LogError(t.Exception, "Background task failed"),
cancellationToken,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.Default);
}
DoBackgroundWorkAsync(cancellationToken).FireAndForget(logger, cancellationToken);
Step 8 -- Diagnose Common Async Bugs
Guide the user to the root cause of async bugs using this checklist:
Deadlock diagnosis:
- Is
.Result, .Wait(), or .GetAwaiter().GetResult() called anywhere on the call stack? (blocking on async)
- Is the code running in a context with a single-threaded
SynchronizationContext (WinForms, WPF, classic ASP.NET)?
- Does the awaited method lack
ConfigureAwait(false)?
If all three are true: classic deadlock. Fix by adding ConfigureAwait(false) throughout the library chain, or by making the call stack fully async.
Thread pool starvation diagnosis:
- Are there many synchronous
.Wait() or .Result calls in hot paths?
- Is thread pool usage consistently near
Environment.ProcessorCount * 2 active threads?
- Are async operations queued but not starting for seconds?
Fix: make the entire call chain async. Never block thread pool threads.
Unobserved exception diagnosis:
- Is a
Task returned from an async method discarded with _ or no assignment?
- Is
async void used outside event handlers?
- Is
TaskScheduler.UnobservedTaskException firing in logs?
Fix: always await tasks or use the fire-and-forget wrapper above. Subscribe to TaskScheduler.UnobservedTaskException as a last-resort global handler.
Output Format
When answering a C# async question, structure your response as follows:
## Problem Classification
[I/O-bound async | Streaming | Parallel fan-out | Legacy bridge | Cancellation design]
Root cause identified: [specific issue, e.g., deadlock from missing ConfigureAwait, ValueTask misuse, unbounded parallelism]
## Recommendation
### Primary Pattern
[Task<T> / ValueTask<T> / IAsyncEnumerable<T> / TaskCompletionSource<T>]
Rationale: [1-2 sentences explaining why this fits]
### Implementation
\```csharp
// [Annotated, production-quality code example]
// Comments explain non-obvious choices
\```
### Decision Matrix (include when comparing two approaches)
| Criterion | Task<T> | ValueTask<T> | Recommendation |
|------------------------|--------------|---------------|-------------------------|
| Allocation cost | 1 heap alloc | 0 on sync path| ValueTask if sync common |
| Multi-await safe | Yes | No | Task if awaited >1 time |
| Public API surface | Preferred | Advanced use | Task for public APIs |
| Hot path call rate | Any | >1M/sec | ValueTask if measured |
### Pitfalls to Avoid
- [Specific pitfall 1 with code showing what NOT to do]
- [Specific pitfall 2]
### Testing Guidance
- [How to test this async pattern -- fake delays, CancellationTokenSource, etc.]
Rules
-
Never recommend .Result or .Wait() as a solution -- always guide the user to propagate async up the call stack. The only acceptable exception is in synchronous Main methods prior to C# 7.1, and even then, use .GetAwaiter().GetResult() which does not wrap exceptions in AggregateException.
-
Never return void from async methods except in event handlers -- async void silently swallows exceptions in most contexts. The return type must be Task (no result), Task<T> (with result), ValueTask, ValueTask<T>, or IAsyncEnumerable<T>.
-
Always add ConfigureAwait(false) in library code -- if the code being written is not a UI event handler or an ASP.NET Core controller action, every await must use .ConfigureAwait(false). Set up a Roslyn analyzer (Microsoft.VisualStudio.Threading.Analyzers or Roslynator) to enforce this automatically.
-
Never use ValueTask<T> in a public API without explicit documentation -- callers must know they cannot await it more than once, store it, or call .Result before completion. The misuse surface is too large for undocumented public APIs.
-
Always use [EnumeratorCancellation] on CancellationToken parameters in async iterators -- without it, calling .WithCancellation() on the returned IAsyncEnumerable<T> has no effect and the token is silently ignored.
-
Never create a SemaphoreSlim with an initial count of 0 as a concurrency limiter -- use new SemaphoreSlim(maxConcurrency, maxConcurrency) with the same value for both initial count and max count. The first argument is the initial permits available, not the maximum.
-
Always Dispose CancellationTokenSource instances -- CancellationTokenSource registers callbacks and holds a WaitHandle. Failing to dispose it leaks memory, especially in loops that create thousands of linked token sources.
Edge Cases
Legacy Codebase with Synchronous Interfaces
When a codebase has interfaces that return string or IEnumerable<T> and you cannot change the interface, do not add async to the implementing method and call .GetAwaiter().GetResult() inside it -- this recreates all the deadlock risk. Instead:
- Create a parallel
Async interface (IOrderRepositoryAsync) and implement it alongside the synchronous version
- Migrate callers to the async interface incrementally
- If you absolutely must call async code from a synchronous context, use a dedicated thread:
Task.Run(() => GetDataAsync(token)).GetAwaiter().GetResult() -- this prevents the deadlock because Task.Run runs on a thread pool thread without a SynchronizationContext. Still, document why this is unavoidable.
EF Core and IAsyncEnumerable
EF Core's ToAsyncEnumerable() returns IAsyncEnumerable<T>, but the DbContext must remain open for the duration of the enumeration. If the DbContext is scoped (common in ASP.NET Core), and you yield items out to a higher layer that outlives the scope, you will get a DbContext disposed exception mid-stream.
- Do not return raw
IAsyncEnumerable<T> from repositories that use scoped DbContexts across tier boundaries
- Either materialize the results within the repository scope, or ensure the consumer awaits the full enumeration within the same scope
- Use
await using var context = _factory.CreateDbContext() pattern with IDbContextFactory<T> when you need the DbContext lifetime tied to the streaming operation
HttpClient and CancellationToken
HttpClient.SendAsync with a CancellationToken will throw OperationCanceledException when the token is cancelled, but it also throws OperationCanceledException (not TaskCanceledException) when the HttpClient.Timeout is exceeded in older .NET versions. In .NET 5+, timeout throws TaskCanceledException with InnerException of TimeoutException. Always check the exception chain:
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException ex) when (ex.InnerException is TimeoutException)
{
throw new ServiceUnavailableException("Request timed out", ex);
}
async/await in Constructors
C# constructors cannot be async. Developers sometimes call .GetAwaiter().GetResult() in a constructor, which introduces blocking. The correct patterns are:
- Factory method pattern:
public static async Task<MyService> CreateAsync(...) that awaits initialization and returns the fully constructed instance
- Lazy initialization: Defer async initialization to the first use with a
Lazy<Task<T>> or a private _initialized flag and an EnsureInitializedAsync() method called at the start of each public method
IHostedService.StartAsync: In ASP.NET Core, async startup work belongs in StartAsync, not the constructor
ValueTask Pooling via IValueTaskSource
Advanced libraries (like System.IO.Pipelines and System.Net.Sockets) pool IValueTaskSource<T> instances to achieve zero-allocation async paths. If you implement IValueTaskSource<T>:
- The source can only be in one of three states: pending, succeeded, faulted -- never recycle it back to the pool before the consumer has completed its await
GetResult() must only be called once; calling it twice corrupts state
- Use
ManualResetValueTaskSourceCore<T> (in System.Threading.Tasks.Sources) as the backing implementation -- do not implement the interface manually from scratch
- This level of optimization is only appropriate for hot-path server code handling millions of connections. For normal business logic, use
Task<T>.
Async Streams with Exceptions Mid-Sequence
When an IAsyncEnumerable<T> producer throws mid-sequence, the consumer's await foreach will surface the exception at the next MoveNextAsync() call. The producer's finally and using blocks ARE executed -- the compiler-generated state machine ensures cleanup on DisposeAsync(). However:
- If the consumer disposes the enumerator early (breaks out of
await foreach), DisposeAsync() is called -- ensure the producer disposes its resources in finally blocks, not after the final yield return
- Never
yield return inside a try/catch block that catches Exception broadly -- it prevents the compiler from generating correct state machine transitions and produces a compiler error in C# prior to C# 8.0 iterator semantics updates
Thread Pool Starvation in Azure Functions / AWS Lambda
Serverless runtimes impose tight thread pool limits. A common failure mode: sync-over-async calls during cold starts stall the thread pool, causing subsequent requests to queue and timeout.
- In Azure Functions, enable
FUNCTIONS_WORKER_PROCESS_COUNT and ensure the host is fully async
- Never use
.Result or .Wait() in Azure Functions middleware or DI factory methods
- Monitor thread pool starvation with EventSource /
ThreadPool.GetAvailableThreads() -- if available worker threads drop below ProcessorCount * 2, starvation is occurring
Example
Input: "We have a C# service that calls a third-party payment API and we're seeing intermittent deadlocks and occasional lost exceptions. The service is a WPF desktop app with a synchronous call chain that eventually hits GetPaymentStatusAsync().Result. There are also background retry tasks being started but we never hear when they fail. How do we fix this?"
Output:
Problem Classification
I/O-bound async + deadlock from blocking + unobserved exceptions (fire-and-forget)
Root causes identified:
GetPaymentStatusAsync().Result on a WPF UI thread blocks the SynchronizationContext, which deadlocks when the async method tries to resume on the same context
- Background retry tasks are discarded (fire-and-forget without error observation), causing exceptions to be swallowed
Recommendation
Fix 1 -- Eliminate the Deadlock
The call chain must become fully async from the button click handler down to the HTTP call. There is no shortcut.
Before (deadlocks):
public void RefreshStatus()
{
var status = _paymentService.GetPaymentStatusAsync().Result;
StatusText = status.Description;
}
After (correct):
public ICommand RefreshCommand => new AsyncRelayCommand(RefreshStatusAsync);
private async Task RefreshStatusAsync(CancellationToken cancellationToken = default)
{
IsLoading = true;
try
{
var status = await _paymentService.GetPaymentStatusAsync(cancellationToken);
StatusText = status.Description;
}
catch (OperationCanceledException)
{
StatusText = "Cancelled.";
}
catch (PaymentApiException ex)
{
StatusText = $"Error: {ex.Message}";
_logger.LogError(ex, "Payment status refresh failed");
}
finally
{
IsLoading = false;
}
}
In the payment service (library code -- must use ConfigureAwait(false)):
public class PaymentService : IPaymentService
{
private readonly HttpClient _httpClient;
private readonly ILogger<PaymentService> _logger;
public PaymentService(HttpClient httpClient, ILogger<PaymentService> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<PaymentStatus> GetPaymentStatusAsync(
CancellationToken cancellationToken = default)
{
using var response = await _httpClient
.GetAsync("/api/payments/status", cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
return JsonSerializer.Deserialize<PaymentStatus>(json)
?? throw new PaymentApiException("Empty response from payment API");
}
}
Fix 2 -- Replace Fire-and-Forget with Observable Background Tasks
Before (exceptions silently lost):
_ = RetryPaymentAsync(paymentId);
After (exceptions are logged, task is tracked):
public static class TaskExtensions
{
public static void FireAndForget(
this Task task,
ILogger logger,
string operationName,
CancellationToken cancellationToken = default)
{
if (task == null) throw new ArgumentNullException(nameof(task));
task.ContinueWith(
completedTask =>
{
if (completedTask.IsFaulted)
{
logger.LogError(
completedTask.Exception?.GetBaseException(),
"Background operation '{OperationName}' failed",
operationName);
}
},
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.Default);
}
}
private CancellationTokenSource _backgroundCts = new CancellationTokenSource();
private void ScheduleRetry(Guid paymentId)
{
RetryPaymentAsync(paymentId, _backgroundCts.Token)
.FireAndForget(_logger, $"RetryPayment:{paymentId}", _backgroundCts.Token);
}
protected override void OnClosed(EventArgs e)
{
_backgroundCts.Cancel();
_backgroundCts.Dispose();
.OnClosed(e);
}
Fix 3 -- Add CancellationToken with Timeout to the Retry Logic
Retry with timeout and cancellation:
private async Task RetryPaymentAsync(
Guid paymentId,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
var delay = TimeSpan.FromSeconds(2);
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
attemptCts.CancelAfter(TimeSpan.FromSeconds(10));
try
{
await _paymentService
.SubmitPaymentRetryAsync(paymentId, attemptCts.Token)
.ConfigureAwait(false);
_logger.LogInformation("Payment {PaymentId} retry succeeded on attempt {Attempt}",
paymentId, attempt);
return;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Payment retry cancelled for {PaymentId}", paymentId);
throw;
}
catch (OperationCanceledException ex) when (attemptCts.IsCancellationRequested)
{
_logger.LogWarning("Payment retry attempt {Attempt} timed out for {PaymentId}",
attempt, paymentId);
}
catch (PaymentApiException ex) when (attempt < maxAttempts)
{
_logger.LogWarning(ex,
"Payment retry attempt {Attempt} failed for {PaymentId}, retrying in {Delay}",
attempt, paymentId, delay);
}
Task.Delay(delay, cancellationToken).ConfigureAwait();
delay *= ;
}
PaymentRetryExhaustedException(
);
}
Decision Matrix
| Concern | Old Approach | Fixed Approach | Why It Matters |
|---|
| UI thread blocking | .Result in ViewModel | async Task command pattern | Prevents SynchronizationContext deadlock |
| Library awaits | No ConfigureAwait | ConfigureAwait(false) on all awaits | Removes context capture; safe for any caller |
| Background exceptions | Discarded _ task | FireAndForget with ContinueWith | All failures logged; no silent data loss |
| Per-request timeout | None | Linked CTS with CancelAfter | Prevents retry storms hanging indefinitely |
| Cancellation at shutdown | None | CancellationTokenSource on window close | Clean shutdown without lingering background work |
Pitfalls Still to Avoid
var status = _paymentService.GetPaymentStatusAsync().GetAwaiter().GetResult();
public async ValueTask<PaymentStatus> GetPaymentStatusAsync(...)
var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
await DoWorkAsync(cts.Token);
Testing Guidance
[Fact]
public async Task GetPaymentStatusAsync_WhenCancelled_ThrowsOperationCancelledException()
{
var cts = new CancellationTokenSource();
var httpHandler = new Mock<HttpMessageHandler>();
httpHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Returns(async (HttpRequestMessage req, CancellationToken ct) =>
{
await Task.Delay(500, ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
var client = new HttpClient(httpHandler.Object) { BaseAddress = new Uri("https://api.example.com") };
var service = new PaymentService(client, NullLogger<PaymentService>.Instance);
cts.CancelAfter(50);
await Assert.ThrowsAsync<OperationCanceledException>(
() => service.GetPaymentStatusAsync(cts.Token));
}
[Fact]
public async Task RetryPaymentAsync_LogsError_WhenAllAttemptsExhausted()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var logger = new TestLogger<PaymentService>();
}