Handling API timeouts and retries without making things worse
fetch has no default timeout, so a hung request waits forever. Here is how to set one properly, and which failures are safe to retry without duplicating work.
This request can hang for minutes:
const response = await fetch('https://api.example.com/data');fetch has no timeout in the specification. Browsers apply their own ceiling, often around five minutes, and Node behaves similarly. If the server accepts your connection and then goes quiet, your code waits.
Short answer
Set an explicit timeout with AbortSignal.timeout(). Choose the value from the API's measured latency rather than a guess. Retry only failures that retrying can fix, meaning timeouts, network errors, 5xx, 408 and 429. Never blind-retry a POST, because a timeout does not tell you whether the server already processed it.
Setting a timeout
The modern approach is one line:
const response = await fetch(url, {
signal: AbortSignal.timeout(8000),
});When the deadline passes, the fetch rejects with a TimeoutError. Distinguish it from other failures:
try {
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
return await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
throw new Error('The API did not respond within 8 seconds');
}
if (error.name === 'AbortError') {
return; // our own code cancelled; not an error
}
throw error;
}If you also need to cancel manually, for example when a component unmounts, combine the two signals:
const controller = new AbortController();
const response = await fetch(url, {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]),
});
// Later: controller.abort();Choosing the number
Guessing produces two failure modes: too short and you abandon requests that would have succeeded, too long and your users stare at a spinner.
Measure instead. We record response times for every API in the catalogue, which makes the decision concrete:
A reasonable rule: take the typical response time, multiply by four to five, and floor it at about 3 seconds. That tolerates normal variance while still failing fast when something is genuinely wrong.
| Request type | Suggested timeout |
|---|---|
| Interactive, user is waiting | 5 to 8 seconds |
| Background job | 30 seconds |
| Health check | 2 to 3 seconds |
| File upload or report generation | 60 seconds or more, ideally async |
What is safe to retry
This is where retry logic goes wrong. The question is not "did it fail" but "can the same request be sent again without causing harm".
Safe by definition. GET, HEAD, PUT and DELETE are idempotent: sending them twice has the same effect as sending them once. Retry freely.
Not safe by default. POST usually creates something. A timeout leaves you genuinely unable to tell whether the resource was created, so retrying risks a duplicate order, payment or record.
Made safe with an idempotency key. Many APIs that handle money support this:
await fetch('https://api.example.com/charges', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(), // reuse this exact value on retry
},
body: JSON.stringify({ amount: 2000 }),
});The critical detail is generating the key once per logical operation, not once per attempt. Reusing it on the retry is what lets the server recognise the duplicate and return the original result.
What to retry, by status
| Outcome | Retry? | Why |
|---|---|---|
| Timeout, network error | Yes, if idempotent | Likely transient |
| 408 Request Timeout | Yes | Explicitly about timing |
| 429 Too Many Requests | Yes, after Retry-After | Explicitly about timing |
| 500, 502, 503, 504 | Yes | Server-side, often transient |
| 400, 422 | No | Malformed; will fail identically |
| 401, 403 | No | Credentials or permissions |
| 404 | No | Resource does not exist |
| 409 Conflict | No | State conflict needing a decision |
Retrying a 400 four times turns one failure into four, consumes quota, and delays the error your user sees.
Putting it together
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
type Options = {
timeoutMs?: number;
maxAttempts?: number;
/** Only set true for requests that are safe to send twice. */
idempotent?: boolean;
};
export async function resilientFetch(
url: string,
init: RequestInit = {},
{ timeoutMs = 8000, maxAttempts = 3, idempotent = true }: Options = {},
): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(timeoutMs),
});
if (!RETRYABLE_STATUS.has(response.status)) return response;
if (!idempotent || attempt === maxAttempts - 1) return response;
await sleep(delayFrom(response, attempt));
} catch (error) {
lastError = error;
// Our own cancellation is not a failure to retry.
if ((error as Error).name === 'AbortError') throw error;
if (!idempotent || attempt === maxAttempts - 1) break;
await sleep(backoff(attempt));
}
}
throw lastError ?? new Error(`Failed after ${maxAttempts} attempts`);
}
function delayFrom(response: Response, attempt: number): number {
const retryAfter = Number(response.headers.get('retry-after'));
return Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, 30_000)
: backoff(attempt);
}
/** Exponential with full jitter, so simultaneous failures do not retry in unison. */
function backoff(attempt: number): number {
return Math.random() * Math.min(500 * 2 ** attempt, 30_000);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));Note idempotent defaults to true because most calls are GETs, and that writes must opt out explicitly. Making the dangerous case the one you have to type is worth the small awkwardness.
Retries multiply your timeout budget
Three attempts at 8 seconds each, plus backoff, is potentially half a minute before your user sees anything. If they are waiting, that is far too long.
Cap the total rather than only the individual attempt:
const deadline = AbortSignal.timeout(12_000); // whole operation
await resilientFetch(url, { signal: deadline }, { timeoutMs: 4000, maxAttempts: 3 });Now each attempt gets 4 seconds and the entire operation is capped at 12, regardless of how the retries fall.
Choosing the timeout number
Most timeouts are set to a round number someone liked. There is a better way to pick one, and it changes behaviour under load more than the retry policy does.
Start from the API's observed latency rather than from intuition. If the median response is 300 ms and the slowest one-in-a-hundred is 1.2 seconds, a timeout somewhere above that tail — two or three seconds — abandons genuinely stuck requests while letting slow-but-fine ones complete. A thirty-second timeout on that API is not cautious; it is thirty seconds of a connection held open, a user waiting, and a worker unavailable, for a request that was never going to arrive.
The opposite error is just as common. A timeout below the normal tail turns ordinary slowness into failures, and because those failures trigger retries, it increases load on a service that was already struggling. A too-short timeout under load is an amplifier.
Two refinements are worth knowing. Separate the connection timeout from the total, where your client supports it: failing to connect within a second or two is clearly broken, while a response legitimately taking longer is not. And give the timeout a budget across retries rather than per attempt — three attempts with a five-second timeout each can occupy fifteen seconds, which is well past the point a user has given up.
That last idea generalises usefully. Decide how long the whole operation may take, and let the retry logic spend that budget rather than rediscovering it each attempt. When the budget is exhausted, stop, regardless of how many attempts remain.
Idempotency decides what is safe to retry
The question that should come before any retry policy, and the one most often skipped.
A retry sends the request again. That is harmless if repeating it has the same effect as doing it once, and potentially damaging if it does not. GET and DELETE are naturally safe — reading twice changes nothing, and deleting an already-deleted thing is usually a no-op. PUT is safe by design, because it sets a value rather than adjusting one.
POST is the problem. A retried POST can create two records, send two emails or take two payments. Worse, the most dangerous case is the one that looks like a failure: the request arrived, the server processed it, and the response was lost on the way back. From your side that is indistinguishable from the request never arriving, and retrying duplicates the effect.
The standard solution is an idempotency key — a unique value you generate per logical operation and send with the request. The server records it and, on seeing it again, returns the original result rather than repeating the work. Payment providers pioneered this and most now require it. Where an API supports it, use it; where it does not, treat POST as not retryable and surface the uncertainty to the user rather than guessing.
The key must be generated once per operation and reused across retries of that operation. Generating a fresh one per attempt defeats the entire mechanism, and it is an easy mistake to make if the key is created inside the retry loop rather than outside it.
Knowing when to stop trying
Retries handle transient problems. Applied to a persistent one, they turn a small outage into a larger one by hammering a service that is already failing.
The pattern that prevents this is a circuit breaker. Count consecutive failures against a dependency, and once the count passes a threshold, stop calling it for a while and fail immediately instead. After a cooling period, let a single request through: if it succeeds, resume normally; if it fails, wait again.
Two things make this valuable. It stops your traffic contributing to the overload, which shortens the outage for everyone. And it makes your own failures fast — a request that fails instantly is far better for a user than one that spends fifteen seconds retrying before failing anyway, and far better for your own capacity, since nothing is tied up waiting.
The complement is a bulkhead: capping how many concurrent requests can be in flight to any one dependency, so a slow third party cannot consume every worker you have. Without it, one degraded API can take down an application that has four healthy ones.
Both are more valuable than a cleverer backoff curve. The failure that takes a service down is rarely a badly chosen delay; it is unbounded concurrency against something slow, with retries multiplying it.
The failure you cannot retry away
Retries help with transient problems: a dropped packet, a restarting instance, a brief overload. They do nothing for an API that has been shut down.
Our daily checks found 487 of 2,712 listed APIs failing to respond at all. No amount of backoff recovers those. This is why the catalogue publishes current status on every listing: the fastest way to fix a persistent timeout is often to discover the service no longer exists and pick a different one.
Common questions
Does fetch have a default timeout?
Not in the specification. Browsers impose their own limits, often around 300 seconds, which is far too long to be useful. Node's fetch behaves similarly. You must set your own.
What is a reasonable timeout for a third-party API?
Between 5 and 10 seconds for interactive requests. Base it on measured latency rather than instinct: take the API's typical response time and allow generous headroom, then treat anything beyond that as failed.
Is it safe to retry a POST request?
Not by default. A timeout does not tell you whether the server processed the request, so retrying may duplicate it. Use an idempotency key if the API supports one, otherwise only retry requests that are naturally idempotent.
What is the difference between a timeout and an abort?
Mechanically nothing, since AbortSignal.timeout is built on the same machinery. The distinction is intent: a timeout is the deadline expiring, while an abort is usually your own code cancelling because the result is no longer wanted.
Should I retry a 4xx error?
Almost never. A 400, 401, 403 or 404 will fail identically every time. The exceptions are 408 Request Timeout and 429 Too Many Requests, which are both explicitly about timing.
Sources
Written by
SandyI build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.