Design the lifetime before adding await
An asynchronous operation needs a beginning, an owner, and an observable end. Imagine a screen loading a report. The person can leave the screen, request another report, or cancel the current load. The operation can succeed, fail, or acknowledge cancellation. Those outcomes should be part of the design before the implementation contains any await expressions. Otherwise, a successful demo can hide work that continues after its owner has disappeared.
Start with a small contract: the loading method returns Task<Report>, accepts a CancellationToken, and completes only when the work it owns is finished. The caller awaits it and decides what each outcome means for the interface. A console application can practice the same contract with a simulated delay rather than a real server. The examples here use standard .NET task APIs and self-contained console programs. A browser or mobile sandbox may support a narrower environment, so check its capabilities before assuming that threads, network access, or a particular external library are available.
Await pauses a flow, not necessarily a thread
Calling an async method starts executing its body immediately. It runs synchronously until it reaches an await whose operation has not completed, or until the method finishes. If the awaited operation is already complete, execution can continue without that suspension. This is why adding async does not automatically move expensive work away from the caller or guarantee that every invocation yields.
In the example, Begin is printed before the caller prints Caller continues, because the method reaches its delay while the initial call is still in progress. Later, awaiting the returned task observes completion. The exact scheduling delay is not the teaching point; the visible ownership of the task is. When reading real code, mark what happens before the first incomplete await. Validation, allocations, and synchronous computation in that region still happen as part of the call. If a user interface freezes before any asynchronous waiting begins, adding another await after the expensive work will not repair the cause. Locate the actual blocking or computational work first.
C# example
using System;
using System.Threading.Tasks;
var work = LoadAsync();
Console.WriteLine("Caller continues");
Console.WriteLine(await work);
static async Task<string> LoadAsync()
{
Console.WriteLine("Begin");
await Task.Delay(50);
return "Ready";
}References: Microsoft Learn: await operator
Separate waiting for I/O from computing on the CPU
Asynchronous I/O APIs are designed to represent waiting for operations such as reading a stream or receiving a response. CPU work is different: calculating a large result requires processor time somewhere. Wrapping synchronous work in an async method without an actual asynchronous boundary does not make that work nonblocking. For a short calculation, straightforward synchronous code is usually easier to understand.
On a desktop user interface, moving a substantial CPU calculation to an appropriate worker can be useful, subject to the framework's threading rules. In a server application, automatically wrapping every request's CPU work in Task.Run does not create more CPU capacity and can add scheduling overhead. In a browser environment, available threading capabilities depend on the runtime and configuration. State the environment when evaluating an approach. For the report example, keep a pure subtotal calculation synchronous and make loading its source asynchronous if the source API supports that. This separation makes performance questions precise: are you waiting for an external resource, spending CPU time, or blocking a thread on a synchronous API?
References: Microsoft Learn: Asynchronous programming scenarios
Return tasks so callers can observe completion
Task represents an operation with no returned value, while Task<T> represents one that eventually produces a value of type T. Both let callers await completion and observe failures. Async void has a special place in event-handler patterns, but it does not give an ordinary caller a task to await. Using it for a service method makes composition and error handling much harder.
Give each started task an owner. A button handler can await a task-returning method and translate its outcome into interface state. A background service can keep tasks in a supervised collection and await them during shutdown. Simply assigning a task to a discard does not establish supervision or a policy for exceptions. During review, search for calls to asynchronous methods whose returned tasks are ignored. Some may be intentional, but each needs an explicit lifecycle explanation. For learning, write a method that fails after a delay and call it both with and without awaiting it. The awaited version makes the failure arrive at a clear point in the caller's control flow.
References: Microsoft Learn: Async return types
Cancellation is a request that work must observe
A CancellationToken does not forcibly terminate arbitrary code. It communicates a cancellation request, and the operation cooperates by passing the token to supporting APIs or checking it at appropriate points. This allows the operation to preserve its invariants and release resources. Cancellation latency therefore depends on where the operation can notice the request and how much cleanup remains.
For a loop over expensive independent items, check before starting the next item and pass the token into each cancelable sub-operation. For a long calculation inside one item, additional checkpoints may be necessary. Choose checkpoints according to the desired responsiveness and the cost of checking, rather than mechanically checking every statement. Do not leave an object half-updated between two related assignments if other code can observe it. A useful design calculates a new value separately and publishes it at a defined boundary. When teaching cancellation, explain both the request and the acknowledgment: calling Cancel means the request was issued, while awaiting the operation tells the owner when that operation has actually finished reacting.
References: Microsoft Learn: Cancellation in managed threads
Let the caller own the cancellation source
A CancellationTokenSource controls the cancellation signal. Its token is the capability passed to work that should listen, while the source remains with the code allowed to request cancellation. This separation keeps a helper method from casually cancelling unrelated work owned by its caller. A fresh user operation normally receives a fresh source; a token whose cancellation has been requested is not reset into a new operation.
Dispose the source after the owned work has finished using its associated cancellation infrastructure. Disposing a source is cleanup, not a request to cancel the operation. In a screen controller, keep the source for the current load, cancel it when appropriate, await or supervise the load's end, and then release the source according to the controller's lifecycle. The small console example uses a pre-cancelled token to make the outcome deterministic. A real interface would issue the request in response to a button or navigation event. That difference changes the trigger but not the ownership relationship between the source, token, and task.
C# example
using System;
using System.Threading;
using System.Threading.Tasks;
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
try
{
await Task.Delay(1000, cancellation.Token);
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
Console.WriteLine("Cancelled by the caller.");
}References: Microsoft Learn: CancellationTokenSource
Propagate the token through every owned layer
A token accepted by the top method is useful only if the relevant work can observe it. Trace the report loader through its dependencies: fetch the source, parse records, enrich records, and calculate a summary. Pass the token to asynchronous APIs that accept it, and add suitable checkpoints to long synchronous loops. An unused token parameter can create the appearance of cancellation support without the behavior.
Do not invent token support by checking once before a long non-cancelable call and then claiming that the call can be interrupted. The check prevents starting when cancellation is already known; it cannot stop the call after it begins. Document that boundary when it matters. In a layered application, use consistent parameter naming and keep cancellation separate from ordinary input data. A cancellation request is about whether the caller still wants the operation to continue, whereas a report filter determines what the operation should calculate. Tests should verify propagation into a controllable fake dependency, because a top-level method can appear responsive in a quick demo while silently dropping the token at a deeper layer.
C# example
using System;
using System.Threading;
using System.Threading.Tasks;
using var cancellation = new CancellationTokenSource();
Console.WriteLine(await LoadTotalAsync(cancellation.Token));
static async Task<decimal> LoadTotalAsync(CancellationToken token)
{
var prices = await ReadPricesAsync(token);
var total = 0m;
foreach (var price in prices)
{
token.ThrowIfCancellationRequested();
total += price;
}
return total;
}
static async Task<decimal[]> ReadPricesAsync(CancellationToken token)
{
await Task.Delay(20, token);
return new[] { 12m, 8m, 25m };
}Classify cancellation according to its cause
OperationCanceledException is the normal exception shape used by many cancelable task APIs. The application should distinguish an expected cancellation request from an unrelated failure. A catch filter checking the caller's token source or token can help establish that the caller actually requested cancellation. Linked tokens and APIs with their own timeout behavior need a deliberate classification policy rather than one broad catch that labels every cancellation-shaped exception as a user action.
At an application boundary, expected user cancellation may simply restore an idle state without an alarming error banner. At a library boundary, propagating cancellation usually lets the owner make that decision. Avoid converting it into a successful empty report unless an empty report is explicitly the contract. Likewise, do not log every cancelled keystroke search as a service outage. Record enough context to distinguish cancellation, timeout, validation failure, and a fault in a dependency. These categories matter operationally: if timeouts are increasing, silently counting them as voluntary user cancellation can hide a performance problem that the team should investigate.
References: Microsoft Learn: Task cancellation
Combine a caller request with a time budget
A report load may have two valid reasons to stop: the caller no longer needs it, or the application has spent its allowed time. A linked CancellationTokenSource can combine those signals. Pass the linked token to the operation, then inspect the original causes when deciding how to report the outcome. Keep the caller's source separate so an internal timeout does not cancel unrelated work that shares the caller's broader lifetime.
The example uses a generous time budget and a short simulated operation, so it demonstrates the successful path. To explore cancellation, cancel the caller before the call. To explore the budget, make the operation's delay exceed the budget. Timing-based demonstrations are useful for intuition, but production tests should use controllable dependencies or a time abstraction rather than assuming an exact scheduler response. A time budget is also not a rollback guarantee: an external system may have accepted a write before cancellation was observed. For operations with side effects, define how the caller determines the final state after a timeout.
C# example
using System;
using System.Threading;
using System.Threading.Tasks;
using var caller = new CancellationTokenSource();
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
caller.Token, budget.Token);
try
{
await Task.Delay(20, linked.Token);
Console.WriteLine("Report loaded.");
}
catch (OperationCanceledException) when (caller.IsCancellationRequested)
{
Console.WriteLine("Caller cancelled.");
}
catch (OperationCanceledException) when (budget.IsCancellationRequested)
{
Console.WriteLine("Time budget expired.");
}References: Microsoft Learn: CreateLinkedTokenSource
Cancelling a wait does not cancel the underlying operation
Task.WaitAsync can bound how long a caller waits for an existing task, including by cancellation or timeout. That wrapper does not automatically make the underlying operation observe the same token. If the original operation was started without cancellation support, it may continue after the wrapper completes. The distinction is essential when the operation owns resources or can still produce an external effect.
For the report example, prefer passing the token into the loader itself. If you must stop waiting for non-cancelable work, keep an owner responsible for observing its eventual completion and any fault. Do not dispose resources that the still-running operation can access merely because the current caller has stopped awaiting it. A cache refresh may reasonably continue for other consumers, while a screen-specific operation may require a different design. Name that policy in the code. The question is not only whether the interface returned promptly; it is also what happens to the task, its resources, its exceptions, and its eventual result after the interface no longer wants to wait.
References: Microsoft Learn: Task.WaitAsync
Catch failures around the awaited operation
An exception raised during asynchronous work is represented by the faulted task and becomes visible when the caller awaits that task. The useful catch therefore surrounds the await, not merely the earlier statement that obtained the task. Keep that distinction visible when starting work early and awaiting it later. A task can have been running for some time before its owner observes its failure.
Catch where the application can decide what to do: retry under a defined policy, show a recoverable error, fall back to an acceptable result, or propagate the failure. Returning a default value after every exception can make a broken dependency look like valid data. If you add context and rethrow, preserve the original exception chain and stack information. Also distinguish synchronous argument validation in a task-returning API from failure during its operation; not every method returning Task is implemented with async, and some usage errors can be thrown during the call itself. A clear try region can include both invocation and await when the caller intends to handle either path.
C# example
using System;
using System.Threading.Tasks;
try
{
await ReadReportAsync();
}
catch (InvalidOperationException error)
{
Console.WriteLine($"Could not load report: {error.Message}");
}
static async Task ReadReportAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("The report is unavailable.");
}References: Microsoft Learn: Consuming the Task-based Asynchronous Pattern
Run independent operations together deliberately
Awaiting one operation before starting the next creates sequential work. When two operations are independent and their combined resource use is acceptable, start both and await their combined completion with Task.WhenAll. Independence is a requirement: if the second operation needs the first result or both mutate an unsafe shared object, running them concurrently changes the problem rather than simply making it faster.
WhenAll is a join over the supplied tasks; it does not automatically choose a safe concurrency limit or cancel siblings because one task fails. The owner still needs policies for failure, cancellation, and resource use. The result array for the generic overload corresponds to the input task order, which can differ from completion order. Use that contract when associating results with requests. In a learning program, simulate two independent loads and compare sequential and concurrent structure. Focus on which calls start before the first await rather than asserting exact elapsed milliseconds. Real timing varies, and a timing-only test can pass or fail for reasons unrelated to correct composition.
C# example
using System;
using System.Threading.Tasks;
var first = ReadAmountAsync(12m);
var second = ReadAmountAsync(25m);
var amounts = await Task.WhenAll(first, second);
Console.WriteLine(amounts[0] + amounts[1]);
static async Task<decimal> ReadAmountAsync(decimal amount)
{
await Task.Delay(20);
return amount;
}References: Microsoft Learn: Task.WhenAll
Observe the complete outcome of a group
A group of concurrent operations can contain more than one failure. WhenAll represents the group's completion, and its Exception property can contain the aggregated failures when it faults. Await normally propagates an individual exception, so catching that one exception does not by itself enumerate every failed child operation. If the application needs a complete diagnostic report, retain the combined task or the individual tasks and inspect their outcomes deliberately.
Choose a product policy before building that reporting code. An all-or-nothing report may fail if any source fails. A dashboard may display successful panels alongside clear failures. A batch importer may record a result for every item. Those are different contracts and should not be implemented by arbitrarily swallowing exceptions inside each child task. Also remember that a fault and cancellation can coexist among children; the group's state follows WhenAll's documented rules, while the application's message may need more detail. In code review, ask whether every started task is accounted for even on the first error path. Exiting early from the parent should not accidentally abandon siblings without supervision.
References: Microsoft Learn: Asynchronous programming with async and await
Bound concurrency to the resource you are protecting
Starting a task for every item in a huge collection can overwhelm a remote service, exhaust connections, or create excessive local memory pressure. Concurrency should be chosen according to the limiting resource and the application's requirements. A small SemaphoreSlim can limit how many operations enter a protected region, while other designs such as a bounded worker queue can avoid creating a task per item in the first place.
If using a semaphore, await acquisition with the operation's token and release only after acquisition succeeded. Place Release in a finally block around the work performed while holding the permit. Cancellation while waiting must not manufacture an extra permit. Keep the semaphore alive until all work using it is complete. For a teaching exercise, use a tiny fixed batch and log entry and exit to see the maximum active count. For production workloads, also define queue bounds and overload behavior; limiting active work while allowing an unbounded backlog can merely move the resource problem elsewhere. The appropriate limit is a measured operational choice, not a universal magic number.
References: Microsoft Learn: SemaphoreSlim.WaitAsync
Make cleanup part of every completion path
Asynchronous code still needs deterministic resource ownership. A try/finally block can restore state or release an acquired resource after success, failure, or cancellation. Using and await using provide structured disposal for appropriate resource types. Keep the lifetime long enough for the work that actually uses the resource, including work that continues after a wrapper stopped waiting.
The order matters during shutdown. Request cancellation, allow owned operations to reach a defined end, and then dispose resources whose use has ended. Disposing the transport first and hoping all operations disappear can produce confusing secondary failures. Cleanup itself may fail or require asynchronous work, so decide what the caller should observe if both the primary operation and cleanup encounter errors. For a small report loader, an explicit ownership diagram is often enough: the screen owns the load task, the load owns its reader, and the reader is released when the load finishes. This is easier to maintain than shared resources whose disposal can be triggered from several unrelated callbacks without a single responsible owner.
References: Microsoft Learn: using statement
Prevent stale results from overwriting a newer request
Cancellation and shared state are separate concerns. A person may type a new search while an earlier search is completing. The earlier operation can finish successfully before it observes cancellation, so cancellation alone does not prove that its result still belongs on screen. Associate each request with an identity or generation and check that it is still current before publishing its result.
Keep state transitions in the framework's required execution context. A background calculation should not update interface controls directly just because it eventually returns a value. Async does not make a List or a mutable model safe for concurrent writes either. Prefer isolated calculation followed by a controlled publication step. For the report example, calculate into a new immutable result, then publish it only if its request identifier matches the current request. Tests can complete two fake operations in the reverse order they were started and verify that the newer result remains visible. This reproduces a real race without relying on network delays or hoping that a particular machine happens to schedule tasks in the desired order.
Retry only when the operation semantics allow it
An asynchronous failure is not automatically transient, and cancellation is not a request to retry. Retrying a malformed input wastes time; retrying a non-idempotent write can duplicate an external effect if the first attempt actually succeeded before the response was lost. The retry policy needs to know the operation's semantics and the dependency's documented behavior.
When retry is appropriate, bound attempts and total time, use a delay strategy suitable for the dependency, and pass the caller's token through both the attempt and the delay. Stop promptly when the caller cancels. Keep the original operation identity when the external system supports idempotency, and distinguish a confirmed failure from an unknown outcome that needs reconciliation. For the report loader, retrying a read may be acceptable under a stated policy, while submitting an invoice needs stronger guarantees. Make the eventual error explain whether the work was never accepted, definitely failed, or may have completed remotely. Those distinctions are more useful to the user than a generic message that encourages them to press the button again.
Test the state machine with controllable events
Reliable asynchronous tests control the events that matter: an operation starts, a dependency completes, a token is cancelled, and a result is published. Avoid using long sleeps to hope that another task has reached the right point. A controllable fake dependency or TaskCompletionSource can let the test release work precisely. Use RunContinuationsAsynchronously when creating such a completion source if the test design should avoid running arbitrary continuations inline on the thread that signals it.
Cover successful completion, a deliberate failure, cancellation before work starts, cancellation during a wait, and overlapping requests completing out of order. Verify that every owned task reaches an observed end and resources are released once. If the code has a timeout, use a time abstraction where available instead of making the suite wait through real production durations. The goal is to prove the lifecycle contract, not the operating system's scheduler timing. Once that contract is clear, async code becomes ordinary program design with additional completion paths: each path still needs a correct result, a responsible owner, and a clear explanation for the caller.
References: Microsoft Learn: TaskCreationOptions
Sources & further reading
Technical references checked against primary documentation. Browser playground examples target the SharpRune .NET 9 profile; production server guidance describes a separate hosting environment.
- Microsoft Learn: Consuming the Task-based Asynchronous Pattern
- Microsoft Learn: Asynchronous programming scenarios
- Microsoft Learn: Async return types
- Microsoft Learn: Cancellation in managed threads
- Microsoft Learn: CancellationTokenSource
- Microsoft Learn: Task cancellation
- Microsoft Learn: CreateLinkedTokenSource
- Microsoft Learn: Task.WaitAsync
- Microsoft Learn: Task.WhenAll
- Microsoft Learn: SemaphoreSlim.WaitAsync
- Microsoft Learn: using statement
- Microsoft Learn: await operator
- Microsoft Learn: Asynchronous programming with async and await
- Microsoft Learn: TaskCreationOptions
