Asynchronous code in real conditions has to cope with three things the happy path ignores. Users change their minds, so requests become unwanted. Networks are slow, so requests need time limits. Servers fail briefly, so some requests deserve a second try. This lesson builds the patterns for all three, using only standard APIs that work in current browsers and in Node.js.
AbortController and AbortSignal
Cancellation in JavaScript is split into two objects:
- An AbortController is held by the code that decides to cancel. It has an
abort()method. - Its AbortSignal,
controller.signal, is handed to the code doing the work. The signal reports whether cancellation has happened, and fires anabortevent when it does.
fetch() accepts a signal. Aborting the controller stops the request, and the fetch promise rejects:
const controller = new AbortController();
const request = fetch('data/courses.json', { signal: controller.signal });
controller.abort();
try {
await request;
} catch (error) {
console.log(error.name); // → AbortError
}
When you call abort() without an argument, the rejection is a DOMException named AbortError. If you pass a reason, controller.abort(new Error('User left the page')), fetch rejects with that reason instead, and it is available as signal.reason.
Because an aborted request is usually intentional, handle it separately from real failures:
try {
const response = await fetch(url, { signal });
// ...
} catch (error) {
if (error.name === 'AbortError') {
return; // Cancelled on purpose: not an error to show the user.
}
throw error;
}
Aborting does not undo anything on the server. If a POST has already arrived, the server may still process it. Cancellation stops your code waiting and frees the connection; it is not a rollback.
Timeouts with AbortSignal.timeout
fetch() has no timeout of its own, and a request to a slow server can hang for a long time. AbortSignal.timeout(ms) returns a signal that aborts itself after the given time:
try {
const response = await fetch('data/courses.json', {
signal: AbortSignal.timeout(5000),
});
const courses = await response.json();
console.log(courses.length);
} catch (error) {
if (error.name === 'TimeoutError') {
console.log('The server took too long. Please try again.');
} else {
throw error;
}
}
A timeout abort uses a DOMException named TimeoutError, not AbortError, so you can tell the two apart and show the right message. The timeout covers the whole operation, including reading the body with response.json(), as long as the signal has not fired before you finish.
Unlike the Promise.race timeout from the combinators lesson, this really stops the request rather than just ignoring its result.
Combining signals
Often you want both: cancel when the user navigates away or when time runs out. AbortSignal.any() creates a signal that aborts when any of its input signals does, with that signal's reason:
const userCancel = new AbortController();
const signal = AbortSignal.any([
userCancel.signal,
AbortSignal.timeout(8000),
]);
const response = await fetch('data/courses.json', { signal });
AbortSignal.timeout() and AbortSignal.any() are supported in all current browsers and current Node.js versions. AbortSignal.any() is the newer of the two, so check MDN's compatibility table if you support older browsers.
Making your own functions cancellable
Accept a signal in an options object, check it before starting, and listen for abort while waiting. A cancellable delay is the building block for retries:
function sleep(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
signal?.throwIfAborted();
const timerId = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timerId);
reject(signal.reason);
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}
signal.throwIfAborted() throws signal.reason if the signal has already been aborted, so the function fails immediately instead of starting work. Removing the listener when the timer wins avoids keeping a closure attached to a long-lived signal. A thrown error inside the promise executor, as from throwIfAborted(), turns into a rejection.
For loops of work, check signal.aborted or call signal.throwIfAborted() between steps, and pass the same signal down to every fetch or helper you call.
Retrying with exponential backoff
Some failures are temporary: a dropped connection, a server restarting, a rate limit. Retrying straight away, in a tight loop, makes things worse. If a struggling server has many clients all retrying instantly, the extra traffic keeps it down.
Exponential backoff waits longer after each failure: for example 0.5 s, then 1 s, then 2 s, then 4 s, up to a cap. Jitter randomises each wait, so that clients that failed at the same moment do not all retry at the same moment. A widely used form, often called full jitter, picks a random delay between zero and the backoff value:
function backoffDelay(attempt, { baseMs = 500, maxMs = 10_000 } = {}) {
const ceiling = Math.min(maxMs, baseMs * 2 ** attempt);
return Math.random() * ceiling;
}
// attempt 0: up to 500 ms, attempt 1: up to 1000 ms, attempt 2: up to 2000 ms...
Deciding what to retry
Retrying the wrong things causes bugs. Retry only when the request is likely to succeed next time and repeating it is safe:
| Situation | Retry? |
|---|---|
Network error (fetch rejected with a TypeError) | Yes |
408, 429, 500, 502, 503, 504 | Yes |
Other 4xx, such as 400, 401, 403, 404 | No: the request itself is wrong |
| Aborted by the user or a timeout you set for the whole operation | No |
A non-idempotent request such as a payment POST | Only if the server supports an idempotency key |
Idempotent means doing it twice has the same effect as doing it once. GET, PUT and DELETE are meant to be idempotent; POST often is not, and retrying it blindly can create duplicates.
A retrying fetch
Putting it together:
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
class HttpError extends Error {
constructor(response) {
super(`HTTP ${response.status} for ${response.url}`);
this.name = 'HttpError';
this.status = response.status;
}
}
export async function fetchWithRetry(url, {
retries = 3,
baseMs = 500,
maxMs = 10_000,
timeoutMs = 8000,
signal,
...fetchOptions
} = {}) {
for (let attempt = 0; ; attempt++) {
const signals = [AbortSignal.timeout(timeoutMs)];
if (signal) signals.push(signal);
try {
const response = await fetch(url, {
...fetchOptions,
signal: AbortSignal.any(signals),
});
if (response.ok) return response;
if (!RETRYABLE_STATUS.has(response.status) || attempt >= retries) {
throw new HttpError(response);
}
const retryAfter = Number(response.headers.get('Retry-After'));
const delay = Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, maxMs)
: backoffDelay(attempt, { baseMs, maxMs });
await sleep(delay, { signal });
} catch (error) {
if (error instanceof HttpError) throw error;
if (signal?.aborted) throw error; // The caller cancelled.
if (attempt >= retries) throw error; // Out of attempts.
if (error.name !== 'TypeError' && error.name !== 'TimeoutError') {
throw error; // Not a network problem.
}
await sleep(backoffDelay(attempt, { baseMs, maxMs }), { signal });
}
}
}
Points to notice:
- Each attempt gets its own timeout signal, combined with the caller's signal. A slow attempt is abandoned and retried; a caller's abort stops everything, including a pending
sleep. Retry-Afteris honoured when the server sends it as a number of seconds, capped atmaxMs. It can also be an HTTP date; this version falls back to backoff in that case.- Errors that are not worth retrying are thrown at once, with the status available on
HttpErrorfor the caller. backoffDelayandsleepare the functions defined earlier; keep them in the same module.
Using it on the dashboard:
const controller = new AbortController();
try {
const response = await fetchWithRetry('data/courses.json', {
signal: controller.signal,
retries: 2,
});
const courses = await response.json();
console.log(`Loaded ${courses.length} courses`);
} catch (error) {
console.log(`Could not load courses: ${error.message}`);
}
Cap the total time as well as the number of attempts. Pass a signal such as AbortSignal.timeout(30_000) as the signal option, so that a user is never left waiting for a long run of retries.
Race conditions: latest request wins
With search-as-you-type, responses can arrive out of order. The user types "ja", then "java". If the "ja" response is slower, it arrives last and overwrites the correct "java" results. Debouncing reduces the number of requests but does not remove the problem.
The fix is to cancel the previous request whenever a new one starts:
let currentController = null;
async function search(query) {
currentController?.abort();
const controller = new AbortController();
currentController = controller;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const results = await response.json();
showResults(results);
} catch (error) {
if (error.name === 'AbortError') return; // Superseded by a newer search.
showError(error);
}
}
showResults and showError stand for your own rendering functions, and /api/search for your own endpoint. Only the latest request can reach showResults, because every older one has been aborted. When the work cannot be aborted, keep a request counter instead: increment it at the start, remember the value, and ignore the result if the counter has moved on by the time it arrives.
Summary
- An
AbortControllercancels; itsAbortSignalis passed to the work. Abortedfetchcalls reject with anAbortError. AbortSignal.timeout(ms)aborts with aTimeoutError, and really stops the request.AbortSignal.any()combines user cancellation with timeouts.- Make your own async functions accept a
signal, check it before starting and listen forabort. - Retry only temporary failures of safe requests, with exponential backoff, jitter, a cap and respect for
Retry-After. - Cancel superseded requests, or ignore stale results, so old responses never overwrite new ones.