CancelAfter, WaitAsync, HttpClient.Timeout, resilience handlers, ASP.NET Core request timeouts middleware, TimeProvider and layered timeout budgets.
A timeout should cancel the work, not just stop waiting. Use a CancellationTokenSource with CancelAfter (linked to the caller's token) and pass it down. Task.WaitAsync(timeout) only stops waiting. For HTTP, HttpClient.Timeout is a single total timeout (100 seconds by default) that throws TaskCanceledException with an inner TimeoutException; in production I prefer AddStandardResilienceHandler, which sets attempt and total timeouts plus retries. In ASP.NET Core 8+, the request timeouts middleware cancels RequestAborted after a configured time. Timeouts must be layered: each inner timeout shorter than the outer one.
"A timeout should cancel the work, not just stop waiting for it. So my default is a
CancellationTokenSourcewithCancelAfter, linked to the caller's token and passed all the way down.Task.WaitAsync(timeout)only stops waiting while the operation keeps running and holding resources; I use it only for APIs that can't be cancelled. For HTTP,HttpClient.Timeoutis a single total timeout per request, 100 seconds by default, which is far too long for most service-to-service calls. In production I useAddStandardResilienceHandler: per-attempt and total timeouts, bounded retries with backoff, and a circuit breaker, withHttpClient.Timeoutset so it doesn't fight the pipeline. In ASP.NET Core 8+, the request timeouts middleware cancelsRequestAbortedafter a configured time, so endpoints that honour their token stop too. The architectural part is layered budgets: every inner timeout must be shorter than its caller's, retries must fit inside a total timeout, and every dependency needs a degraded path for when its timeout fires. I test all of this withTimeProvider."
| Layer | Tool | Cancels the work? | Notes |
|---|---|---|---|
| Any async operation | Linked CTS + CancelAfter |
Yes | Translate own timeouts to TimeoutException |
| Non-cancellable operation | task.WaitAsync(timeout, ct) (.NET 6+) |
No | Work continues in the background |
HttpClient |
HttpClient.Timeout |
Yes | One total timeout; default 100 s |
| Resilient HTTP | Microsoft.Extensions.Http.Resilience (Polly v8) |
Yes | Attempt + total timeouts, retries, circuit breaker |
| ASP.NET Core endpoints | AddRequestTimeouts + UseRequestTimeouts + [RequestTimeout] / .WithRequestTimeout() (.NET 8+) |
Yes, via RequestAborted |
Code must honour the token |
| Kestrel | RequestHeadersTimeout, KeepAliveTimeout, min data rates |
Connection-level | Protects against slow clients |
| Database | EF Core / ADO.NET command timeout + token | Yes | Server-side cancellation |
| Source | Exception | Distinguish from caller cancellation |
|---|---|---|
HttpClient.Timeout |
TaskCanceledException with InnerException = TimeoutException (.NET 5+) |
Caller's token not cancelled |
| Resilience pipeline timeout | TimeoutRejectedException (Polly) |
Same check |
| Your linked CTS | OperationCanceledException |
!callerToken.IsCancellationRequested → rethrow as TimeoutException |
| Request timeouts middleware | OperationCanceledException from RequestAborted; response becomes 504 by default |
Configurable status/response |
| Hop | Timeout | Why |
|---|---|---|
| Client / API gateway | 30 s | Outermost |
| Service request policy | 20 s | Respond before the gateway gives up |
| Dependency total (incl. retries) | 6 s | Room for a fallback |
| Dependency attempt | 2 s | Fail fast, retry once or twice |
| Database command | 5 s | Bounded by the request budget |
Inner < outer at every level. Retries without a total timeout multiply latency during outages.
| Dependency | Degraded path |
|---|---|
| Fraud/risk check | Accept and queue for manual review |
| Recommendations | Return a cached or default list |
| Pricing | Serve last known price with a flag |
| Non-critical enrichment | Omit the field |
Our [checkout service] called a fraud API through an HttpClient with the default 100-second timeout, wrapped in a hand-written retry loop of [three] attempts. During a fraud-API slowdown, checkout p99 climbed to [several minutes] and we hit connection-pool exhaustion; traces showed each attempt waiting up to 100 s while the gateway had already returned 504 to customers after 30 s. I replaced the loop with AddStandardResilienceHandler: a [2 s] attempt timeout, a [6 s] total, two retries with jittered backoff, and a circuit breaker, with HttpClient.Timeout set to infinite so the pipeline owned timeouts. On timeout, checkout accepted the order and queued it for manual fraud review. In the next incident checkout stayed under [three seconds]; we documented per-dependency timeout budgets and added timeout-count dashboards.
Checks production resilience thinking.
Green flags
HttpClient.Timeout behaviour and resilience handlers.Red flags
WaitAsync vs real cancellation// Stops waiting after 1 s; SlowAsync keeps running, holding a connection/thread/locks
await SlowAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
// Cancels the work itself
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(1));
await SlowAsync(cts.Token);
The old Task.WhenAny(work, Task.Delay(timeout)) pattern has the same problem as WaitAsync, plus it leaks the delay timer if not cancelled.
| Setting | Covers | Default |
|---|---|---|
HttpClient.Timeout |
Whole SendAsync (and content buffering with ResponseContentRead) |
100 s |
SocketsHttpHandler.ConnectTimeout |
TCP/TLS connect | Infinite (bounded by HttpClient.Timeout) |
HttpCompletionOption.ResponseHeadersRead |
Timeout ends once headers arrive; body reads need your own token | — |
Resilience AttemptTimeout / TotalRequestTimeout |
Per attempt / across retries | Standard handler ships with defaults (tune them) |
With a resilience handler, set HttpClient.Timeout to Timeout.InfiniteTimeSpan or larger than the total pipeline timeout, otherwise the client's timeout can fire first with a less informative exception.
builder.Services.AddRequestTimeouts(o =>
{
o.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(10) };
o.AddPolicy("reports", TimeSpan.FromSeconds(60));
});
app.UseRequestTimeouts();
app.MapGet("/report", async (ReportService s, CancellationToken ct) => await s.BuildAsync(ct))
.WithRequestTimeout("reports");
HttpContext.RequestAborted; it doesn't abort threads.total 6 s
├─ attempt 1: 2 s timeout -> times out
├─ backoff 200 ms (+ jitter)
├─ attempt 2: 2 s timeout -> times out
├─ backoff 400 ms
└─ attempt 3: remaining ~1.4 s -> total timeout fires
Only retry idempotent operations (or use idempotency keys). Retrying a non-idempotent POST after a timeout can duplicate side effects because the first attempt may have succeeded.
TimeProviderpublic sealed class QuoteService(HttpClient http, TimeProvider time)
{
public async Task<Quote> GetAsync(CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(2)); // uses real time; for full control create the CTS with time
await Task.Delay(TimeSpan.FromMilliseconds(100), time, cts.Token);
return await http.GetFromJsonAsync<Quote>("quote", cts.Token) ?? throw new InvalidOperationException();
}
}
// Tests: inject FakeTimeProvider and call Advance(...) instead of waiting.
| Version | Change |
|---|---|
| .NET 5 | HttpClient timeout exceptions carry an inner TimeoutException |
| .NET 6 | Task.WaitAsync |
| .NET 8 | ASP.NET Core request timeouts middleware; Microsoft.Extensions.Http.Resilience; TimeProvider |
| .NET 9 / 10 | Same building blocks; resilience defaults may evolve: check docs for your version |
HttpClient with Timeout = 1s at a test endpoint that delays 5 s; print ex.InnerException?.GetType().WaitAsync(1s) vs a linked CTS: log when the server finishes the request in each case.AddStandardResilienceHandler with a 1 s attempt and 3 s total timeout, and log retries.FakeTimeProvider that asserts a TimeoutException without waiting.using Microsoft.Extensions.Http.Resilience;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<FraudClient>(c =>
{
c.BaseAddress = new Uri("https://fraud.internal/");
c.Timeout = Timeout.InfiniteTimeSpan; // let the resilience pipeline own timeouts
})
.AddStandardResilienceHandler(o =>
{
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
o.Retry.MaxRetryAttempts = 2;
});
builder.Services.AddRequestTimeouts(o =>
o.DefaultPolicy = new() { Timeout = TimeSpan.FromSeconds(10) });
var app = builder.Build();
app.UseRequestTimeouts();
app.MapPost("/checkout/{orderId:int}", async (int orderId, FraudClient fraud, CancellationToken ct) =>
{
try
{
bool ok = await fraud.CheckAsync(orderId, ct);
return ok ? Results.Ok() : Results.StatusCode(StatusCodes.Status402PaymentRequired);
}
catch (Exception ex) when (ex is TimeoutException or Polly.Timeout.TimeoutRejectedException && !ct.IsCancellationRequested)
{
return Results.Accepted(value: "Queued for manual fraud review"); // degrade, do not hang
}
});
app.Run();
public sealed class FraudClient(HttpClient http)
{
public async Task<bool> CheckAsync(int orderId, CancellationToken ct)
{
using var response = await http.GetAsync($"check/{orderId}", ct);
return response.IsSuccessStatusCode;
}
}
WaitAsync or WhenAny(work, Delay) as the timeout mechanismWhat happens: the caller stops waiting. Why it is wrong: the operation keeps running and holding resources. Do instead: pass a token with a timeout into the operation itself.
HttpClient.TimeoutWhat happens: service-to-service calls wait up to 100 s. Why it is wrong: callers give up long before; threads and connections stay busy. Do instead: per-dependency attempt and total timeouts.
What happens: three 10 s attempts plus backoff. Why it is wrong: 30+ s per call and multiplied load during outages. Do instead: total timeout shorter than your caller's budget.
What happens: the DB command timeout is 30 s inside a 10 s request budget. Why it is wrong: work continues after the caller has gone. Do instead: budget per hop, decreasing inward.
HttpClient.Timeout and resilience timeoutsWhat happens: client timeout 10 s, pipeline total 30 s.
Why it is wrong: the client timeout fires first; retries never happen as designed.
Do instead: let the pipeline own timeouts (InfiniteTimeSpan or larger client timeout).
What happens: OperationCanceledException from a timeout is logged as "client cancelled".
Why it is wrong: dependency slowness is hidden.
Do instead: check the caller's token and report timeouts distinctly (metrics, TimeoutException, 504).
What happens: a payment POST is retried after a timeout. Why it is wrong: the first attempt may have succeeded → double charge. Do instead: idempotency keys or no automatic retry.
What happens: middleware configured, but handlers don't pass CancellationToken down.
Why it is wrong: the timeout changes nothing about the work.
Do instead: accept and forward the token everywhere.
What happens: a timeout simply becomes a 500. Why it is wrong: one slow non-critical dependency takes down a critical flow. Do instead: degrade: cache, default, queue for later, partial response.
What happens: tests use Thread.Sleep and real seconds.
Why it is wrong: slow and flaky.
Do instead: TimeProvider + FakeTimeProvider.
Timeouts are an architecture decision, not a parameter. I define a budget per hop, make every timeout cancel real work, and give each dependency a degraded path for when the timeout fires.
| Principle | Practice |
|---|---|
| Cancel, do not just stop waiting | Tokens over WaitAsync |
| Inner shorter than outer | Documented timeout budget |
| Bound retries | Total timeout plus jittered backoff |
| Degrade gracefully | Fallback, queue for later, cached result |
| Observe | Metrics for timeouts per dependency |
CancellationTokenSource.CreateLinkedTokenSource(a, b, ...) creates a new source that is cancelled when any of the given tokens is cancelled, and can also be cancelled itself (for example with CancelAfter). It is how you combine "the caller cancelled" with "my own timeout" or "the host is shutting down". You must dispose the linked source, because it registers callbacks on the parent tokens; forgetting to do that on long-lived tokens leaks memory. To report the right error, check which source fired: if the caller's token is cancelled, rethrow cancellation; if only your timeout fired, throw a TimeoutException.
Cancellation in .NET is cooperative. A CancellationTokenSource owns the decision to cancel; it hands out CancellationTokens that code checks with IsCancellationRequested or ThrowIfCancellationRequested(), or passes to APIs that support it. When cancelled, the convention is to throw OperationCanceledException, which makes the task Canceled. Good APIs accept a CancellationToken as the last parameter (defaulting to default), pass it to every async call they make, and never swallow cancellation. In ASP.NET Core, a CancellationToken parameter is bound to HttpContext.RequestAborted, so client disconnects stop the work.
How to tell it (STAR)
When the [fraud API] slowed down, our checkout hung for minutes because each retry could wait 100 seconds. I replaced the retry loop with a resilience handler using short attempt and total timeouts, and added a manual-review fallback. In the next incident checkout stayed under [three seconds] and orders were reviewed later.
Last updated 31 Oct 2026.