📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you handle timeouts correctly in async .NET code, from HttpClient to ASP.NET Core requests?

CancelAfter, WaitAsync, HttpClient.Timeout, resilience handlers, ASP.NET Core request timeouts middleware, TimeProvider and layered timeout budgets.

Senior RealWorldScenario Advanced cancellation httpclient resilience timeouts

🎯 Key points

  • A timeout should cancel the work: linked CTS with CancelAfter
  • WaitAsync only stops waiting; the work continues
  • HttpClient.Timeout is one total timeout, default 100 seconds
  • Use resilience handlers for attempt and total timeouts with bounded retries
  • ASP.NET Core 8+ request timeouts middleware cancels RequestAborted
  • Inner timeouts must be shorter than outer timeouts
  • Use TimeProvider to test timeouts and provide fallbacks

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"A timeout should cancel the work, not just stop waiting for it. So my default is a CancellationTokenSource with CancelAfter, 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.Timeout is a single total timeout per request, 100 seconds by default, which is far too long for most service-to-service calls. In production I use AddStandardResilienceHandler: per-attempt and total timeouts, bounded retries with backoff, and a circuit breaker, with HttpClient.Timeout set so it doesn't fight the pipeline. In ASP.NET Core 8+, the request timeouts middleware cancels RequestAborted after 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 with TimeProvider."


1. Tools by layer

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

2. Recognising timeouts

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

3. Layered timeout budget (example)

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.

4. When the timeout fires: degrade

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

Production example

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.

🔍 What the interviewer is checking

Checks production resilience thinking.

Green flags

  • Distinguishes cancelling work from stopping waiting.
  • Knows HttpClient.Timeout behaviour and resilience handlers.
  • Mentions ASP.NET Core request timeouts.
  • Designs layered budgets and fallbacks.

Red flags

  • Uses default timeouts everywhere.
  • Retries without limits.

📚 Learn in depth

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

2. HttpClient timeouts in detail

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.

3. ASP.NET Core request timeouts

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");
  • The middleware cancels HttpContext.RequestAborted; it doesn't abort threads.
  • It's not applied when a debugger is attached (so breakpoints don't trip timeouts).
  • If the endpoint ignores its token, the timeout does nothing useful.

4. Retries and timeouts together

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.

5. Testing with TimeProvider

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

6. Version notes

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

7. Try it yourself

  1. Point an HttpClient with Timeout = 1s at a test endpoint that delays 5 s; print ex.InnerException?.GetType().
  2. Compare WaitAsync(1s) vs a linked CTS: log when the server finishes the request in each case.
  3. Add AddStandardResilienceHandler with a 1 s attempt and 3 s total timeout, and log retries.
  4. Add request timeouts middleware with 2 s and an endpoint that ignores its token; observe the work still running.
  5. Make the endpoint honour the token and observe immediate cancellation.
  6. Write a test with FakeTimeProvider that asserts a TimeoutException without waiting.

💻 Code

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;
    }
}

⚠ Common mistakes

1. WaitAsync or WhenAny(work, Delay) as the timeout mechanism

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

2. Relying on the default 100-second HttpClient.Timeout

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

3. Retries without a total timeout

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.

4. Inner timeouts longer than outer ones

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.

5. Conflicting HttpClient.Timeout and resilience timeouts

What 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).

6. Treating timeouts as client cancellations

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

7. Retrying non-idempotent operations after timeouts

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.

8. Request timeouts middleware with endpoints that ignore tokens

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.

9. No fallback

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.

10. Testing timeouts with real delays

What happens: tests use Thread.Sleep and real seconds. Why it is wrong: slow and flaky. Do instead: TimeProvider + FakeTimeProvider.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate combines request and timeout tokens Why: Checks linked token disposal
    How to answer

    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.

  2. When: Candidate mentions RequestAborted Why: Checks cancellation API design
    How to answer

    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.

🛠 Real-world problems: how you handle them

Checkout hanging behind a slow fraud API
Situation
A [checkout service] called a fraud API with default `HttpClient` settings and a custom retry loop of [three] attempts.
Signals
During a fraud API slowdown, checkout p99 rose to [several minutes]; connection pool exhaustion errors appeared.
Investigation
Traces showed each attempt waiting up to 100 seconds; the gateway had already returned 504 to customers after 30 seconds.
Resolution
Replaced the custom loop with `AddStandardResilienceHandler` (2 s attempt, 6 s total) and a fallback that queued orders for manual review.
Prevention
Documented timeout budgets per dependency and added dashboards for timeout counts.

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.

↩ Usually asked after

More in Async Advanced

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me