2 min read
fetchWithTimeout
A fetch wrapper that gives up after a timeout, with a clear error and optional retries.
- ts
fetch never gives up on its own. If a server hangs, your request hangs with it, and so does the spinner waiting on it. This wrapper adds a timeout and a clear error, and it still respects an abort signal you pass in yourself.
ts
export class TimeoutError extends Error {
constructor(ms: number) {
super(`Request timed out after ${ms}ms`);
this.name = 'TimeoutError';
}
}
export async function fetchWithTimeout(
input: RequestInfo | URL,
{ timeout = 8000, signal, ...init }: RequestInit & { timeout?: number } = {}
) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new TimeoutError(timeout)), timeout);
// Also abort when the caller's own signal does.
const forward = () => controller.abort(signal?.reason);
if (signal?.aborted) forward();
else signal?.addEventListener('abort', forward, { once: true });
try {
return await fetch(input, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', forward);
}
}What the timeout covers
The timer stops as soon as the response headers arrive, so it guards against a server that never answers, not against a slow download of a large body.
Usage
ts
try {
const res = await fetchWithTimeout('/api/posts', { timeout: 5000 });
const posts = await res.json();
} catch (error) {
if (error instanceof TimeoutError) {
// The server took too long: offer a retry button.
}
}With retries
For flaky networks, wrap it in a loop that retries on failures and server errors, waiting a little longer each time (300ms, then 600ms, and so on):
ts
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function fetchWithRetry(
input: RequestInfo | URL,
{ retries = 2, ...init }: RequestInit & { timeout?: number; retries?: number } = {}
) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetchWithTimeout(input, init);
if (res.status < 500 || attempt >= retries) return res;
} catch (error) {
// Stop on the last attempt, or when the caller cancelled on purpose.
if (attempt >= retries || init.signal?.aborted) throw error;
}
await sleep(2 ** attempt * 300);
}
}