Timeouts
Give every outbound call a deadline it cannot exceed, with CancelAfter, WaitAsync or a linked token source.
Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken)A timeout is a deadline attached to work that might never finish. Anything that leaves your process can hang: a database with a locked row, a service that accepted your connection and went quiet, a DNS lookup into a black hole. Without a deadline, that one call holds a request, a connection and a thread for as long as the other side stays silent. Enough of them and the whole service stops answering.
The three ways to set one
- CancelAfter on a
CancellationTokenSourceturns a deadline into cancellation. The work stops only because it honours the token. - WaitAsync on a
Task, added in .NET 6, puts a deadline on the waiting.WaitAsync(TimeSpan)faults the returned task with aTimeoutException; the overload taking a token throwsOperationCanceledExceptioninstead. - CancellationTokenSource.CreateLinkedTokenSource builds a new source that fires when any of the tokens you pass it fires. Link the caller's token with your own
CancelAfterand you get one token meaning "caller gave up, or time ran out".
What trips people up
WaitAsync abandons the wait, not the work. The task carries on in the background, still holding its connection, and nobody is left to observe its result. Prefer a token the work actually honours, and keep WaitAsync for APIs that give you no token at all.
Example
// WaitAsync puts a deadline on the waiting without touching the work.
try
{
// A ten second job, given fifty milliseconds to finish.
await Task.Delay(TimeSpan.FromSeconds(10)).WaitAsync(TimeSpan.FromMilliseconds(50));
Console.WriteLine("slow job finished");
}
catch (TimeoutException)
{
Console.WriteLine("slow job timed out");
}
// The same shape over a job that fits inside its deadline.
await Task.Delay(TimeSpan.FromMilliseconds(20)).WaitAsync(TimeSpan.FromSeconds(5));
Console.WriteLine("quick job finished");
// A linked source fires when the caller's token fires or the deadline does.
using CancellationTokenSource callerCts = new CancellationTokenSource();
using CancellationTokenSource linked =
CancellationTokenSource.CreateLinkedTokenSource(callerCts.Token);
linked.CancelAfter(TimeSpan.FromMilliseconds(50));
try
{
await Task.Delay(TimeSpan.FromSeconds(10), linked.Token);
}
catch (OperationCanceledException)
{
// Comparing the two tells you which one fired, and so what to report.
Console.WriteLine($"caller cancelled: {callerCts.IsCancellationRequested}");
Console.WriteLine($"deadline fired: {linked.IsCancellationRequested}");
}
// Output:
// slow job timed out
// quick job finished
// caller cancelled: False
// deadline fired: TrueWhen to use it
- Keeping one slow dependency from exhausting your thread pool and taking the service down.
- Giving a search query two seconds before returning a partial answer.
- Reporting a timeout and a user cancellation as different outcomes to a retry loop.
- Tightening the 100-second HttpClient default to something your callers will actually wait for.
More examples
A method that imposes its own deadline
This is the pattern for every outbound call in a service. The method respects the caller's wishes and still refuses to wait forever, and the exception filter turns one exception type into the two distinct outcomes a caller cares about: "you cancelled" and "they were too slow". Without the filter, a caller cannot tell which happened, and a retry loop cannot decide whether retrying makes sense.
static class Prices
{
private static readonly HttpClient _client = new HttpClient();
public static async Task<string> GetAsync(string url, CancellationToken cancellationToken)
{
// Fires if the caller cancels, or after two seconds, whichever is first.
using CancellationTokenSource deadline =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(2));
try
{
return await _client.GetStringAsync(url, deadline.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The caller's token is still clear, so this was our own deadline.
// Report it as a timeout rather than as a cancellation.
throw new TimeoutException($"{url} did not answer within 2 seconds.");
}
}
}Per-call deadline over a shared HttpClient
Set Timeout once as a backstop for the whole client, then narrow it per call with a token when one endpoint deserves less patience than the rest. Timeout can only be assigned before the client's first request, so it belongs with the client's construction. The catch clause matters: people file bugs about timeouts not being caught because they wrote catch (TimeoutException) here.
// HttpClient.Timeout applies to every request made through this instance.
// Its default is 100 seconds, which is far too long for most services.
HttpClient client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(5);
// A tighter deadline for one particular call. Only the shorter of the two applies.
using CancellationTokenSource perCall = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
string body = await client.GetStringAsync("https://example.com/health", perCall.Token);
Console.WriteLine(body.Length);
}
catch (TaskCanceledException)
{
// HttpClient reports its own Timeout this way too, not as TimeoutException.
// TaskCanceledException derives from OperationCanceledException.
Console.WriteLine("health check gave up");
}Deadline over work that takes no token
This is the honest use of WaitAsync: a last resort over code you cannot change. Be clear about the cost. The abandoned task still holds whatever it held, and if it later fails, its exception has no observer. If the call allocates a connection or a lock, abandoning it repeatedly will exhaust that resource, and the fix is to add a token overload rather than to lower the timeout.
static class Legacy
{
// An older API: it returns a Task but offers no CancellationToken overload.
public static async Task<int> CountAsync()
{
await Task.Delay(TimeSpan.FromSeconds(30));
return 42;
}
}
// WaitAsync lets you stop waiting for it. The work itself carries on in the
// background, so only do this when abandoning it is genuinely harmless.
try
{
int count = await Legacy.CountAsync().WaitAsync(TimeSpan.FromMilliseconds(500));
Console.WriteLine(count);
}
catch (TimeoutException)
{
Console.WriteLine("count abandoned");
}
Discussion