Timeouts

Give every outbound call a deadline it cannot exceed, with CancelAfter, WaitAsync or a linked token source.

SyntaxTask 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 CancellationTokenSource turns 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 a TimeoutException; the overload taking a token throws OperationCanceledException instead.
  • 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 CancelAfter and 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

Example · csharp
// 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: True

When 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.

Example · csharp
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.

Example · csharp
// 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.

Example · csharp
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

  • Be the first to comment on this lesson.