Distributed Systems

When a Timeout Is Not Really a Timeout Problem

The quickest response to a timeout error is often to increase the timeout.

Sometimes that is correct.

It is also one of the easiest ways to hide the actual problem for a little longer.

I have learned to treat a timeout as a boundary being crossed, not as a root cause. The interesting question is what consumed the time.

Consider a fictional .NET API that normally responds quickly. During periods of heavier traffic, a small percentage of requests start failing after five seconds.

The first dashboard view seems to suggest an obvious fix:

TEXT
Normal request latency:   < 500 ms
Failed requests:          ~ 5 s
Configured timeout:         5 s

Five seconds appears everywhere. Increasing it to ten seconds feels reasonable.

I would resist that change until I knew what happened inside those five seconds.

The first false lead: the dependency must be slow

The failing endpoint calls another HTTP service, so the dependency is the first suspect.

Tracing changes the picture.

TEXT
request
|
+-- application work ............. 4.6 s
|
+-- downstream HTTP .............. 0.3 s
|
+-- response ..................... timeout

The dependency is not consuming most of the budget.

That does not prove it is healthy in every request, but it is enough to stop treating the downstream call as the leading explanation.

This is why a timeout log by itself is weak evidence. It records where the operation finally gave up, not necessarily where the time was spent.

The second false lead: CPU looks fine

CPU is below its alert threshold, so the service does not look saturated.

But CPU is only one resource.

A server can spend most of a request waiting rather than executing. Connection pools, thread-pool starvation, database pools, locks, queues and external calls can all create latency while CPU remains surprisingly ordinary.

For an ASP.NET Core service, I would correlate the timeout window with signals such as:

TEXT
request rate
p50 / p95 / p99 latency
active requests
queueing
thread-pool activity
outbound connection usage
dependency duration
database pool usage
GC pauses
CPU and memory

I am not looking for the dashboard with the most charts. I am looking for the signal whose shape changes before the failures begin.

Follow the waiting

Suppose traces show requests spending several seconds before an outbound operation can make useful progress.

At that point, increasing the HTTP timeout changes the symptom but not the contention.

A simplified pattern might be:

TEXT
traffic rises
    |
    v
more concurrent work
    |
    v
a constrained resource saturates
    |
    v
requests wait
    |
    v
end-to-end latency rises
    |
    v
timeout expires

The timeout is doing its job. It is preventing requests from waiting forever.

Removing that protection can make the system less stable. More requests remain alive, consume resources and increase the amount of concurrent work competing for the same constrained resource.

A timeout change can therefore turn a latency problem into a larger saturation problem.

Cancellation is part of the timeout design

There is another failure mode I specifically look for in .NET code: a timeout occurs at one layer, but the work underneath continues.

Consider this boundary:

C#
public async Task<Order> GetOrderAsync(
    string id,
    CancellationToken cancellationToken)
{
    return await _repository.LoadAsync(id, CancellationToken.None);
}

The caller may give up after five seconds, but the repository operation has been disconnected from that cancellation.

Under load, abandoned work can continue consuming a scarce resource even though nobody is waiting for its result.

The fix is not complicated:

C#
public async Task<Order> GetOrderAsync(
    string id,
    CancellationToken cancellationToken)
{
    return await _repository.LoadAsync(id, cancellationToken);
}

But I would still verify the entire path. Forwarding a token through one method does not guarantee that every dependency honors it correctly.

This is one reason I treat timeout configuration and cancellation propagation as related concerns rather than separate coding details.

Timeout budgets should describe intent

A useful timeout answers a question:

How long is this operation still valuable to its caller?

That is different from asking how long the operation might take in the worst case.

If an upstream request has an eight-second budget, allowing an internal dependency to independently wait ten seconds makes little sense.

I prefer thinking in terms of an end-to-end budget:

TEXT
caller budget
    |
    +-- application work
    +-- dependency A
    +-- dependency B
    +-- serialization / response
    +-- small safety margin

This does not mean dividing the number mechanically between dependencies. It means acknowledging that nested operations share a finite amount of useful time.

Retries consume the same budget too.

Three attempts with a five-second timeout are not a five-second resilience strategy.

The dangerous fix: timeout plus retry

Timeout failures often lead to another apparently safe change: add retries.

That can help with transient failures. It can also multiply pressure on a dependency that is already struggling.

If a request times out because a shared resource is saturated, immediate retries create more work at exactly the wrong moment.

Before adding them, I want to know:

  • Is the operation safe to retry?
  • Is the failure actually transient?
  • Is there backoff and jitter?
  • How many attempts fit inside the caller's useful time budget?
  • What happens to concurrency during the retry?
  • Can the dependency absorb the additional work?

A retry policy is part of load behavior, not just error handling.

What would convince me to increase the timeout?

Sometimes the timeout really is too short.

For example, an operation may have a legitimate long tail that is understood, bounded and acceptable to the caller. A dependency contract may have changed. The original timeout may have been chosen arbitrarily and no longer match the workload.

In that case, I would want evidence that the system remains healthy while those slower operations complete.

I would compare the latency distribution, resource utilization, queueing and failure rate before and after the change. I would also check whether the longer wait creates a worse experience upstream.

The key difference is that the timeout changes because our understanding of the operation changed, not because an exception appeared in a log.

The investigation I want during an incident

When timeout errors start appearing, my first pass is roughly:

TEXT
1. Identify which timeout fired.
2. Check the end-to-end latency distribution, not only averages.
3. Trace where the request spent its time.
4. Correlate the beginning of latency growth with traffic and saturation signals.
5. Check cancellation propagation and work that survives abandoned requests.
6. Inspect retry behavior.
7. Change a timeout only when the evidence supports a different time budget.
8. Reproduce the relevant load pattern and measure again.

The order matters.

If I change the timeout first, I change the system before I understand the evidence. The next investigation becomes harder because the original failure boundary has moved.

Timeouts are useful failures

A timeout is frustrating because it usually appears at the end of the story.

The cause may have started seconds earlier in a queue, pool, lock, dependency or piece of work that should have been cancelled.

That is also what makes timeout incidents useful. They force us to look at the whole request path rather than one exception.

When I see one, I do not start with:

How much should we increase this number?

I start with:

Where did the time go?

That question tends to lead to a much better fix.

Further reading

Microsoft's .NET guidance on resilience and HTTP requests is a useful starting point for timeout and retry behavior:

Microsoft .NET HTTP resilience guidance