XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

Timeouts Need a Deadline, Not Just a Number

Jul 23, 2026 · 8 min read

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.

traceview://deadline
policy fresh 2.5s clock for every task
elapsed 0.0s
A
profile
runs 2.5s
B
pricing
runs 2.5s
C
recommendations
runs 2.5s
result deadline missed by 2.5s

Each dependency starts a new allowance, so the operation can take three times longer than intended.

Three independent 2.5-second timeouts turn one request into a 7.5-second wait.

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.

traceview://deadline-shared
policy fresh 2.5s clock for every task
elapsed 0.0s
A
profile
runs 2.5s
B
pricing
runs 2.5s
C
recommendations
runs 2.5s
result deadline missed by 2.5s

Each dependency starts a new allowance, so the operation can take three times longer than intended.

A shared five-second deadline stops the operation on time, but one slow task can still starve the work behind it.

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.

traceview://deadline-capped
policy fresh 2.5s clock for every task
elapsed 0.0s
A
profile
runs 2.5s
B
pricing
runs 2.5s
C
recommendations
runs 2.5s
result deadline missed by 2.5s

Each dependency starts a new allowance, so the operation can take three times longer than intended.

Per-task caps stop one dependency consuming the shared budget and leave half a second for recovery.

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.

traceview://deadline-parallel
policy fresh 2.5s clock for every task
elapsed 0.0s
A
profile
runs 2.5s
B
pricing
runs 2.5s
C
recommendations
runs 2.5s
result deadline missed by 2.5s

Each dependency starts a new allowance, so the operation can take three times longer than intended.

Independent tasks finish in the time taken by the slowest task, while the shared deadline still bounds the operation.

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.