[readonly] markdown buffer
Timeouts Need a Deadline, Not Just a Number
Adding a timeout looks like responsible engineering.
await fetchCustomer({ timeout: 2_500 });
await fetchOrders({ timeout: 2_500 });
await fetchRecommendations({ timeout: 2_500 });
Each call has a 2.5-second timeout. The request can still take 7.5 seconds.
Retries, fallbacks and nested services make it worse. Every layer starts a fresh clock while the caller's patience continues to expire.
Each dependency starts a new allowance, so the operation can take three times longer than intended.
The system has plenty of timeouts and no shared understanding of when the work must finish.
A timeout belongs to one operation
A timeout answers:
How long may this attempt run?
A deadline answers:
When must the whole operation be finished?
That difference matters whenever work crosses more than one boundary.
Suppose an HTTP request has five seconds to produce a response. One dependency may use at most 2.5 seconds. If earlier work consumes three seconds, the next dependency does not receive a fresh 2.5 seconds. It receives at most the two seconds left, minus enough time to handle the result.
The local cap protects one task. The shared deadline protects the operation.
Each dependency starts a new allowance, so the operation can take three times longer than intended.
Pass the remaining budget
A deadline can be represented as one absolute point in time:
class Deadline {
constructor(private readonly expiresAt: number) {}
static after(milliseconds: number): Deadline {
return new Deadline(Date.now() + milliseconds);
}
remaining(): number {
return Math.max(0, this.expiresAt - Date.now());
}
expired(): boolean {
return this.remaining() === 0;
}
}
Create a five-second deadline at the edge of the operation and pass it down:
const deadline = Deadline.after(5_000);
async function buildAccountView(
accountId: string,
deadline: Deadline,
): Promise<AccountView> {
const account = await loadAccount(accountId, deadline);
const orders = await loadOrders(accountId, deadline);
if (deadline.remaining() < 250) {
return { account, orders, recommendations: [] };
}
const recommendations = await loadRecommendations(
accountId,
deadline,
);
return { account, orders, recommendations };
}
Each dependency receives the time that remains, not a renewed allowance.
Leave time to recover
Consuming the entire deadline in a downstream call is rarely useful.
The caller may still need to parse the response, release resources, record an outcome or return a controlled fallback. Reserve part of the budget for that work.
const remainingForWork = Math.max(0, deadline.remaining() - 150);
const timeout = Math.min(2_500, remainingForWork);
const signal = AbortSignal.timeout(timeout);
A sub-timeout and a deadline belong together. The 2.5-second cap prevents one dependency consuming the whole request. The five-second deadline prevents several individually valid attempts consuming more time than the caller allowed.
Each dependency starts a new allowance, so the operation can take three times longer than intended.
A timeout should also cancel the work where possible. Promise.race() can stop waiting, but it does not stop the losing promise from continuing in the background. Propagate an AbortSignal or the cancellation mechanism supported by the dependency.
Run independent work together
Not every task has to wait for the previous one. If the inputs are independent, start them together and give them the same deadline and local cap.
const [account, orders, recommendations] = await Promise.all([
loadAccount(accountId, deadline),
loadOrders(accountId, deadline),
loadRecommendations(accountId, deadline),
]);
This is not a licence to parallelise dependent work. It is a way to stop independent latency accumulating.
Each dependency starts a new allowance, so the operation can take three times longer than intended.
Retries spend the same budget
Retries do not earn more time.
Before another attempt, consider the remaining deadline, the expected duration and any backoff. If there is not enough time for a meaningful attempt, fail or use the fallback now.
if (deadline.remaining() < expectedAttemptMs + recoveryMs) {
throw new DeadlineExceededError();
}
This also makes retry policy more honest. Three attempts may be configured, but the operation performs only the attempts that fit inside its promise to the caller.
One clock tells the truth
Individual timeouts still matter. They protect sockets, queries and attempts from hanging forever. Treat them as local caps inside the shared budget, not as fresh budgets of their own.
Set one deadline at the boundary. Pass the remaining budget through every layer. Reserve time to recover, and cancel work when the budget is gone.
A system with ten timeout values can still wait far too long.
A system with one shared deadline knows when it is finished.