All articles
Distributed Systems
Reliability in Distributed SystemsPart 03

Retry, Timeout, and Backoff: When Should We Stop Retrying?

Retrying a failed request seems reasonable. But what happens when our retries increase the load on a service that is already struggling to recover?

10 min readTürkçe oku

Retries are among the first mechanisms we consider when designing resilient distributed systems.

A connection fails, so we retry. A request times out, so we try again. A service becomes temporarily unavailable, so we wait and reconnect.

These mechanisms are useful, but they can also become part of the failure.

In systems where multiple services depend on one another, uncontrolled retries may transform a temporary slowdown into a much larger availability problem.

A timeout is not proof of failure

A timeout tells us that the expected response was not received within the configured time.

It does not necessarily tell us whether the remote operation was executed.

The request may never have reached its destination. It may still be running. Or it may have completed successfully while the response was lost.

These outcomes can look identical to the caller, but they have different implications for retry safety.

Repeating a read-only operation may be acceptable. Repeating a payment or a physical device command may introduce unintended side effects.

This brings us back to idempotency.

A timeout is an observation, not proof of the operation’s outcome.

How retries amplify failures

Suppose a downstream service becomes overloaded.

Client requests begin to time out. Each client immediately retries several times.

The overloaded service receives even more requests, increasing latency and triggering further retries.

A feedback loop develops: slow responses cause retries, and retries make responses slower.

The effect can multiply across service boundaries. If five layers independently make up to three total attempts, a failure at the deepest layer can theoretically result in 3⁵, or 243 attempts.

Real systems do not always reach this worst case, but the example demonstrates why retry placement and limits matter.

Exponential backoff and jitter

Exponential backoff introduces progressively longer delays between attempts.

For example:

Initial request    immediately
Retry 1            after 200 ms
Retry 2            after 400 ms
Retry 3            after 800 ms

This can give the downstream service time to recover.

However, many clients using identical delays may still retry simultaneously.

Jitter introduces controlled randomness into those delays, spreading retry attempts over time and reducing synchronized bursts.

Schematic — How jitter spreads synchronized retries

Top: three clients fail at the same instant, all wait 400 ms and retry together. Bottom: the same three clients wait a random 0 to 400 ms and their retries land at different moments. Without jitter: every client waits 400 ms t₀ · 3 clients fail together t₀ + 400 ms · 3 retries at once With jitter: each client waits a random 0–400 ms t₀ · 3 clients fail together Retries spread over time time →
Schematic illustration, not measured data. The measured effect is shown in Figure 1.

A retry policy also needs limits on attempt count, elapsed time, and acceptable load. Jitter alone is not a complete resilience strategy.

A small .NET example

The following code applies bounded retries with jitter to a read-only HTTP GET request:

using System.Net;

static bool IsTransient(HttpStatusCode status) =>
    status is HttpStatusCode.RequestTimeout
        or HttpStatusCode.TooManyRequests
        or HttpStatusCode.BadGateway
        or HttpStatusCode.ServiceUnavailable
        or HttpStatusCode.GatewayTimeout;

static async Task<HttpResponseMessage> GetWithRetryAsync(
    HttpClient client,
    string url,
    CancellationToken cancellationToken)
{
    const int maxAttempts = 4;

    for (var attempt = 1; ; attempt++)
    {
        using var timeout = CancellationTokenSource
            .CreateLinkedTokenSource(cancellationToken);

        timeout.CancelAfter(TimeSpan.FromSeconds(2));

        HttpResponseMessage? response = null;

        try
        {
            response = await client.GetAsync(
                url,
                timeout.Token);

            if (!IsTransient(response.StatusCode) ||
                attempt == maxAttempts)
            {
                return response;
            }
        }
        catch (OperationCanceledException)
            when (!cancellationToken.IsCancellationRequested &&
                  attempt < maxAttempts)
        {
            // This attempt timed out.
        }
        catch (HttpRequestException)
            when (attempt < maxAttempts)
        {
            // The failure may be transient.
        }

        response?.Dispose();

        var capMs = Math.Min(
            200 * (1 << (attempt - 1)),
            2000);

        var delay = TimeSpan.FromMilliseconds(
            Random.Shared.Next(0, capMs + 1));

        await Task.Delay(delay, cancellationToken);
    }
}

This example intentionally focuses on the basic retry mechanism rather than providing a complete production policy.

A real system must also consider total execution budgets, server-provided Retry-After guidance, telemetry, load limits, and the semantics of the operation.

The example is restricted to GET requests. Applying the same mechanism to operations with non-idempotent side effects requires additional safeguards.

Testing the behavior: What do retries actually improve?

Rather than evaluating retries, backoff, and jitter through theory alone, I wanted to compare their behavior under controlled failure conditions.

As part of the Distributed Reliability Lab, I developed two experiments. E03-A examines general retry behavior during transient failures, while E03-B focuses on recovery after a large number of clients experience a synchronized disruption.

The initial results revealed an important limitation: different retry strategies could generate very similar total request volumes, and jitter did not consistently improve success rates.

This led me to focus on when requests arrive, rather than only how many requests are sent.

Experimental setup

E03-B simulates 200 clients.

The downstream dependency freezes between 8.5 and 10.0 seconds. When it resumes at 10.0 seconds, it responds to the accumulated requests with HTTP 503.

I compared four strategies:

  • A — Immediate retry: Failed attempts are retried without delay.
  • B — Exponential backoff: Increasing delays are introduced between attempts.
  • C — Backoff with jitter: Randomness is added to the backoff delays.
  • D — Backoff with jitter and a retry budget: Additional retries are constrained to approximately 10% of the initial request volume.

All strategies share the same basic limits: a maximum of four attempts, a two-second timeout per attempt, and a five-second total operation budget.

The dependency is a synthetic model: 10 workers at 40 ms per request give a capacity of 250 requests per second, with a queue of 100 and an immediate HTTP 503 once capacity is full. For five seconds after the restart (10–15 s), capacity drops to 125 requests per second. I ran the experiments on a single laptop, inside Docker. The results belong to this model and these conditions; I do not claim they represent any real service one to one.

Total request volume does not tell the whole story

In the E03-B run with seed 42, strategies B and C generated nearly identical request volumes: 2,956 and 2,959 requests respectively.

Their peaks within a one-second window were also nearly identical: 229 requests for B and 225 for C.

At that resolution, jitter appeared to make little difference.

But when I measured the traffic in 100-millisecond intervals, the difference became clear.

Strategy B peaked at 143 requests, while C peaked at 76.

In this scenario, jitter reduced short-interval peak concentration by approximately 47%, without materially reducing total request volume.

The reason is synchronization. Clients that fail simultaneously and use identical backoff schedules tend to retry together.

Jitter spreads those attempts across a wider interval.

Figure 1 — Temporal distribution of retry attempts

  • A · Immediate retry
  • B · Exponential backoff
  • C · Backoff + jitter
E03-B, seed 42: requests reaching the dependency per 100 ms between 8 and 13 seconds. A makes one sharp peak at 10.0 seconds, B retries in waves, and C spreads its retries over a wider interval.Freeze (8.5–10 s)0501001502002508 s9 s10 s11 s12 s13 sTime since the run startedRequests per 100 msA · Immediate retryB · Exponential backoffC · Backoff + jitter
Figure 1. Requests reaching the dependency in 100 ms intervals during E03-B, seed 42. Immediate retries (A) produce a sharp burst when the dependency resumes. Exponential backoff (B) delays retries but can produce synchronized waves. Adding jitter (C) spreads retry attempts more evenly over time. Based on actual experimental measurements. The chart shows seconds 8–13; measurement interval 100 ms. Source: distributed-reliability-lab, E03-B, seed 42, commit e8743ae (experiments/retry-resilience/results/sample/e03-b).
Show the data as a table
TimeA · Immediate retryB · Exponential backoffC · Backoff + jitter
8 s7109
8.1 s1099
8.2 s121112
8.3 s111211
8.4 s998
8.5 s999
8.6 s667
8.7 s1088
8.8 s81010
8.9 s777
9 s101010
9.1 s988
9.2 s121110
9.3 s121314
9.4 s101011
9.5 s675
9.6 s778
9.7 s888
9.8 s899
9.9 s111110
10 s2321176
10.1 s4373
10.2 s514319
10.3 s6613
10.4 s71111
10.5 s8815
10.6 s4368
10.7 s344
10.8 s564
10.9 s112
11 s100
11.1 s844
11.2 s655
11.3 s323
11.4 s881
11.5 s634
11.6 s455
11.7 s427
11.8 s854
11.9 s736
12 s353
12.1 s11117
12.2 s855
12.3 s889
12.4 s13712
12.5 s14912
12.6 s6109
12.7 s11913
12.8 s14108
12.9 s1459
13 s1287

This highlights an important measurement issue:

The observed effect of a resilience mechanism can depend on the time resolution of the metrics.

A one-second aggregate may conceal a substantial burst occurring within a much shorter interval.

Retry budgets introduce a different trade-off

Strategy D introduces a stricter limit on retry volume.

In the seed 42 run, it reduced total requests to 2,858, the one-second peak to 120, and the 100 ms peak to 19.

However, the success rate among affected operations fell to 78%, compared with 100% for B and C.

That does not mean the retry budget failed.

It means the policy prioritized limiting downstream work, even when doing so prevented some requests from recovering.

Protecting a dependency and maximizing the success rate of individual requests are not always the same objective.

Whether this trade-off is acceptable depends on the application’s requirements and available fallback behavior.

Figure 2 — Strategy comparison

StrategySuccessaffected operations (8.5–15 s)Total requestsfirst attempts + retriesRetriessentRetriesdenied by budgetPeakper 1 sPeakper 100 msp95all operationsp99all operationsRecovery
A · Immediate retry92.9%3,0402200275232628 ms1,600 ms1 s
B · Exponential backoff100.0%2,9561820229143844 ms2,132 ms0 s
C · Backoff + jitter100.0%2,959171022576870 ms2,043 ms0 s
D · C + retry budget78.0%2,8581012612019131 ms1,192 ms1 s
Figure 2. Measured E03-B results, seed 42. Success rates refer to affected logical operations, while request counts and peak traffic describe requests reaching the dependency. All strategies share the same base attempt and timeout limits; D adds a retry budget. Affected operations are the logical operations that started between the freeze at 8.5 s and the end of the cold start at 15.0 s; p95 and p99 cover all operations, succeeded and failed. Source: distributed-reliability-lab, E03-B, seed 42, commit e8743ae (experiments/retry-resilience/results/sample/e03-b).

We should also avoid interpreting lower p99 latency as automatically better performance.

Strategy D had the lowest reported p99 at 1,192 ms, but also the lowest success rate.

The treatment of failed requests in latency calculations matters. Success rate and tail latency must be interpreted together.

Repeating the experiment

I repeated E03-B using five seeds. The main pattern remained consistent: jitter reduced short-interval request concentration, while overall request volumes for B and C remained similar.

However, changing the workload produced different outcomes.

With a smaller queue, jitter improved the success rate. With longer disruptions or more clients, C sometimes produced more total requests and a higher one-second peak than B.

Each alternative configuration was tested in a single run, so these observations should not be treated as evidence of general superiority.

In E03-A, variation between random seeds sometimes exceeded the differences between strategies.

The conclusion is important:

A retry policy cannot be evaluated reliably using one scenario, one random seed, or one metric.

What the experiment taught me

Backoff, jitter, and retry budgets solve related but distinct problems.

Backoff delays retries. Jitter can reduce synchronization between them. Retry budgets constrain the additional work that retries are allowed to generate.

Their effects depend on workload characteristics, downstream capacity, and recovery conditions.

Instead of asking which retry algorithm is universally better, we should ask:

What behavior is acceptable when this particular system fails and recovers?

Answering that requires evaluating success rates, short-term traffic bursts, total request costs, latency, and recovery behavior together.

Run the experiment yourself

The code, tests, and raw results of both experiments are open source in the distributed-reliability-lab repository. Docker is the only requirement:

git clone https://github.com/hemreg/distributed-reliability-lab.git
cd distributed-reliability-lab/experiments/retry-resilience
docker compose up --build

The command takes about four to five minutes. It runs E03-A, then E03-B, and finally prints the counterparts of the tables in this article. Afterwards, docker compose down cleans up, and ./test-e03.sh runs the unit tests. They verify the delay schedules, the retry loop’s attempt and time limits, the retry budget, and the failure timeline; they do not verify the experiment’s results or that any strategy is better. On a laptop, sleep pauses the experiment; on macOS, caffeinate -di docker compose up --build prevents it.

Parameters can be changed with environment variables, for example E03_SEED=7 docker compose up --build for a different seed. The repository README explains each parameter and why it was chosen.

Your numbers may differ slightly, because timing varies from machine to machine. In an independent run from a fresh clone with the same seed, the 100 ms peaks of B and C were 144 and 77, and D’s success rate was 77.9%. The differences between strategies and their order should hold. If they do not, that is a result worth sharing; you can report it by opening an issue in the repository.

When should we stop retrying?

Retrying is not always the right response.

We should reconsider retries when:

  • Repeating the same invalid request cannot change the outcome.
  • The previous operation may already have produced a non-idempotent side effect.
  • The total execution budget has been exhausted.
  • A dependency requests that clients stop or delay further attempts.
  • The downstream system cannot safely absorb additional traffic.

The appropriate alternative may be returning an error, delaying work, querying the operation state, queueing the request, or requiring operator intervention.

In industrial environments, retry decisions may also depend on command semantics and the observed state of a physical device.

Retry is only one resilience mechanism

Retries may need to work alongside circuit breakers, backpressure, rate limiting, and load shedding.

Each mechanism introduces its own trade-offs. An overly aggressive circuit breaker, for instance, may continue rejecting requests even after a dependency has recovered.

Choosing thresholds and observing recovery behavior are therefore as important as selecting the mechanism itself.

Conclusion: Retry is a policy, not a reflex

The purpose of a retry mechanism is not simply to resend a failed request.

The E03 experiments showed that different retry policies can affect success rates, total request volume, and short-term traffic concentration in different ways.

Under certain conditions, jitter reduced synchronized retry bursts. Retry budgets constrained downstream traffic more aggressively, but at the cost of lower recovery success for some operations.

Choosing an algorithm is therefore not enough. Operation semantics, time budgets, downstream capacity, and business consequences must all be considered.

Reliability is not about retrying after every failure. It is about knowing when retrying is the right decision.

Further reading

#retry#timeout#backoff#jitter#resilience