Design patternResilience Patterns
Timeout
Give every remote call a deadline shorter than the caller's own budget, cancel the work when it expires, and surface the timeout as a distinct error.
- Complexity
- Low
- Seen in the wild
- High
- Also known as
- Deadline
Intent
Every call that crosses a process boundary gets a maximum wait. When the deadline passes, the call fails with a distinct timeout error and the resources it held are released. The caller then decides: retry, fall back, or report. Without a deadline, a hung dependency converts your healthy process into a parked one.
Forces
- Unbounded waits vs finite capacity: a worker blocked forever on a dead socket is a worker you have lost, and the queue behind it grows.
- One hung dependency vs whole-system failure: without deadlines, slowness propagates upstream until every caller of the slow one is stuck.
- Callee's design vs caller's budget: the dependency may be built for 30-second responses while your user journey allows 2.
- Cancel vs abandon: cancelling stops the downstream work; abandoning only stops waiting while the work keeps running and consuming.
Therefore
Set an explicit deadline on every remote or expensive call, shorter than the caller's own budget, cancel the underlying work when it expires, and raise a distinct timeout error.
The deadline is a property of the caller, not of the dependency: each layer subtracts its own overhead from what it was given and passes the remainder down. A timeout is not a fix for slowness - it converts slow failure into fast failure so the rest of the system can react.
Structure
Diagram source (Mermaid)
classDiagram
class QuoteSource {
<<interface>>
+quote(postcode, signal) Number
}
class RemoteQuotes {
+quote(postcode, signal) Number
}
class BoundedQuotes {
-Duration limit
+quote(postcode, signal) Number
}
QuoteSource <|.. RemoteQuotes
QuoteSource <|.. BoundedQuotes
BoundedQuotes --> QuoteSource : innerParticipants
- Deadline holder (
BoundedQuotes): starts a timer per call, and cancels the call when it fires. A thin wrapper - the same shape as a Decorator. - Cancellable operation (
QuoteSource): every layer accepts a cancellation token (abort signal, context, interrupt flag) so the deadline can reach the actual I/O. - Dependency (
RemoteQuotes): does the work and honours the cancellation signal. - Caller: receives
TimeoutErroras a type it can branch on.
Use it when
- A call crosses a network, another process, or any resource with its own latency profile: HTTP, RPC, database, queue, file system.
- The caller has its own budget: a user is waiting, a request deadline exists, or a job has a
maxDuration. - You are about to add retries: each attempt needs a deadline, or one hung attempt hangs the whole retry loop.
- Code calls a library whose defaults are absent or generous (no socket timeout, 60-second HTTP client default).
Do not use it when
- The call is in-process and cannot hang. A function that throws on bad input needs no deadline.
- The operation is legitimately long-running. Model it as an async job with status polling instead of a 30-second synchronous call you will inevitably cancel.
- The platform already propagates deadlines (gRPC deadline propagation, Go
context.Context, .NETCancellationToken). Use the platform mechanism; do not build a parallel timer layer next to it. - You are using a timeout to excuse a slow dependency. It stops the bleeding; capacity work or a Fallback fixes the experience.
- Every caller would pick the same number and the client library exposes one cleanly. Configure the library's timeout option instead of wrapping each call site.
Application grill
Inspect first: every remote call's deadline today (HTTP clients default to none or minutes - find each one); the platform's own limits (serverless execution caps, load balancer idle timeouts, database statement_timeout); the caller's budget chain, since every timeout must be shorter than its caller's remaining budget.
Missing pieces: none to buy: AbortController, context deadlines, and statement timeouts are platform features. The infrastructure work is alignment - setting database, proxy and load balancer timeouts to agree with the code's, so no layer quietly outlives another.
Ask before you build:
- Scale: what per-dependency latency budget fits the workload - base deadlines on measured p99, not hope?
- Performance: does cancellation propagate - when a parent times out, do child calls abort (context linking)?
- Reliability: on timeout, is the server-side work cancelled or orphaned (zombie requests keep spending)?
- Budget: a timeout times a retry multiplies worst-case latency - who owns the total budget per request?
- Implementation: is a timeout a distinct error type callers can classify, or an indistinguishable failure?
Implementation steps
- Inventory the remote calls in the path. Note which have explicit timeouts today; most have none or an OS default measured in minutes.
- Derive each deadline from the caller's budget: if the request may take 2 s and makes three calls, none may take more than its share. Write the number down next to the call.
- Use the platform's cancellation primitive -
AbortSignal,context.Context,CancellationToken,Future.get(timeout)- so expiry cancels the work instead of merely abandoning it. - Raise a distinct error type (
TimeoutError,context.DeadlineExceeded) so callers can distinguish "slow" from "broken" from "rejected". - Propagate remaining time downstream: pass the context or deadline through, so a dependency cannot outlive its caller's budget.
- Test the timeout path with a fake dependency that sleeps past the deadline, and assert the caller recovers.
Example
A checkout service asks a shipping API for delivery quotes. The API usually answers in 20 ms, but when its database stalls it answers in never - and without a deadline, checkout workers pile up behind it.
// Distinct type so callers can branch on "slow" without string matching.
class TimeoutError extends Error {
constructor(ms: number) {
super(`no answer within ${ms} ms`);
this.name = "TimeoutError";
}
}
interface QuoteSource {
quote(postcode: string, signal: AbortSignal): Promise<number>;
}
// The dependency: a shipping API that hangs when its database stalls.
class RemoteQuotes implements QuoteSource {
quote(postcode: string, signal: AbortSignal): Promise<number> {
const hang = postcode === "XXXXX";
// Real client: fetch(url, { signal }). Fake: a timer that honours the signal.
return new Promise((resolve, reject) => {
const done = setTimeout(() => resolve(4.99), hang ? 10_000 : 20);
signal.addEventListener(
"abort",
() => {
clearTimeout(done);
reject(signal.reason ?? new Error("cancelled"));
},
{ once: true },
);
});
}
}
// The deadline: each call gets its own abort controller with a timer.
function withTimeout(inner: QuoteSource, ms: number): QuoteSource {
return {
quote(postcode) {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new TimeoutError(ms)),
ms,
);
return inner
.quote(postcode, controller.signal)
.finally(() => clearTimeout(timer));
},
};
}
const quotes = withTimeout(new RemoteQuotes(), 500);
const fast = await quotes.quote("90210");
console.log(`quote: $${fast.toFixed(2)}`); // quote: $4.99
try {
await quotes.quote("XXXXX"); // hangs; the deadline fires at 500 ms
} catch (e) {
console.log(`${(e as Error).name}: ${(e as Error).message}`);
// TimeoutError: no answer within 500 ms
}Go needs no wrapper at all when callers create their own contexts: context.WithTimeout at the call site is the pattern. The wrapper earns its place when you want one number to apply to every caller of a client.
Trade-offs
Gains
- Latency becomes bounded and predictable; capacity planning gets possible.
- Hung dependencies release workers instead of accumulating them.
- Slow and broken become distinguishable errors.
Costs
- A call that would have succeeded at 501 ms is cancelled at 500 ms and its work is wasted.
- Too-low deadlines convert a slow dependency into a failing one.
- Cancellation is per language and per client; where the primitive is weak (threads that ignore interrupts), you abandon instead of cancel and the work still runs.
Relations
- Retry repeats the operation - but every attempt must carry its own timeout, or one hung attempt hangs the loop.
- Circuit Breaker counts failures to decide when to stop calling; timeouts must be classified as failures or hangs never trip it.
- Exponential Backoff spaces re-attempts; the timeout bounds each attempt within the schedule.
- Fallback is what the caller serves when the deadline expires with no answer.
- Decorator is the shape the wrapper takes: same interface, added behaviour, composed at the call site.
Agent checklist
- Every remote call in the changed path has an explicit, written-down deadline.
- The deadline is shorter than the caller's own budget, including downstream calls it triggers.
- Expiry cancels the underlying work through the platform primitive, not just the wait.
- Timeout is a distinct error type callers branch on, not a generic
Error. - The timeout path has a test with a dependency that sleeps past the deadline.
See also
- RetryRe-attempt only retryable, repeatable operations a bounded number of times, honour Retry-After, check cancellation between attempts, and surface the last error.
- Circuit BreakerCount consecutive failures to a dependency, refuse calls while it looks broken, and let one probe test recovery - so a dead service cannot eat its callers.
- Exponential BackoffSpace repeat attempts at base times two raised to the attempt, capped, with random jitter - so retries spread out instead of arriving in a synchronized herd.
- FallbackReturn a pre-decided lesser answer when a dependency cannot - stale cache, static default, partial result - so the journey continues, marked as degraded.
- DecoratorWrap an object in another object with the same interface that adds behavior before or after delegating, so features stack without subclassing.
Original writing and examples. Topic coverage follows Microsoft Azure Architecture Center, which explains this topic with its own illustrations.