Three healthy hops can still miss one user promise.
Imagine a request with 100 ms total. The gateway spends 45 ms, inventory spends 45 ms, and pricing needs another 45 ms. If each service receives a fresh 100 ms timeout, every hop can say “on time” while the user waits 135 ms. The local timers protect the hops, not the request.
A propagated deadline fixes the clock: the parent sets an absolute end time and each child computes how much remains. A timeout race answers a different question: did the work, a timer, or another candidate settle first? It does not make the losing Promise, fetch, or goroutine vanish.
Do not ask only “did this call time out?” Ask “whose budget was it, what outcome won, and what is still running?”
Read one local timeoutTypeScript · race the work, then abort it
// A timeout is a local budget. The controller makes the losing work stoppable.
export async function withTimeout<T>(
work: (signal: AbortSignal) => Promise<T>,
budgetMs: number
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new TimeoutError(budgetMs));
}, budgetMs);
});
try {
return await Promise.race([work(controller.signal), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
controller.abort();
}
} The timeout function gives one hop a duration and a signal. The signal is the important part: it gives the losing operation a way to stop. A rejected timeout alone would only change the caller’s result.
Same work, different answers to “when is it too late?”
A local timeout starts a new clock at the current boundary. A deadline travels as a timestamp; children may shorten it but should not extend it. A race starts more than one possible outcome and returns the first settlement or first success according to the chosen combinator.
Local timeout
Protect one hop with a relative duration.
- Budget
- Starts here.
- Pass
- Signal or context to the operation.
- Risk
- Every hop can reset it.
Absolute deadline
Carry one end-to-end point through the call chain.
- Budget
- Set once by the parent.
- Pass
- Timestamp or remaining duration.
- Risk
- Clock skew and swallowed cancellation.
Outcome race
Let work, timeout, or a replica compete to settle first.
- Budget
- One competitor among others.
- Pass
- Winner and loser ownership.
- Risk
- The loser keeps running.
| Policy | Clock | Winner | Still needs |
|---|---|---|---|
| Local timeout | Relative to this hop | Work or local timer | Cleanup and a downstream signal |
| Deadline | One absolute end time | Work before remaining budget ends | Propagation and clock interpretation |
| Race | Each competitor’s settlement | First settlement or first success | Stop or join the losing work |
export function remaining(deadlineMs: number, nowMs: number): number {
return Math.max(0, deadlineMs - nowMs);
}
export function withDeadline<T>(
work: (signal: AbortSignal, deadlineMs: number) => Promise<T>,
deadlineMs: number,
nowMs = Date.now()
): Promise<T> {
return withTimeout((signal) => work(signal, deadlineMs), remaining(deadlineMs, nowMs));
} // Promise.any chooses the first success; aborting in finally gives the losers a lifetime owner.
export async function firstSuccess<T>(
works: Array<(signal: AbortSignal) => Promise<T>>
): Promise<T> {
const controller = new AbortController();
try {
return await Promise.any(works.map((work) => work(controller.signal)));
} finally {
controller.abort();
}
} Read Go’s context clocksGo · WithTimeout, WithDeadline, and select
func withLocalTimeout(parent context.Context, budget time.Duration, work func(context.Context) error) error {
ctx, cancel := context.WithTimeout(parent, budget)
defer cancel()
return work(ctx)
}
func withDeadline(parent context.Context, deadline time.Time, work func(context.Context) error) error {
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
return work(ctx)
}
func raceWork(parent context.Context, budget time.Duration, work func(context.Context) error) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
result := make(chan error, 1)
go func() { result <- work(ctx) }()
timer := time.NewTimer(budget)
defer timer.Stop()
select {
case err := <-result:
return err
case <-timer.C:
cancel()
<-result
return context.DeadlineExceeded
}
}
func remaining(deadline, now time.Time) time.Duration {
if deadline.Before(now) {
return 0
}
return deadline.Sub(now)
} Go puts the budget in the context tree. A child context can expire earlier than its parent
but should not silently outlive the upstream deadline. A select over the work
and ctx.Done() is the observation point where the child accepts the budget.
Change the clock. Watch the outcome.
Choose a timing policy and decide which event arrives first. The lab keeps the budget and the three-hop story fixed so you can see whether time resets, travels, or competes.
Change the clock. Watch the boundary move.
Each hop gets its own 100 ms timer.
Relative time resets when the next hop starts.
The current hop times out, but earlier hops already spent their budgets.
Clear the timer or abort the hop when either outcome wins.
Watch for Three 100 ms hop timers can spend roughly 300 ms end to end.
Read the complete comparisonTypeScript and Go · same 100 ms budget
Local boundary: one hop receives one timeout and a stoppable signal.
// A timeout is a local budget. The controller makes the losing work stoppable.
export async function withTimeout<T>(
work: (signal: AbortSignal) => Promise<T>,
budgetMs: number
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new TimeoutError(budgetMs));
}, budgetMs);
});
try {
return await Promise.race([work(controller.signal), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
controller.abort();
}
} func withLocalTimeout(parent context.Context, budget time.Duration, work func(context.Context) error) error {
ctx, cancel := context.WithTimeout(parent, budget)
defer cancel()
return work(ctx)
} Choose the clock, winner, and cleanup.
Practice telling a hop budget from a request budget and a result race from a timeout mechanism. The useful answer says what the caller may wait for and what the losing operation must do.
Propagate the budget beside the work it governs.
At the edge of a request, choose the total budget from the user or upstream contract. Pass an absolute deadline or cancelable context down the call chain. Each service may reserve time for its own work, but it must derive that time from what remains rather than starting over.
When a timer or replica wins, return the right public outcome (timeout, unavailable, or a healthy fallback), and then own the loser. Abort the fetch, cancel the context, close the stream, or await the child’s cooperative cleanup. A timeout error without a resource policy is only half a design.
Who sets the clock?
Start from the caller’s or upstream deadline.
What does each hop receive?
Pass the deadline, remaining time, signal, or context.
Who owns the loser?
Stop, drain, or join work after the timeout or winner.
An account summary owns one 1500 ms effect budget and aborts both child requests on cleanup.
import { useEffect, useState } from 'react';
type SummaryState =
| { status: 'loading' }
| { status: 'ready'; value: unknown }
| { status: 'timed-out' }
| { status: 'failed'; message: string };
async function loadSummary(accountId: string, signal: AbortSignal) {
const [profile, orders] = await Promise.all([
fetch(`/api/accounts/${accountId}`, { signal }),
fetch(`/api/accounts/${accountId}/orders`, { signal })
]);
if (!profile.ok || !orders.ok) throw new Error('summary dependency failed');
return { profile: await profile.json(), orders: await orders.json() };
}
export function Summary({ accountId }: { accountId: string }) {
const [state, setState] = useState<SummaryState>({ status: 'loading' });
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 1500);
let current = true;
setState({ status: 'loading' });
void loadSummary(accountId, controller.signal).then(
(value) => current && setState({ status: 'ready', value }),
(error: unknown) => {
if (!current) return;
setState(
controller.signal.aborted
? { status: 'timed-out' }
: { status: 'failed', message: String(error) }
);
}
);
return () => {
current = false;
window.clearTimeout(timer);
controller.abort();
};
}, [accountId]);
return <SummaryView state={state} />;
}
Build services or UIs?A deadline is part of the request contract, not an afterthought.
Where it already is in your components
A fetch signal, a Go context, a database command timeout, and a retry budget all carry a version of “not after this point.” Inspect whether downstream code receives the same clock or quietly starts a new one.
When you have to own it
When work can outlive the caller, race another attempt, or consume a scarce connection, own the timer and the losing operation together. Record whether the result was a timeout, a dependency failure, or an intentional fallback.
Loading state is a clock with a visible face.
One effect budget
Give the render-owned request one controller and one total timer.
Stale result
Abort or ignore work when the component’s input changes before it settles.
Fallback race
Choose whether a fallback wins on first settlement or only after the primary fails.
The timer can win while the work keeps going.
Promise.race does not cancel
A timeout Promise only settles the race. The fetch or computation in the other branch can keep running and later produce side effects. Use an AbortController or a structured owner for the loser.
Fresh timeouts extend the request
A service that gives every downstream call a new duration can exceed the upstream promise. Carry the absolute deadline and calculate remaining time at every hop.
Deadline propagation is not clock synchronization
Across machines, use a protocol that handles clock differences and transport overhead. A child should never turn an expired upstream budget into a new full budget because its local clock says otherwise.
Timeout is not the same as failure
A timeout says the caller stopped waiting within its budget. The remote operation may have committed, may still be running, or may never have started. If you retry a write, pair the timeout with idempotency and uncertainty handling.
Use the clock that matches the promise.
Use a local timeout for one operation’s resource budget. Use a propagated deadline when the whole request has one end-to-end limit. Use a race when a timer, fallback, or replica is a competing outcome, and define what happens to the loser. In all three cases, make cancellation and cleanup observable.
Set a local timeout.
Protect this operation without pretending it is the whole request.
Propagate a deadline.
Let every downstream hop spend only what remains.
Race with cleanup.
Choose the right winner and own whatever loses.
Make time a value that travels.
A timeout tells one hop when to stop waiting. A deadline tells a call chain when the request is no longer useful. A race tells competing work which outcome wins. The design is complete when the clock, the public result, and the losing work agree.
- Why
- Make the caller’s waiting promise and resource boundary explicit.
- What
- Choose a local timeout, shared deadline, or outcome race.
- Constraint
- Propagate one budget through the call chain and clean up the losing work.
- Fallback
- When the budget runs out, return the timeout result and abort the loser. Aggressive budgets create retries, so measure before tightening them.
- Reconsider when
- The work needs a durable owner or a retry-safe write contract.
Connections to follow nextRelated lessons
- Bounded parallelism when concurrency changes how much work can consume the budget.
- Cancellation propagation when the expired budget has to stop the children.
- Retry, backoff & idempotency when a timeout leaves a write uncertain.