Q33 — What is the difference between a timeout, a deadline, and a retry budget?
Why do you need more than just a timeout?
The timeout is the most basic tool for dealing with slow dependencies: wait at most N milliseconds, then give up. Every engineer knows this. And yet, in distributed systems, timeouts alone are insufficient — and systems that rely only on timeouts fail in predictable, preventable ways.
The problem is that timeouts are local to a single operation, but the user's experience spans a chain of operations. A request from a user triggers a call from Service A to Service B, which calls Service C, which calls Service D. Each service independently sets timeouts. The sum of these independent timeouts can dramatically exceed what the user is willing to wait — or what any SLA promises.
Understanding deadlines and retry budgets is understanding how to make timeout behavior coherent across a distributed call chain, not just within a single service.
What is a timeout?
A timeout is a maximum duration to wait for a single operation to complete. If the operation doesn't complete within that time, it is abandoned and an error is returned.
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
result, err := callDownstream(ctx)
Timeouts prevent a single slow dependency from holding your resources indefinitely. Without a timeout on the database query, a slow query holds a connection forever. Without a timeout on the HTTP call, a slow downstream holds a thread/goroutine forever. At scale, forever-held resources exhaust capacity and cause the cascading failure circuit breaker exists to prevent.
The timeout gap problem: Service A calls Service B with a 2-second timeout. Service B calls Service C with its own 2-second timeout. Service C calls Service D with its own 2-second timeout. If D is slow and all three timeouts fire sequentially (each waiting 2 seconds before timing out), the user waits 6 seconds — even though A told B it would respond within 2. Each timeout restarts the clock from zero for that hop, ignoring how much time has already passed in the chain.
This is the core limitation of timeouts: they're per-hop, not end-to-end.
What is a deadline?
A deadline is an absolute point in time by which the entire operation — across all hops — must complete. Unlike a timeout (a relative duration), a deadline is a fixed timestamp. As a request propagates through services, the deadline propagates with it. Each service passes the remaining time to the next.
If the user's request must complete by T + 2000ms, and Service A spends 300ms processing before calling B, then B receives a deadline of T + 2000ms — which means B has only 1700ms remaining, not a fresh 2000ms. B spends 200ms and calls C with 1500ms remaining. C knows it has 1500ms, not an independent 2000ms.
Why deadlines matter: If C takes 1600ms — 100ms more than the remaining time — there's no point in D doing any work at all. D's response will arrive after the deadline, and A will have already returned an error to the user. With a timeout system, D doesn't know this — it receives the request and starts working, consuming resources for computation that is literally worthless to the user. With deadline propagation, C can check is there time left? before calling D and short-circuit immediately.
gRPC has deadline propagation built in as a first-class feature. Every RPC carries the deadline, and the framework automatically cancels downstream calls when the deadline expires. The downstream receives a cancellation signal and can abort its work, freeing resources rather than completing pointless computation.
Deadlines prevent wasted work. This is their key value beyond timeouts — they tell the system "this result will be discarded if it arrives after this moment, so don't bother producing it."
What happens without deadline propagation?
A practical failure scenario: a user clicks a button that triggers Service A → B → C → D → (slow database query). The user gives up and navigates away after 5 seconds.
Without deadline propagation:
- A times out after 5 seconds, returns a 504 to the browser
- B is still waiting for C's response, holding a thread
- C is still waiting for D's response, holding a thread
- D is still executing the slow database query, holding a database connection
The user is gone. The request failed. But the system is still doing the work — for another 10 seconds, 30 seconds, however long the query takes. Resources are consumed for a result nobody will use.
With deadline propagation: when A's deadline fires, the cancellation cascades downstream. B, C, and D receive cancellation signals. The database query is aborted. Resources are freed immediately. The system recovers from the failed request as fast as the network can propagate the cancellation.
At scale, this difference is enormous. A load spike that causes many slow requests can leave a system doing minutes of pointless work per second without deadline propagation, delaying recovery.
What is a retry budget?
A retry budget is a service-level cap on how much of its outgoing traffic is allowed to be retry attempts. It prevents retry amplification from destabilizing the system under load.
The problem: as described in Q29, if Service A retries failed calls to B, and B retries failed calls to C, the failures amplify. If A retries 3 times and B retries 3 times, a single failure at C generates 9 requests reaching C. In a system with 5 layers each retrying 3 times, a single root failure generates 3^4 = 81 requests. This amplification turns a degraded service into a crashed one.
A retry budget says: at most X% of all outgoing calls to a given service can be retries. If Service A makes 1,000 calls/s to Service B, and the retry budget is 10%, at most 100 of those 1,000 calls can be retries. Once the budget is exhausted, further failures are returned immediately to the caller rather than retried.
The budget is measured as a rolling rate, not a per-request decision. The logic is: "right now, are we within budget?" If yes, retry. If no, don't. This makes the retry load self-regulating — as failure rates rise and more retries happen, the budget fills faster, which causes later failures to not be retried, which keeps the total load within bounds.
Why per-request backoff isn't enough: Individual request backoff (wait 1s, 2s, 4s) spaces out that request's retries. But if 10,000 requests are failing simultaneously, all of them individually backing off still results in 10,000 retries hitting the downstream 1 second later, then 10,000 more 2 seconds after that. The retry budget caps the total number of retries across all concurrent requests, which is the amplification vector backoff alone doesn't address.
How the three concepts work together
They're not alternatives — they're a layered system for managing time and retry behavior correctly:
Timeout: Don't wait forever for a single operation. Prevents resource leaks from hung connections.
Deadline: Propagate the end-to-end time budget across the call chain. Prevents wasted work on requests that will be discarded.
Retry budget: Cap total retry amplification at the service level. Prevents retries from turning degraded services into crashed ones.
A robust service uses all three:
- Every outgoing call has a timeout derived from the remaining deadline (not an independent fixed value)
- Deadlines propagate downstream with every call
- Retries are gated on both individual backoff and the service-level retry budget
// Derive per-call timeout from remaining deadline
remaining := deadline.Sub(time.Now())
callTimeout := min(remaining, maxCallTimeout)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
// Only retry if budget allows
if retryBudget.Allow() && isRetryable(err) {
// retry with backoff
}
The mental model
Timeout:
Per-hop: wait at most N ms for this one call
Problem: each hop gets its own N ms → chain latency = sum of all timeouts
Prevents: hung resources from a single slow dependency
Deadline:
End-to-end: this request must complete by timestamp T, propagated through all hops
Remaining time = T - now, passed to each downstream call
Prevents: wasted work after the user-visible deadline has already passed
gRPC: built-in. REST: must implement manually via headers
Retry budget:
Service-level: at most X% of outgoing calls to service B can be retries
Prevents: retry amplification turning degradation into collapse
Individual backoff controls per-request retry spacing
Budget controls total retry volume across all concurrent requests
Together:
Timeout → don't wait forever for one hop
Deadline → don't do work that will be discarded
Retry budget → don't amplify failures into collapse
Missing any one:
No timeout → hung threads, resource exhaustion
No deadline → wasted computation, slow recovery from load spikes
No retry budget → retry storms, cascading collapse under degradation
Timeouts are what junior engineers add. Deadlines are what senior engineers propagate. Retry budgets are what SREs enforce. Together they form a coherent contract with time — ensuring that in a distributed system, slowness and failure are bounded, not amplified.