← Concepts & practices
Pattern Concurrency, scheduling, and delivery

Timeouts, deadlines & races

Which clock wins?

A request crosses a gateway, inventory, and pricing. Each hop can be healthy while the user still waits too long. A local timeout protects one hop. A deadline carries one end-to-end budget. A race chooses between competing outcomes. These are related tools, but they answer different questions and leave different losers to clean up.

The judgment to keep

A timeout is usually a local duration. A deadline is an absolute end-to-end point. A race is a competing-outcome policy. Whichever clock or result wins, explicitly stop or observe the work that lost.

TypeScriptGo One 100 ms request budget · three timing policies
Start with the budget

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
timing.ts · local timeout
// 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.

Compare the clocks

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.

Clock 01

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.
Clock 02

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.
Clock 03

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.
What each timing policy promises
PolicyClockWinnerStill needs
Local timeoutRelative to this hopWork or local timerCleanup and a downstream signal
DeadlineOne absolute end timeWork before remaining budget endsPropagation and clock interpretation
RaceEach competitor’s settlementFirst settlement or first successStop or join the losing work
TypeScript · remaining deadline
timing.ts · propagated deadline
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));
}
TypeScript · first-success race
timing.ts · first-success race
// 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
timing.go · context clocks and a race
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.

Follow the time

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.

One 100 ms budget

Change the clock. Watch the boundary move.

Runs a local comparison
Local timeout per hop Budget finishes first
01start hop timer
02work or timer settles
03next hop may get a fresh timer
Budget

Each hop gets its own 100 ms timer.

Clock

Relative time resets when the next hop starts.

Result

The current hop times out, but earlier hops already spent their budgets.

Cleanup

Clear the timer or abort the hop when either outcome wins.

Use a local timeout for a hop’s own resource budget, not as an end-to-end promise.

Watch for Three 100 ms hop timers can spend roughly 300 ms end to end.

The controls change a local model; they do not wait or make network calls.
Read the complete comparisonTypeScript and Go · same 100 ms budget

Local boundary: one hop receives one timeout and a stoppable signal.

TypeScriptReading
timing.ts
// 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();
	}
}
GoAlongside
timing.go
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)
}
Name the timing contract

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.

A request has 250 ms total, and it calls two services in sequence.
A fetch should return within 2 seconds, and the request must stop using the connection after that.
Two replicas race; the first successful response wins.
A Go handler receives a context with an upstream deadline.
Feedback stays on this page; it is not saved.
A production boundary

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.

Budget

Who sets the clock?

Start from the caller’s or upstream deadline.

Propagation

What does each hop receive?

Pass the deadline, remaining time, signal, or context.

Cleanup

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.

ReactAlready in your code
textbook.tsx · one request budget
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.

Recognize it in UI code

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.

Pair timing with cancellation propagation ↗
The parts to watch

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.

Make the call

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.

One hop

Set a local timeout.

Protect this operation without pretending it is the whole request.

One request

Propagate a deadline.

Let every downstream hop spend only what remains.

Competing outcomes

Race with cleanup.

Choose the right winner and own whatever loses.

Take the idea with you

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
Explore more concepts & practices →