Timeouts
The problem with waiting forever
Our payment service usually responds in 200 milliseconds. But today something is wrong. It is taking 30 seconds. What happens to our server while it waits?
The request is holding a connection open. It is using memory. If there is a database transaction involved, it is holding a lock. Now ten more requests come in and all of them hit the same slow payment service. Ten connections held open. Ten chunks of memory consumed. Ten potential database locks. New requests start failing because the server is running out of resources.
One slow dependency just took down the entire application. Not because it failed, but because it was slow and we waited for it.
This is why every external call needs a timeout. Never wait forever.
Setting timeouts on fetch
The standard way to add a timeout to a fetch call is with AbortController:
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs: number = 5000,
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
} Here is what each piece does. AbortController creates a signal that can be used to cancel an operation. We start a setTimeout that calls controller.abort() after timeoutMs milliseconds. We pass controller.signal to fetch, which tells fetch to listen for the abort signal.
If fetch completes before the timeout, we return the response and clear the timeout in the finally block (so it does not fire after we are done).
If the timeout fires first, controller.abort() causes fetch to throw an AbortError. We catch it and throw a more descriptive error that includes the URL and timeout duration. If fetch fails for some other reason (network error, DNS failure), we let that error through unchanged.
Wrapping any async operation
AbortController works with fetch, but what about other async operations? Our simulated chargeCard function is not a fetch call. We need a general-purpose timeout wrapper:
// src/timeout.ts
export async function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
fn()
.then((result) => {
clearTimeout(timeoutId);
resolve(result);
})
.catch((err) => {
clearTimeout(timeoutId);
reject(err);
});
});
}
// Usage
const charge = await withTimeout(() => chargeCard(99.99, "tok_123"), 5000, "Payment processing"); This function takes three arguments: fn is the async operation to run, timeoutMs is how long to wait, and label is a human-readable name for the operation (used in the error message).
It creates a new promise that wraps the original operation. A setTimeout sets up the timeout. If the timeout fires first, it rejects the promise with a descriptive error. If the operation completes first (success or failure), it clears the timeout and resolves or rejects normally.
[!WARNING] Timeouts do not cancel the underlying operation. If
chargeCardtakes 30 seconds, the timeout fires after 5 seconds and your code handles the timeout error. ButchargeCardkeeps running for another 25 seconds in the background. For operations with side effects (like charging a credit card), this means the charge might still succeed after you have told the user it failed. Use idempotency keys to handle this safely.
This is an important point that catches people off guard. A timeout is a local decision: your code stops waiting. The underlying operation continues on the remote service. The payment might still go through. This is why idempotency keys (from the REST API Design course) are so important when combining timeouts with payment processing.
Choosing timeout values
How long should a timeout be? Too short and you get false failures. The service responded in 800ms but your timeout was 500ms, so you retried unnecessarily or returned an error for a request that would have succeeded. Too long and you waste resources. A 60-second timeout holds a connection and memory for a full minute while other requests queue behind it.
Here are some reasonable starting points:
- External API calls: 5-10 seconds
- Database queries: 2-5 seconds
- Internal service calls: 1-3 seconds
- User-facing endpoints (total): 10-30 seconds
The best approach is to measure your dependencies’ actual response times and set the timeout to 2-3x the 99th percentile (P99) latency. If your payment service responds in under 500ms 99% of the time, a 1.5 second timeout is reasonable.
Combining timeouts with retries
Timeouts and retries work together naturally. Each retry attempt gets its own timeout:
const charge = await withRetry(
() =>
withTimeout(() => chargeCard(99.99, "tok_123", { idempotencyKey: orderId }), 5000, "Payment"),
{ maxRetries: 3, baseDelayMs: 1000 },
); Read this from the inside out. chargeCard is called with an idempotency key (so retrying is safe, as we covered in the previous lesson). If it takes longer than 5 seconds, withTimeout rejects. That rejection is caught by withRetry, which waits with exponential backoff and tries again. Each attempt has its own 5-second timeout. After 3 failed attempts, the error propagates to the caller.
This combination handles two failure modes: the service being slow (timeout catches it) and the service failing briefly (retry handles it).
Exercises
Exercise 1: Implement fetchWithTimeout. Call a slow endpoint (add a setTimeout to a test route). Verify the timeout fires.
Exercise 2: Implement withTimeout for arbitrary async operations. Wrap chargeCard with a 1-second timeout.
Exercise 3: Combine withRetry and withTimeout. Set a short timeout (500ms) with 3 retries. Verify the operation retries on timeout.
Timeouts and retries handle individual failures. But what happens when a service is completely down? Every request tries, waits for the timeout, retries, and eventually fails. That is a lot of wasted time. Next, we will build circuit breakers, which detect that a service is down and stop calling it entirely.
Why does a timeout not cancel the underlying operation?