Why independent per-hop timeouts fail
A deadline budget is a single absolute time limit set once for a request at the edge and carried through every downstream call. Each hop spends part of it, and no hop may wait longer than what remains. The usual alternative gives each client its own fixed timeout, configured independently per service. That approach fails in two ways.
Work that outlives its caller
Suppose an API gateway times out after 2 seconds, but the service it calls allows 5 seconds for its own database query. When the gateway gives up, the user gets an error. The service and the database keep going for up to 3 more seconds, holding connections, threads, and locks to produce a result that will be thrown away. Under load this wasted work competes with live requests. Latency rises, more callers time out, and more work is abandoned. This is how cascading timeouts feed on themselves.
Retries multiply with depth
If every layer retries independently, the attempts that reach the deepest dependency are multiplied, not added:
attempts at depth N = (1 + r₁) × (1 + r₂) × … × (1 + rₙ)
where rᵢ is the retry count at layer i.
Illustrative estimate (assumed numbers, not a benchmark): gateway → A → B → database, with each of the three callers set to 2 retries. That gives (1 + 2)³ = 27 database attempts for one failed user request, which arrive at the moment the database is already struggling.
Who benefits most
Teams running synchronous call chains of three or more hops behind a user-facing latency target benefit most. In those chains, mismatched timeouts and stacked retries are hardest to spot and most expensive to leave in place.
(1) Work that outlives its caller: gateway 2s, service 5s (assumed)
0s 2s 5s
Gateway |===========X gives up, user gets an error
Service |=================================| still waiting
Database|=================================| still querying
| |<----- 3s wasted ----->|
(2) Retries multiply with depth: 2 retries per caller (illustrative)
+---------+
| Gateway | 1 user request, 2 retries
+---------+
| 1 + 2 = 3 attempts
v
+-----------+
| Service A | 2 retries per incoming attempt
+-----------+
| 3 x 3 = 9 attempts
v
+-----------+
| Service B | 2 retries per incoming attempt
+-----------+
| 9 x 3 = 27 attempts
v
+----------+
| Database | 27 attempts for one failed user request
+----------+
With independent per-hop timeouts, the service and database keep working for 3 seconds after the gateway has given up. With 2 retries at each of three callers, one failed user request becomes 27 database attempts (illustrative estimate with assumed values).
Deriving a budget from the user-facing latency target
Start from the latency target the user actually experiences, usually a p99 at the edge, and work inward. Each hop receives a budget, keeps some for its own work and a safety margin, and passes the rest downstream:
downstream = (received − local_work) × (1 − margin)
For sequential calls, the downstream share is split across the calls in order. For parallel fan-out, each branch can use the full share, because the slowest branch determines when the hop finishes.
Illustrative estimate (assumed numbers, not a benchmark)
Assume a 400 ms edge target, 50 ms for TLS, load balancing, and serialization, 30 ms of local work per hop, and a 15% margin per hop. That leaves 350 ms entering service A.
| Hop | Budget received | Local work | Margin (15%) | Passed downstream |
|---|---|---|---|---|
| A | 350 ms | 30 ms | 48 ms | 272 ms |
| B | 272 ms | 30 ms | 36 ms | 206 ms |
| C | 206 ms | 30 ms | 26 ms | 150 ms |
| D | 150 ms | 30 ms | 18 ms | 102 ms |
The margins and fixed costs compound. Service D's database call gets about a quarter of the original target. If any hop also allows one retry, its downstream share is roughly halved again.
Use percentiles, not guesses
Set each dependency's timeout from its observed latency distribution. A common choice is its p99 plus modest headroom, capped at the remaining budget. If the dependency's p99 is already larger than its slot, raising the timeout will not help. It only hides the problem. The real fixes are design changes: remove a hop, parallelize calls, cache results, or renegotiate the target.
How deadline propagation works
The mechanism behind the budget is simple. The caller attaches the time it has left to the request metadata. Each service reads that value on arrival, works out its own remaining budget, and passes a smaller value to anything it calls.
At each hop, a service does four things:
- Read the incoming value and record the local arrival time.
- Compute what remains after its own processing so far.
- Subtract a safety margin before calling downstream. The margin leaves room to send the response back and to handle a failure in the dependency.
- Reject the request immediately if the budget is already zero or negative. Work on an expired request produces a result nobody is waiting for.
The value can be an absolute deadline (a wall-clock timestamp) or a relative duration (milliseconds remaining). The two fail in different ways:
| Property | Absolute deadline | Relative timeout |
|---|---|---|
| Sensitive to clock skew | Yes. A receiver with a fast clock sees the deadline as earlier than it is. | No. Only the local monotonic clock is used. |
| Counts time spent in network and queues | Yes, implicitly | No. Time before the receiver starts its timer goes uncounted. |
| Safe across trust or ownership boundaries | Only with synchronized clocks | Yes |
A practical approach is to keep the deadline internally as an absolute point on the local monotonic clock and convert it to a relative duration whenever the request leaves the process. gRPC works this way: it sends a relative timeout on the wire. Some time in network transit and queues still goes uncounted, so the safety margin needs to cover it.
Propagation is only as reliable as its weakest component. A proxy, sidecar, or client library that drops or ignores the metadata does not report an error. It falls back to its own static timeout, which is often far longer than the remaining budget. Check every component in the path to confirm it forwards the metadata.
# Per-hop handling (pseudocode). Header name is an example convention.
function handle(request):
arrived = monotonic_now() # local clock only, immune to skew
budget_ms = request.metadata["x-timeout-ms"] # relative value from caller
if budget_ms is missing:
budget_ms = config.overall_default_ms # explicit fallback, not a silent one
deadline = arrived + budget_ms # absolute point on LOCAL monotonic clock
if deadline - monotonic_now() <= 0:
return error(DEADLINE_EXCEEDED) # caller has stopped waiting
do_local_work()
# Convert back to a relative duration at the process boundary.
remaining = deadline - monotonic_now()
outbound = remaining - config.safety_margin_ms # room for reply + failure handling
if outbound <= 0:
return error(DEADLINE_EXCEEDED) # fail fast, do not call downstream
call = new_request("inventory-service")
call.metadata["x-timeout-ms"] = outbound # propagate the smaller budget
call.timeout = outbound # enforce it locally too
return send(call)
# Client timeout settings (illustrative keys and values)
inventory_client:
connect_timeout_ms: 200 # TCP/TLS setup only; fail fast on unreachable hosts
request_timeout_ms: 800 # upper cap per attempt, even if budget is larger
overall_default_ms: 2000 # used only when no incoming budget is present
safety_margin_ms: 50 # reserved for transit, queueing, and the response
# Effective per-call timeout = min(request_timeout_ms, remaining - safety_margin_ms)
Pseudocode showing a service reading a relative budget, tracking it as a local monotonic deadline, rejecting expired work, and sending a reduced budget downstream, plus client settings that separate connect, per-request, and overall limits.
Retries, hedging, and cancellation inside the budget
A retry spends budget that the caller has already partly used. Before each attempt, compare the remaining time against the latency you expect from a successful call, such as the downstream's p95. If the remaining time is smaller, the attempt will probably time out, so fail fast. Cap attempts at a small fixed number. Add exponential backoff with jitter so that clients which failed together do not retry together.
Retry at one layer only. If three layers each make three attempts, a single failing leaf can receive 3 × 3 × 3 = 27 calls, and the extra load lands on the component that is already struggling. Retry only idempotent operations, or operations protected by an idempotency key. A request that timed out may still have succeeded on the server.
Hedged requests take a different approach. If the first request has not responded by roughly its p95 latency, send a second copy to another replica and use whichever response arrives first. This cuts tail latency, but it adds downstream traffic. Limit the share of requests that can be hedged, and apply the same idempotency rule as for retries.
When the deadline expires, cancel the work. Close outbound requests, return pooled connections, free worker threads, and abort in-flight database queries. Without cancellation, abandoned work keeps holding capacity that live requests need.
Cancellation is often incomplete. Some databases keep running a query after the client disconnects. A message already published to a queue still gets processed. A third-party API may finish a charge you stopped waiting for. Find these gaps in your call path and design for them explicitly, for example with server-side statement timeouts or compensating actions.
// Requires Node 20.3+ for AbortSignal.any. GET is idempotent, so it is safe to retry.
const MAX_ATTEMPTS = 3;
const EXPECTED_P95_MS = 120; // assumed downstream p95 for a successful call
const BASE_BACKOFF_MS = 50;
// Sleep that ends early if the caller cancels.
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const onAbort = () => { clearTimeout(t); reject(signal.reason); };
const t = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms);
signal.addEventListener("abort", onAbort, { once: true });
});
}
// deadline: absolute epoch ms propagated from the caller. signal: aborts when the caller gives up.
export async function getWithRetry(url: string, deadline: number, signal: AbortSignal): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const remaining = deadline - Date.now();
// Fail fast: an attempt that cannot finish in time only adds load downstream.
if (remaining < EXPECTED_P95_MS) break;
try {
// Bound the attempt by the remaining budget and by the caller's cancellation.
const res = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(remaining)]) });
if (res.status < 500) return res; // success, or a client error that retrying will not fix
lastError = new Error(`HTTP ${res.status}`);
await res.body?.cancel(); // drain so the connection goes back to the pool
} catch (err) {
if (signal.aborted) throw err; // caller cancelled: stop all work now
lastError = err;
}
// Exponential backoff with full jitter so clients that failed together do not retry together.
const backoff = Math.random() * BASE_BACKOFF_MS * 2 ** attempt;
if (deadline - Date.now() - backoff < EXPECTED_P95_MS) break; // no room for another real attempt
await sleep(backoff, signal);
}
throw new Error("retry budget exhausted", { cause: lastError });
}
A retry loop for an idempotent GET that checks the remaining deadline against the expected p95 before each attempt, caps attempts, backs off with full jitter, and stops immediately when the caller's AbortSignal fires.
Server-side enforcement and observability
A server should check the remaining budget when a request arrives and again before each expensive step, such as a database query, a fan-out, or a lock acquisition. If the remaining time is less than the work usually takes, reject the request immediately. Starting work that cannot finish only consumes capacity that requests with enough time left could use. Base the threshold on observed service time for that endpoint, such as a low percentile, so that you shed requests that are already doomed without turning away ones that would succeed.
Report the rejection with a status the caller can tell apart from other failures. In gRPC this is DEADLINE_EXCEEDED, which is separate from UNAVAILABLE or INTERNAL. Over HTTP, choose one convention and document it. For example, use 504 when the deadline expired and 503 when the server is shedding load. The distinction matters because callers act on it. An expired deadline should never be retried, since there is no budget left to retry with. An overloaded server might be retried elsewhere if time allows.
On every hop, record two attributes on the trace span: the budget the request arrived with and the budget it had left when it finished. A trace then shows directly which hop used up the time.
In metrics, count deadline-exceeded as its own error class. Label each occurrence with two things:
- Where it was detected: rejected at admission, or expired mid-flight.
- Which hop detected it.
Set alerts on these patterns:
- Rising deadline-exceeded counts at deep hops while their own latency stays flat. This means upstream services are spending the budget, and the deep hops are reporting the symptom rather than causing it.
- A growing share of requests that arrive with almost no budget left. This is an early sign of cascading timeouts, and it often appears before users notice anything.
import grpc
from opentelemetry import trace
from prometheus_client import Counter
import inventory_pb2 # generated stubs (placeholder service)
import inventory_pb2_grpc
# Deadline-exceeded is its own error class, labeled by stage and hop.
DEADLINE_EXCEEDED = Counter(
"rpc_deadline_exceeded_total", "Requests that ran out of budget", ["stage", "hop"])
HOP = "inventory-service"
# Illustrative thresholds: a low percentile of observed service time.
MIN_REQUEST_S = 0.040
MIN_DB_QUERY_S = 0.015
def require_budget(context, needed_s, stage):
remaining = context.time_remaining() # None if the caller set no deadline
if remaining is not None and remaining < needed_s:
DEADLINE_EXCEEDED.labels(stage=stage, hop=HOP).inc()
# Distinct status: callers must not retry this, unlike UNAVAILABLE.
context.abort(grpc.StatusCode.DEADLINE_EXCEEDED,
f"{remaining * 1000:.0f}ms left, need {needed_s * 1000:.0f}ms")
return remaining
class InventoryServicer(inventory_pb2_grpc.InventoryServicer):
def GetStock(self, request, context):
span = trace.get_current_span()
arrived = context.time_remaining()
if arrived is not None:
span.set_attribute("deadline.budget_at_arrival_ms", int(arrived * 1000))
try:
require_budget(context, MIN_REQUEST_S, stage="admission")
# ... cheap validation, cache lookup ...
left = require_budget(context, MIN_DB_QUERY_S, stage="mid_flight")
count = query_stock(request.sku, timeout_s=left) # pass budget down
return inventory_pb2.StockReply(sku=request.sku, count=count)
finally:
# Recorded on success and on abort, so traces show where time went.
left = context.time_remaining()
if left is not None:
span.set_attribute("deadline.budget_at_exit_ms", int(max(left, 0) * 1000))
A Python gRPC handler sheds requests whose remaining deadline is too short at admission and before the database call, returns DEADLINE_EXCEEDED, counts the rejection by stage and hop, and records the budget on arrival and exit on the trace span.
When to use deadline budgets, when to avoid them, and the alternatives
Deadline budgets pay off when a request crosses several synchronous hops, a user-facing latency target exists, and retries happen at more than one layer. Under those conditions, independent timeouts compound and waste work. They are not worth the effort for shallow chains of one or two hops. They also add little for internal batch traffic, where throughput matters more than latency. They are a poor fit for stacks that cannot carry metadata end to end, such as a legacy proxy or third-party hop that strips headers. Partial propagation is risky because it suggests deadlines are enforced when they are not.
Async and fan-out work
Background jobs often outlive the request that triggered them, so they should not inherit its deadline. Give them their own timeout, tied to the job's purpose rather than the caller's latency target.
Fan-out breaks the linear model. Parallel branches each receive the full remaining budget, not a share of it. You therefore need explicit rules for each branch:
- Required branches fail the whole request if they miss the deadline.
- Optional branches get a shorter cap and are skipped on expiry, with the aggregate marked as partial.
Alternatives
| Approach | Solves | Misses |
|---|---|---|
| Static per-hop timeouts | Simple, no metadata needed | Hops don't know how much time remains |
| Client-side-only timeouts | Bounds what the user waits | Downstream keeps working after the client gives up |
| Circuit breakers | Stops calls to a failing dependency | Does not limit per-request time |
| Load shedding | Protects overloaded servers | Does not coordinate time across hops |
| Queue-based decoupling | Removes the latency coupling | Changes semantics to eventual completion |
Tight versus loose budgets
A tight budget fails fast and frees capacity, but it rejects slow requests that would have succeeded. A loose budget accepts more of those requests, but it lets abandoned work pile up under load. Set the budget from the latency target first, then adjust it using observed tail latency.
Impact on development cost and engineering time
The upfront cost is spread across the whole fleet. Every service has to read the incoming deadline, subtract its own work, and pass the remainder on. Every proxy has to forward the deadline instead of stripping it. Every client library has to turn it into a per-call timeout and cancel the call when it expires. A single component that drops the deadline restores independent timeouts for everything downstream of it, so broad coverage matters more than a polished implementation in a few services.
Testing also gets harder. Behavior now depends on how much time is left when a request arrives, so a call can pass in isolation and fail in production behind a slow upstream. These failures depend on timing rather than input, which makes them difficult to reproduce.
The return shows up mostly during incidents. When every hop logs the budget it received and the time it spent, you can trace a timeout to the hop that consumed the budget. That replaces comparing timeout configs across teams. Retry logic also consolidates: a retry is allowed only if the remaining budget covers another attempt. This replaces per-service retry counts that multiply across layers. Slow dependencies surface earlier, as shrinking remaining budget on dashboards, before they cause outright timeouts.
An incremental rollout
- Assign deadlines at the edge, then propagate them along the hottest call paths first, where cascading timeouts cost the most.
- Implement propagation once, in shared middleware or the common client library, rather than separately in each service.
- Use fault injection to verify the behavior. Add latency to a downstream dependency and confirm that callers cancel, stop retrying, and return errors within the budget.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to microservice timeout budget, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.