Updated on

An HttpClient waits 100 seconds for a response before it gives up. That is the default value of its Timeout property, and it applies to every request the instance sends. When it elapses, the call throws an OperationCanceledException instead of returning a failed response.

To download the source code for this article, you can visit our GitHub repository.

One hundred seconds is a long time to hold a request open, so most applications change it. We can do that once for a whole client, per request with a CancellationToken, or by handing the job to the standard resilience handler, which brings its own two timeouts.

Let’s start.

What Is the Default Timeout for HttpClient in C#?

HttpClient.Timeout defaults to 100 seconds, and Microsoft’s reference page for the property gives it in milliseconds too: “The default value is 100,000 milliseconds (100 seconds).”

The timer covers the whole call, connection included. It starts when we send the request and stops when the response has been read, so a slow server and a slow download both spend the same budget.

When it elapses, .NET 5 and later throw an OperationCanceledException that nests a TimeoutException. The exception object is a TaskCanceledException, which derives from OperationCanceledException, so a catch (TaskCanceledException) block catches it too.

The connection has a timer of its own. SocketsHttpHandler.ConnectTimeout, which caps opening the TCP connection and completing the TLS handshake, defaults to InfiniteTimeSpan. Unless we set it, an attempt to reach an unreachable host runs until the operating system gives up or the 100 seconds run out.

Timeout is also write-once in practice: setting it after the instance has sent a request throws InvalidOperationException.

The quotation comes from the HttpClient.Timeout reference page on Microsoft Learn, which documents the InvalidOperationException as well.

If you want to find out more about the HttpClient, check out our article HttpClient with ASP.NET Core Tutorial.

How Do We Set a Default (Global) Timeout for HttpClient?

We set a global timeout once, on the client, and every request that client sends inherits it.

With IHttpClientFactory the natural place is the AddHttpClient() registration, because the factory applies our configuration to each new client before it sends anything. Assigning Timeout later, on a client that has already sent a request, throws InvalidOperationException.

Because the value comes from configuration, a slow downstream service can get more time through a settings change, without a rebuild.

A named client is also the right place for BaseAddress. Every call in this article passes a relative path such as /api/delay-4-seconds, and a relative path only resolves if the client has a base address. Without one, GetAsync() throws InvalidOperationException for an invalid request URI.

To disable the timeout entirely, assign Timeout.InfiniteTimeSpan, and let a CancellationToken decide when to stop instead. That suits a long upload, but not a request a user is waiting on.

Let’s consider an example where we configure a global timeout in appsettings.json:

{
  "TestClient": {
    "TimeOutSeconds": 3
  }
}

Next, we apply this timeout to HttpClient:

builder.Services.AddHttpClient("TestClient", (sp, httpClient) =>
{
    var configuration = sp.GetRequiredService<IConfiguration>();
    var timeoutSeconds = configuration.GetValue<int>("TestClient:TimeOutSeconds");

    httpClient.BaseAddress = new Uri("http://localhost:5000");
    httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds);
});

We read the timeout setting from the configuration provided by the service provider. Every request from the client we resolve by the name TestClient now uses the same 3 second timeout. We cover registering named clients with IHttpClientFactory in a separate article.

Let’s look at a scenario where we send a request to an external endpoint: 

app.MapGet("/api/test-global-timeout", async (IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("TestClient");

    try
    {
        using var response = await httpClient.GetAsync("/api/delay-4-seconds");

        return Results.Ok();
    }
    catch (TaskCanceledException)
    {
        return Results.Text("TaskCanceledException: HttpClient global timeout passed");
    }
});

Here, we have an endpoint that responds in 4 seconds, one second more than our configured global timeout.

Consequently, the HttpClient throws TaskCanceledException and cancels the request, illustrating the importance of aligning timeouts with expected response times.

How Do We Set a per Request Timeout?

A per request timeout is a CancellationToken handed to the call. GetAsync(), PostAsync() and SendAsync() all take one, and the token cancels that single request without touching the client’s own Timeout.

Microsoft’s reference page for HttpClient.Timeout says how the two interact: “Note that only the shorter of the two timeouts will apply.” A three second client timeout and a ten second token end the call at three seconds.

Inside an ASP.NET Core endpoint we usually have a token already. Binding a CancellationToken parameter in a minimal API handler or a controller action gives us HttpContext.RequestAborted, which is cancelled when the caller who is waiting on us hangs up.

Passing it down means our outgoing call stops the moment the incoming request is abandoned, which frees a connection instead of waiting for a response nobody will read.

For a deadline of our own, we build a CancellationTokenSource with a timespan and pass its token instead.

Now, let’s create another endpoint to test the per-request timeout:

app.MapGet(
    "/api/test-per-request-timeout", 
    async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) =>
    {
        var httpClient = httpClientFactory.CreateClient("TestClient");

        try
        {
            using var response = await httpClient.GetAsync($"/api/delay-4-seconds", cancellationToken);

            return Results.Ok();
        }
        catch (TaskCanceledException)
        {
            return Results.Text("TaskCanceledException: User request cancelled");
        }
    }
);

In this case, we use the cancellationToken the minimal API binds to HttpContext.RequestAborted, which cancels the operation if the user aborts the request. Our article on binding a CancellationToken in a controller or minimal API handler covers that binding in detail.

We can use this method in conjunction with the global timeout, where the shorter duration between the two determines when the request is canceled. This strategy frees resources quickly and gives us detailed control over how long the server waits, request by request.

How Do We Combine a Request Timeout With the Caller’s Cancellation?

CancellationTokenSource.CreateLinkedTokenSource() builds one token out of several, and it is cancelled as soon as any of its sources is.

That lets a single call carry a deadline of its own while still respecting the caller’s. We create a source with the tighter timeout, link it with the request’s token, and pass the linked token to GetAsync().

Three timers are now running: the client’s Timeout, the caller’s token, and our two second source. Whichever fires first ends the request.

After the catch we can ask which one it was. endpointSpecificToken.IsCancellationRequested is true only if our own source fired, which is how the sample tells a deliberate short deadline apart from the global timeout.

Both sources need disposing, so both are declared with using. A linked source registers callbacks on the tokens it links, and those registrations live until it is disposed.

Let’s look at an example of how we can implement an additional combined timeout:

app.MapGet(
    "/api/test-combined-timeout", 
    async (IHttpClientFactory httpClientFactory, CancellationToken cancellationToken) =>
    {
        var httpClient = httpClientFactory.CreateClient("TestClient");

        using var endpointSpecificToken = new CancellationTokenSource(TimeSpan.FromSeconds(2));
        using var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken, 
            endpointSpecificToken.Token
        );

        try
        {
            using var response = await httpClient.GetAsync("/api/delay-4-seconds", tokenSource.Token);

            return Results.Ok();
        }
        catch (TaskCanceledException)
        {
            return endpointSpecificToken.IsCancellationRequested
                ? Results.Text("TaskCanceledException: Specific token canceled")
                : Results.Text("TaskCanceledException: HttpClient global timeout passed");
        }
    }
);

Here, we use a linked token source tokenSource combining the request’s cancellationToken with a new, stricter 2-second timeout endpointSpecificToken. The principle is the same as before: the shortest of the three cancellation sources wins, whether that is the global timeout, the user’s cancellation token, or the specific token.

How Do We Tell a Timeout From a Cancellation?

Every catch block above this one writes catch (TaskCanceledException) and then guesses why the request stopped, although the exception already carries the answer.

Microsoft’s SendAsync reference lists the difference by platform: “OperationCanceledException that nests a TimeoutException is thrown on .NET 5 and later versions”, while a token that we cancelled ourselves produces the same exception type without a nested TimeoutException.

So the inner exception is the tell, and an exception filter can read it in the catch clause itself:

catch (OperationCanceledException ex) when (ex.InnerException is TimeoutException)

That branch means the request ran out of time. A plain catch (OperationCanceledException) after it means the caller went away, and the two deserve different handling: one is worth retrying and logging as a fault, the other is worth abandoning quietly.

On .NET Framework the same timeout throws HttpRequestException instead, which is why older code checks the type rather than the inner exception.

The quoted line is from the Remarks section of the HttpClient.SendAsync reference page, which also lists what .NET Framework and .NET Core throw.

The four timers that can end one request sit inside each other, from the caller’s connection down to the TCP connect:

Four nested boxes showing RequestAborted around HttpClient.Timeout around the per-request CancellationToken around ConnectTimeout.

Handling Timeouts Gracefully

In the examples we’ve covered, the catch blocks managing TaskCanceledException stand out. Catching this exception during timeouts enables our application to respond effectively. We can either retry the operation or inform the user.

A common strategy among developers is to implement retry mechanisms using libraries like Polly, which provides a straightforward and efficient way to establish complex retry strategies and fallback methods. The HttpClient side of that is in our guide to retrying failed HttpClient requests.

How Do We Set Timeouts With the Standard Resilience Handler?

Since .NET 8, Microsoft has shipped a first-party resilience package for HttpClient. Microsoft.Extensions.Http.Resilience adds AddStandardResilienceHandler() to a named client and wraps every request in a rate limiter, a total timeout, retries, a circuit breaker and a per attempt timeout.

Two of those five are timeouts. The total request timeout covers one logical request including its retries and defaults to 30 seconds. The attempt timeout covers a single try and defaults to 10 seconds. Exceeding either throws Polly’s TimeoutRejectedException, which is not a TaskCanceledException, so the catch blocks earlier in this article will not catch it.

Adding the handler also sets the client’s own Timeout to InfiniteTimeSpan, so a value we configured in AddHttpClient() stops applying the moment the handler is attached.

The library does this on purpose, so that its own timeout strategies control the deadline, and with the defaults that means 30 seconds instead of our 100.

The sample registers a second named client, ResilientClient, with a two second attempt timeout and an eight second total timeout:

builder.Services.AddHttpClient("ResilientClient", httpClient =>
{
    httpClient.BaseAddress = new Uri("http://localhost:5000");
})
.AddStandardResilienceHandler(options =>
{
    options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
    options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
});

Calling the four second endpoint through this client fails with TimeoutRejectedException, and the client’s Timeout reads back as -00:00:00.0010000, which is Timeout.InfiniteTimeSpan.

The five strategies and their defaults come from Microsoft’s own table on Build resilient HTTP apps: total timeout 30 seconds, three retries with exponential backoff and jitter on a two second base delay, a circuit breaker at a 10% failure ratio over a 30 second sampling window, and a 10 second attempt timeout.

Here are all six timeouts from this article in one place, with their defaults:

TimeoutWhere it is setDefaultWhat it covers
HttpClient.TimeoutOn the client, or in AddHttpClient()100 secondsThe whole call, from send to the end of reading the response content
ConnectTimeout on SocketsHttpHandlerOn the primary handlerInfiniteEstablishing the TCP connection and the TLS handshake only
CancellationToken passed to GetAsync()At the call siteNoneWhatever the token's source decides. The shorter of it and Timeout wins
HttpContext.RequestAbortedGiven to us by ASP.NET CoreNoneUntil the client that called us disconnects
Total request timeout (resilience)The standard resilience handler's options30 secondsAll attempts of one logical request, retries and delays included
Attempt timeout (resilience)The standard resilience handler's options10 secondsOne attempt. Exceeding it throws TimeoutRejectedException and triggers a retry

Everything in this article times out calls we make. For calls made to us, ASP.NET Core has the request timeouts middleware.

Conclusion

If 100 seconds is too long to wait, we set a shorter Timeout once, in the AddHttpClient() registration, and pass a CancellationToken to any request that needs less, because only the shorter of the two applies. A timeout and a cancelled token both end in an OperationCanceledException, and only the timeout nests a TimeoutException inside it. The standard resilience handler sets the client’s Timeout to InfiniteTimeSpan and enforces its own 30 second total and 10 second attempt timeouts, which throw TimeoutRejectedException, an exception a catch (TaskCanceledException) block does not catch.

Tested with .NET 10.0.10, Microsoft.Extensions.Http.Resilience 10.10.0 and xunit 2.9.3.