JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Timeouts and retries for flaky requests
Bound each attempt with a timeout, cancel abandoned work with AbortController, and retry only transient failures with capped exponential backoff.
What you will learn
- Bound a slow promise with Promise.race against a rejecting timer, then clearTimeout
- Abort with AbortController so a timed-out attempt stops doing work, not just waiting
- Retry only transient, idempotent failures; rethrow a 404 or a TypeError at once
- Space attempts with capped exponential backoff and track the total time budget
Understanding Timeouts and retries for flaky requests
A promise has no concept of "too long". When you await a request, your async function stays suspended until that promise settles, so a socket that hangs open produces a promise that never settles and code that never resumes; no error is thrown because nothing failed. A deadline therefore has to come from outside the request: you start a second promise that rejects on a timer and race it against the first, and whichever settles first decides what your code sees.
Racing ends your wait, not the work. The request keeps running, the server keeps processing it, and the connection stays occupied, which is why an unconditional retry of a POST can apply the same operation twice. Passing an AbortSignal into the request and aborting it when the deadline fires is the only thing that closes the socket, so the timeout wrapper and the cancellation mechanism are two separate jobs that belong together.
Retries buy you a second chance at the cost of latency, so they need boundaries on both ends. Retry the failures that a different moment can fix, such as a connection reset, a 429, or a 502/503/504, and rethrow immediately on a 400 or 404 that will answer identically forever, and on your own TypeError, which retrying only delays. Then think in budgets: attempts multiplied by the per-attempt timeout plus the backoff waits is the worst-case delay a user sits through, and exponential backoff with jitter keeps a crowd of clients from retrying in lockstep against a server that is already struggling.
The mental model is two nested clocks. The inner clock bounds one attempt; the outer clock bounds the whole operation, including sleeps, and both must be explicit or the operation is unbounded in practice.
let calls = 0;
// Stand-in for a flaky endpoint: the first call hangs, later calls are fast.
function fetchQuote() {
const n = ++calls;
return new Promise((resolve) => {
setTimeout(() => resolve(`quote #${n}`), n === 1 ? 500 : 20);
});
}
function withTimeout(promise, ms) {
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function retry(task, { attempts = 3, timeoutMs = 100, base = 50 } = {}) {
let lastError;
for (let i = 1; i <= attempts; i++) {
try {
const value = await withTimeout(task(), timeoutMs);
console.log(`attempt ${i}: ok`);
return value;
} catch (err) {
lastError = err;
console.log(`attempt ${i} failed: ${err.message}`);
if (i < attempts) await sleep(base * 2 ** (i - 1));
}
}
throw new Error(`all ${attempts} attempts failed`, { cause: lastError });
}
retry(fetchQuote).then((quote) => console.log('got:', quote));A timeout is a competing promise that bounds one attempt while cancellation stops the work, and a retry loop trades extra latency for a second chance only when the failure is transient.
Worked examples
Abort the work, not just the wait
Shows that a deadline enforced through AbortController actually stops the underlying work, which Promise.race cannot do.
function slowRequest(signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => {
console.log('server work finished');
resolve('data');
}, 300);
signal.addEventListener('abort', () => {
clearTimeout(id);
console.log('work stopped at the source');
reject(signal.reason);
});
});
}
const controller = new AbortController();
setTimeout(() => controller.abort(new Error('deadline exceeded')), 100);
slowRequest(controller.signal)
.then((data) => console.log('resolved:', data))
.catch((err) => console.log('rejected:', err.message));Example explained
Line 1The abort listener calls clearTimeout, so the simulated work is torn down instead of merely ignored.
Line 2controller.abort(reason) puts your own Error on signal.reason, so the promise can reject with a message you chose.
Line 3'server work finished' never prints; with a real fetch this is the moment the request is dropped and the socket freed.
Line 4Build a fresh AbortController per attempt, because an aborted signal stays aborted and would fail every later attempt instantly.
Decide which failures deserve a retry
Separates transient failures from permanent ones so the loop stops early instead of repeating a hopeless call.
class HttpError extends Error {
constructor(status) {
super(`HTTP ${status}`);
this.status = status;
}
}
function isRetryable(err) {
if (err instanceof HttpError) return err.status === 429 || err.status >= 500;
return true; // no status: a network-level failure, worth another try
}
const failWith = (status) => () => Promise.reject(new HttpError(status));
async function retry(task, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
return await task();
} catch (err) {
if (!isRetryable(err) || i === attempts) {
console.log(`${err.message}: stopped after ${i} attempt(s)`);
return;
}
console.log(`${err.message}: attempt ${i} failed, retrying`);
}
}
}
(async () => {
await retry(failWith(503));
await retry(failWith(404));
})();Example explained
Line 1isRetryable reads err.status: 429 and 5xx describe a busy or broken server, a state that can change between attempts.
Line 2The fallback returns true because a thrown network error carries no status at all, and those are the classic transient failures.
Line 3The 503 run uses its whole budget, while the 404 run exits on the first catch and saves two pointless round trips.
Line 4The i === attempts check in the same branch keeps the loop from reporting success or sleeping after the final failure.
Cap the backoff and read the budget
Computes the actual wait schedule so you can see how long a retry policy makes a user wait in the worst case.
function backoff(attempt, { base = 100, cap = 1000 } = {}) {
return Math.min(cap, base * 2 ** (attempt - 1));
}
let total = 0;
for (let attempt = 1; attempt <= 6; attempt++) {
const wait = backoff(attempt);
total += wait;
console.log(`after attempt ${attempt}: wait ${wait}ms (total ${total}ms)`);
}Example explained
Line 1base * 2 ** (attempt - 1) doubles each wait, which is what gives an overloaded server increasing room to recover.
Line 2Math.min against cap stops the doubling before a late attempt turns into a multi-second nap nobody will wait for.
Line 3The running total is only the sleeping time; add attempts multiplied by the per-attempt timeout to get real worst-case latency.
Line 4Production clients multiply each wait by a random factor (jitter) so many retrying clients do not hit the server on the same tick.
Important notes
fetch rejects only on network-level failure, so an HTTP 500 resolves with res.ok === false; if you retry on rejection alone, server errors are never retried at all.
AbortSignal.timeout(ms) gives a per-attempt deadline without a manual timer, but call it fresh inside each attempt, since a reused aborted signal makes every later attempt fail immediately.
Common mistakes
Leaving the deadline timer running after a fast success: every call parks a live timer, so a Node script hangs until the last one fires and a long-running page accumulates useless handles.
Believing Promise.race cancelled the request: the first POST can still be applied on the server, so a retry double-charges the customer while abandoned requests eat the connection pool.
Retrying inside a catch that also swallows your own bugs: a TypeError from a typo in the response handler is repeated three times, delaying the crash and pointing the stack trace at the retry loop.
Try it yourself
Change, predict, then run
In a browser console, write flaky() that rejects with new Error('HTTP 503') on its first two calls and resolves with 'ok' on the third, then drive it with a retry loop capped at 3 attempts that logs each attempt and waits 100ms then 200ms. Change it to reject with 'HTTP 404' and add an isRetryable check so the loop gives up after the first attempt.
Open the JavaScript workspaceCheck your understanding
Your fetch is wrapped in Promise.race with a 2-second timeout. The timeout wins and your code retries the same POST. What has actually happened on the server?
- The race rejected the request promise, so the server never received the first POST.
- The first POST is cancelled once nothing holds a reference to its promise.
- The first POST may still arrive and be applied, because the race only ended your wait.
- The browser retries the POST itself and de-duplicates the second one.
Show answer
Promise.race only chooses which settlement your code observes; it has no channel to the open connection, so the in-flight request continues and the server can apply it. Option 1 is tempting because a rejection did occur, but that rejection came from your timer promise, not from the request, and rejecting a wrapper cannot un-send bytes already written to the network. Only an AbortController, or AbortSignal.timeout passed to fetch, cancels the request itself.