ASP.NET Core / Intermediate: Data, Identity, and Asynchronous Work / Lesson 12
Async Requests, Cancellation, and Background Work
Keep every asynchronous operation observed and bounded across request cancellation, shared dependencies, and background scopes.
On this page
Lesson goals
- Propagate RequestAborted to relevant operations.
- Bound active and queued work.
- Separate request lifetime from owned background processing.
Async waiting does not make dependencies safe or unlimited
Await composes completion without blocking a thread during supported asynchronous waiting. It does not automatically parallelize a method or make a scoped dependency thread-safe. Synchronous .Result/Wait on request work can block threads and contribute to starvation or context deadlock. Prefer an await-based path and measure actual blocking before tuning thread settings.
HttpContext belongs to the request. Do not retain it in singleton/background work or use it concurrently as an arbitrary shared container. Copy only the safe data needed by a separately owned operation, with an explicit authorization and lifetime policy. A completed response does not guarantee every launched task finished.
Request cancellation is cooperative
The first complete program assigns a synthetic RequestAborted token, cancels it, and awaits a token-aware delay. It verifies cancellation composition without a client disconnect or network. A real operation must forward or observe the token at each relevant blocking stage. Checking once at entry does not interrupt a later tokenless call.
Cancellation requests do not join tasks or roll back committed effects. Await owned work before disposing streams, contexts, or scopes. Distinguish cancellation associated with the intended token from unrelated failures, and decide how the endpoint reports a request whose client may already be gone.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using var source = new CancellationTokenSource();
var context = new DefaultHttpContext { RequestAborted = source.Token };
source.Cancel();
try { await Task.Delay(1000, context.RequestAborted); }
catch (OperationCanceledException exception) when (exception.CancellationToken == source.Token)
{ Console.WriteLine("request work cancelled and observed"); }
request work cancelled and observed
The assigned token cancels the awaited delay; no request or client connection is opened.
Limit concurrency and still observe all started work
The second program uses a semaphore around four synthetic operations and awaits all tasks. It verifies result correctness and that the observed active count stays within two. The values and limit are teaching inputs, not a universal request limit. A semaphore bounds active work but an unbounded set of waiting tasks can still retain memory.
Define admission, queue capacity, timeout, cancellation, and rejection policy together. Release a semaphore only after successful acquisition, and keep it alive until all users finish. Avoid fire-and-forget work that has no failure observer. Retries need bounded budgets and idempotency rather than more hidden concurrency.
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using var gate = new SemaphoreSlim(2);
object sync = new object();
int active = 0, peak = 0;
Task<int>[] work = Enumerable.Range(1, 4).Select(ProcessAsync).ToArray();
int[] results = await Task.WhenAll(work);
Console.WriteLine($"sum={results.Sum()} withinLimit={peak <= 2}");
async Task<int> ProcessAsync(int value)
{
await gate.WaitAsync();
try
{
lock (sync) { active++; peak = Math.Max(peak, active); }
await Task.Yield();
return value * value;
}
finally
{
lock (sync) active--;
gate.Release();
}
}
sum=30 withinLimit=True
The squares sum to 30 and the measured local active count does not exceed the two permits.
A hosted worker needs its own scope and shutdown contract
BackgroundService and hosted services provide lifecycle integration, not durable delivery by themselves. A worker should use bounded units, create appropriate scopes, observe exceptions, pass shutdown cancellation, and finish or stop under policy. Enqueuing into process memory can lose work on restart; a required durable job needs an appropriate persistence/queue design.
Do not inject request-scoped DbContext or capture HttpContext into a singleton worker. Resolve scoped dependencies inside a valid owned scope and await users before disposing it. Define retries, poison work, ownership transfer, and startup/shutdown readiness. A background task is not completed merely because the request that started it returned 202.
Timeout and cancellation are not atomic-effect protocols
A request can time out after a database commit or external effect. The caller may retry without knowing whether the first attempt succeeded. Define operation identity, idempotency/reconciliation, and the point after which cancellation cannot honestly imply nothing happened. Request-timeout middleware has versioned setup/behavior; a CancellationToken field alone does not enforce a deadline.
Test cancellation before admission, while waiting, during work, and after a commit boundary with synthetic dependencies. Require completion and cleanup evidence, not just a returned cancellation exception. The examples start only bounded local tasks and no configured hosted service, database, HTTP listener, or external call.
Common errors and fixes
A request launches a task and disposes its scope immediately.
- Why it happens
- Task start was confused with completion.
- How to fix it
- Await work within ownership or transfer to a separately owned lifecycle.
- Prevent it next time
- Test after-suspension dependency access.
A semaphore is released after cancelled acquisition.
- Why it happens
- Cleanup assumes a permit was acquired.
- How to fix it
- Enter the release-owning try/finally only after successful WaitAsync.
- Prevent it next time
- Test already-cancelled admission.
A 202 response is treated as durable completed work.
- Why it happens
- Acceptance, persistence, and business completion were conflated.
- How to fix it
- Define queue durability and status/failure contract.
- Prevent it next time
- Test restart and replay requirements in the intended system.
Check your understanding
1. A handler creates ten thousand tasks waiting on a semaphore of four. Is the whole operation bounded?
Show worked solution
Only active work is bounded by the semaphore. The queued task set can still hold inputs, closures, and memory without a useful admission policy. Use bounded producer/consumer admission or a reviewed worker model, propagate cancellation, and observe every started task. Choose limits from workload requirements rather than treating four as universally safe.
2. Why should a required background operation not retain HttpContext after returning?
Show worked solution
The context belongs to the completed request and may contain scoped state, mutable features, or sensitive identity/input data whose lifetime has ended. Copy only the necessary safe data into an explicit durable/bounded job contract, establish authorization at the correct boundary, and give the background owner scopes, cancellation, and failure handling. Request lifetime is not a job lifetime.
Key takeaways
- Await every owned task and keep borrowed resources alive until completion.
- Cancellation is cooperative, and concurrency limits do not automatically bound queues.
- Background durability and idempotency are application contracts, not async syntax.