Cancellation, retries and backoff

Stop work nobody needs any more, put time limits on requests, retry failures without making them worse, and keep stale responses off the screen.

  • Beginner
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Cancel in-flight requests
  • Retry with backoff safely
  • Add timeouts with AbortSignal.timeout
  • Prevent stale responses from overwriting newer ones

Before you start

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 an abort event when it does.

fetch() accepts a signal. Aborting the controller stops the request, and the fetch promise rejects:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

SituationRetry?
Network error (fetch rejected with a TypeError)Yes
408, 429, 500, 502, 503, 504Yes
Other 4xx, such as 400, 401, 403, 404No: the request itself is wrong
Aborted by the user or a timeout you set for the whole operationNo
A non-idempotent request such as a payment POSTOnly 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:

JavaScript
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-After is honoured when the server sends it as a number of seconds, capped at maxMs. 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 HttpError for the caller.
  • backoffDelay and sleep are the functions defined earlier; keep them in the same module.

Using it on the dashboard:

JavaScript
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:

JavaScript
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 AbortController cancels; its AbortSignal is passed to the work. Aborted fetch calls reject with an AbortError.
  • AbortSignal.timeout(ms) aborts with a TimeoutError, 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 for abort.
  • 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.

Further reading