Timeouts, retries and deadline propagation: an interactive simulator
A timeout is how long one caller waits for one call. A deadline is the moment the whole request stops being useful, and deadline propagation passes it down the chain of calls so every service, and the database at the end, knows how long is left. Without it, each hop guesses on its own: services retry for callers that have already gone and databases finish queries nobody will read.
The playground sends 2,000 requests from a client through an API and a service to a database, under four strategies. Make queries stall, tighten the deadline, and watch one request’s trace and the totals.
2,000 requests, each going client to API to service to database, with a 1 s deadline. 5% of queries stall for 1.5 to 2.5 s; the rest take 20 to 80 ms. With one try and 1 s timeouts, 95% are answered in time. Retrying at every layer answers 100%, but one request can now cause up to 5 queries, because each of the API's tries starts a service that tries again on its own. Retrying only in the service answers 100% with at most 3. All three leave stalled queries running after the caller has gone: 55% of the database's time in the one-layer case is spent after the client stopped waiting. Propagating the deadline answers 100% too, but the database cancels a query the moment its caller stops waiting: 66 ms of database time per request instead of 155, and none after the client has left.
- Answered in time
- 95%
- p99 latency
- 1,000 ms
- Queries per request
- 1.00 (up to 1)
- Database time per request
- 150 ms
- Database time wasted
- 68%
- Spent after the client left
- 35%
- Answered in time
- 100%
- p99 latency
- 384 ms
- Queries per request
- 1.11 (up to 5)
- Database time per request
- 160 ms
- Database time wasted
- 69%
- Spent after the client left
- 56%
- Answered in time
- 100%
- p99 latency
- 382 ms
- Queries per request
- 1.05 (up to 3)
- Database time per request
- 155 ms
- Database time wasted
- 68%
- Spent after the client left
- 55%
- Answered in time
- 100%
- p99 latency
- 382 ms
- Queries per request
- 1.05 (up to 3)
- Database time per request
- 66 ms
- Database time wasted
- 24%
- Spent after the client left
- none
What the playground shows
- One try with 1 s timeouts fails every request whose query stalls: 95% answered when 5% of queries stall. The stalled query runs on for up to 2.5 s, long after the client gave up at 1 s.
- Retries at every layer answer nearly everything, but multiply. The API tries the service 3 times and each service call tries the database 3 times, so when every query stalls, one request causes 9 queries and 18 seconds of database time. With 5 layers retrying 3 times each, it would be 243.
- Retries at one layer answer just as well with at most 3 queries. But nothing tells the database the caller has given up, so with 5% of queries stalling, 56% of its time is spent after the client has stopped waiting.
- A propagated deadline answers the same requests and the database cancels each query when its caller stops waiting: 66 ms of database time per request instead of 155, and 900 ms instead of 6 s when everything stalls.
Propagation does not make more requests succeed in this model. What it saves is capacity, and capacity is what an overloaded system is short of: queries for abandoned requests slow down the queries for live ones, which time out and are retried, which adds more load. Cutting the abandoned work is how a slowdown stays a slowdown instead of becoming an outage.
Rules for timeouts and retries
- Set a timeout on every network call. Many client libraries default to none, or to minutes, and one hung dependency then holds threads and connections until the caller runs out.
- Pick it from the latency distribution: a little above the dependency’s p99 or p99.9, so a retry is spent on the stragglers, not on ordinary slow calls.
- Inner timeouts must fit inside outer ones. If the API waits 300 ms and the service’s first query attempt alone may take 300 ms, the service’s retries finish after nobody is listening.
- Retry at one layer, usually the one closest to the failing dependency, with backoff and jitter and a retry budget, such as at most 10% extra calls.
- Pass the time left, not a timestamp. gRPC sends the remaining time in a header and each server turns it back into a local deadline, so clock skew between machines does not matter. Subtract a little at each hop for the network.
- Act on it everywhere: don’t start work that cannot finish in time, set the database’s statement timeout from it, and stop when the request is cancelled.
Deadline propagation in code
import time
class Deadline:
"""An absolute point in time, passed down as the time left."""
def __init__(self, seconds):
self.at = time.monotonic() + seconds
def left(self):
return self.at - time.monotonic()
def get_order(order_id, deadline):
for attempt in range(3): # retry here, and only here
left = deadline.left()
if left < 0.04: # cannot finish in time: don't start
raise DeadlineExceeded()
try:
with db.transaction() as tx:
# the database gives up when we do
ms = int(min(0.3, left) * 1000)
tx.execute(f"SET LOCAL statement_timeout = {ms}")
return tx.query_one("SELECT * FROM orders WHERE id = %s", (order_id,))
except QueryCanceled:
continue
raise DeadlineExceeded()
def handle(request):
# the caller says how long it will wait; pass on what is left, never a fixed timeout
deadline = Deadline(int(request.headers.get("x-deadline-ms", "1000")) / 1000)
return get_order(request.params["id"], deadline)In Go, the deadline travels in context.Context and every library call that takes a context respects it; in gRPC, the client’s deadline arrives as the server’s context deadline and a cancelled call cancels the server’s context too.
In a system design interview
When you draw a chain of services, give the request a budget and say where it is spent: “The client waits 1 s. The gateway passes the remaining deadline in a header. Only the order service retries, twice, with jitter and a 10% retry budget. Each database query gets a statement timeout of whatever is left, so when a request is abandoned, so is its work.” Expect the follow-ups: how timeouts relate to tail latency, where a circuit breaker fits, and what happens to a non-idempotent call that timed out but may have succeeded.
Read the guide to resilience or load shedding and backpressure, see load shedding for the server’s side, or watch a strong candidate design a payment system.
Questions people ask
What is deadline propagation?
Passing the time a request has left along with every call it makes, so each service and the database know when the original caller will stop waiting. Each hop uses it to set its own timeouts, to skip work that cannot finish in time, and to cancel work once the deadline passes. gRPC does this by sending the remaining time in a header.
What is the difference between a timeout and a deadline?
A timeout is a duration one caller waits for one call, such as 300 ms. A deadline is an absolute point by which the whole request must finish. Timeouts set independently at each hop do not add up to anything; a deadline passed down the chain keeps every hop within the time the user will actually wait.
How do you choose a timeout value?
From the dependency’s measured latency: a little above its p99 or p99.9, so only real stragglers time out, and short enough that a retry can still finish within the caller’s own deadline. Every network call needs one, since many client libraries default to waiting forever.
Why is retrying at every layer a problem?
Retries multiply. If three layers each try three times, one failing request at the top can become 27 calls at the bottom, arriving exactly when the bottom layer is already struggling. Retry at one layer, usually nearest the failing dependency, with backoff, jitter and a retry budget.
Should inner timeouts be shorter than outer timeouts?
Yes. A service’s timeout on its dependency, including any retries, has to fit inside the time its own caller will wait, minus its own work. Otherwise the service is still waiting or retrying after its caller has given up, and its answer is thrown away.
What happens to work after the client times out?
Unless something cancels it, it keeps going: downstream services keep processing and retrying, and the database finishes the query. That work uses capacity live requests need. Propagating the deadline, setting statement timeouts from it, and cancelling on disconnect stop it.
How does gRPC propagate deadlines?
The client sets a deadline on the call and gRPC sends the remaining time in the grpc-timeout header. The server sees it as its context’s deadline, and when the server passes that context to its own calls the deadline carries on down the chain. If the client cancels, the server’s context is cancelled too.