EthicsComputerCourses
Join training +91 90532 10052

Async Patterns: Retry, Cancel & Limit

JavaScript · Lesson 48 of 74 Intermediate 7 min read Updated

The Fetch API lesson showed a single request that either works or fails. Real apps are messier. A user types fast and five searches race each other. A server is busy for two seconds and then recovers. A page needs to upload 40 photos without opening 40 connections at once. Each of these has a standard solution that every working JavaScript developer is expected to know.

In this lesson you will build four small, reusable tools: cancelling an old request when a new one starts, combining a Cancel button with a timeout, retrying with exponential backoff, and running many jobs with a concurrency limit. The examples use pretend servers built with setTimeout, so you can see exactly what happens and the results are the same every time.

The stale response problem

A search box sends a request after each pause in typing. Answers do not always come back in the order they were asked for. If the search for "pun" is slow and the search for "pune" is fast, the old answer arrives last and overwrites the correct one. This bug is called a race condition: the result depends on which job happens to finish first.

The fix is to cancel the previous request whenever a new one starts. An AbortController gives you a signal to pass to the request and an abort() method to stop it. A cancelled fetch() rejects with an error named "AbortError".

JavaScriptRun it yourself
// A pretend search API: short words take longer, like a busy server
function fakeSearch(query, signal) {
  const delay = query.length < 4 ? 300 : 100;
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve(`results for "${query}"`), delay);
    signal?.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, { once: true });
  });
}

// Version 1: no cancelling
async function showResults(query) {
  const text = await fakeSearch(query);
  console.log(`Without cancel, screen shows: ${text}`);
}
showResults("pun");
showResults("pune");

await new Promise((resolve) => setTimeout(resolve, 400));

// Version 2: abort the previous request
let controller = null;
async function showLatest(query) {
  controller?.abort();               // stop the old search, if any
  controller = new AbortController();
  try {
    const text = await fakeSearch(query, controller.signal);
    console.log(`With cancel, screen shows: ${text}`);
  } catch (error) {
    if (error.name === "AbortError") {
      console.log(`Cancelled old search for "${query}"`);
      return;
    }
    throw error;
  }
}
showLatest("pun");
showLatest("pune");
Output
Without cancel, screen shows: results for "pune"
Without cancel, screen shows: results for "pun"
Cancelled old search for "pun"
With cancel, screen shows: results for "pune"

Without cancelling, the screen ends up showing results for "pun" while the box says "pune". With cancelling, the old request is stopped and only the latest answer is shown. With the real fetch(), you pass the same signal as fetch(url, { signal: controller.signal }).

Notice how fakeSearch supports cancelling: it listens for the signal's abort event and rejects with signal.reason. When abort() is called without an argument, the reason is a DOMException named "AbortError". Your own slow functions can accept a signal in exactly the same way.

Combining a Cancel button and a timeout

Often you want two reasons to stop: the user clicks Cancel, or the job takes too long. AbortSignal.timeout(ms) gives a signal that aborts by itself after the time you choose, with a reason named "TimeoutError". AbortSignal.any([...]) combines several signals into one that aborts as soon as any of them does, and its reason comes from whichever signal fired first.

JavaScriptRun it yourself
// Pretend to build a sales report; it takes 500 ms and can be stopped
function buildReport(signal) {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted(); // already cancelled? stop at once
    const timer = setTimeout(() => resolve("Report: ₹4,52,300 sales in Kochi"), 500);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason); // the reason says why it stopped
    }, { once: true });
  });
}

async function run(label, timeoutMs, cancelAfterMs) {
  const controller = new AbortController(); // for the Cancel button
  const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
  if (cancelAfterMs) setTimeout(() => controller.abort(), cancelAfterMs);

  try {
    console.log(`${label}: ${await buildReport(signal)}`);
  } catch (error) {
    if (error.name === "TimeoutError") console.log(`${label}: took too long`);
    else if (error.name === "AbortError") console.log(`${label}: cancelled by the user`);
    else throw error;
  }
}

await run("Run 1", 1000);      // finishes in time
await run("Run 2", 200);       // the timeout wins
await run("Run 3", 1000, 100); // the user clicks Cancel first
Output
Run 1: Report: ₹4,52,300 sales in Kochi
Run 2: took too long
Run 3: cancelled by the user

signal.throwIfAborted() throws the reason straight away if the signal has already fired, so a job never starts after it has been cancelled. Checking error.name lets you show a different message for each case, and every other error is passed on with throw.

Retrying with exponential backoff

Some failures are temporary: the mobile network drops for a moment, or the server answers 503 Service Unavailable or 429 Too Many Requests. Trying again often works. Other failures are permanent: 404 Not Found or 400 Bad Request will fail the same way every time, so retrying them only wastes time.

Good retry code follows four rules:

  • Retry only temporary errors: network failures, 5xx statuses and 429.
  • Wait longer after each failure: 200 ms, then 400 ms, then 800 ms. This is exponential backoff, and it gives a struggling server time to recover.
  • Add a little randomness, called jitter, so thousands of phones do not all retry at the same instant.
  • Stop after a fixed number of attempts, and stop at once if the user cancels.
JavaScriptRun it yourself
class HttpError extends Error {
  constructor(status) {
    super(`Server answered ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

function isWorthRetrying(error) {
  if (error.name === "AbortError") return false;        // the user cancelled
  if (error instanceof HttpError) {
    return error.status === 429 || error.status >= 500; // busy or broken server
  }
  return true; // network trouble, such as the TypeError fetch throws when offline
}

function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, { once: true });
  });
}

async function retry(task, { attempts = 4, baseDelay = 200, signal } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await task(attempt);
    } catch (error) {
      if (attempt === attempts || !isWorthRetrying(error)) throw error;
      const delay = baseDelay * 2 ** (attempt - 1);     // 200, 400, 800...
      const jitter = Math.random() * delay * 0.2;       // up to 20% extra
      console.log(`Attempt ${attempt} failed (${error.message}), retrying in about ${delay} ms`);
      await wait(delay + jitter, signal);
    }
  }
}

// A pretend payment-status API that is busy twice, then answers
const answers = [503, 503, 200];
async function checkPaymentStatus(attempt) {
  const status = answers[attempt - 1];
  if (status !== 200) throw new HttpError(status);
  return "Payment of ₹1,299 confirmed";
}

console.log(await retry(checkPaymentStatus));

try {
  await retry(async () => { throw new HttpError(404); });
} catch (error) {
  console.log(`Gave up at once: ${error.message}`);
}
Output
Attempt 1 failed (Server answered 503), retrying in about 200 ms
Attempt 2 failed (Server answered 503), retrying in about 400 ms
Payment of ₹1,299 confirmed
Gave up at once: Server answered 404

With real fetch(), your task function checks response.ok and throws an HttpError with response.status, as you learned in the Fetch lesson.

Limiting how many jobs run at once

Promise.all(files.map(upload)) starts every upload at the same moment. With five files that is fine. With 500 it can flood the network, hit the server's rate limit, or use too much memory on a cheap phone. The answer is a concurrency limit: run at most a few jobs at a time and start the next one as soon as a slot is free.

The helper below creates a fixed number of "lanes". Each lane keeps taking the next waiting item until none are left:

JavaScriptRun it yourself
async function mapWithLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function lane() {
    while (next < items.length) {
      const index = next++; // claim the next item before awaiting
      results[index] = await worker(items[index], index);
    }
  }

  const lanes = Array.from({ length: Math.min(limit, items.length) }, lane);
  await Promise.all(lanes);
  return results;
}

const photos = ["aarav.jpg", "priya.jpg", "kabir.jpg", "meera.jpg", "rohan.jpg", "isha.jpg"];
const times = [1000, 300, 600, 300, 450, 600]; // pretend upload times in ms

const uploaded = await mapWithLimit(photos, 2, async (name, i) => {
  console.log(`start ${name}`);
  await new Promise((resolve) => setTimeout(resolve, times[i]));
  console.log(`  done ${name}`);
  return name.replace(".jpg", ".webp");
});
console.log(uploaded.join(", "));
Output
start aarav.jpg
start priya.jpg
  done priya.jpg
start kabir.jpg
  done kabir.jpg
start meera.jpg
  done aarav.jpg
start rohan.jpg
  done meera.jpg
start isha.jpg
  done rohan.jpg
  done isha.jpg
aarav.webp, priya.webp, kabir.webp, meera.webp, rohan.webp, isha.webp

Never more than two uploads run at once, a new one starts the moment another finishes, and the results stay in the original order because each one is stored at its own index. The line const index = next++ is safe because JavaScript runs one piece of code at a time: no other lane can run between reading next and adding 1 to it.

In a large project you may use a small npm package for this, but now you know how it works inside, and you can combine it with retry() and an abort signal.

Common mistakes

  • Showing whatever answer arrives last. Cancel the old request, or ignore answers that do not match the latest query.
  • Treating a cancel as an error. An AbortError is usually expected. Do not show "Something went wrong" when the user pressed Cancel.
  • Retrying everything. A 404 or a validation error will never succeed. Retry only network errors, 5xx and 429.
  • Retrying instantly in a tight loop. Without a growing delay, you hammer a server that is already struggling.
  • Starting hundreds of requests with Promise.all. Limit concurrency for large lists.

Key points

  • Cancel stale requests with AbortController so old answers cannot overwrite new ones.
  • AbortSignal.timeout(ms) aborts by itself with a TimeoutError; AbortSignal.any() combines it with a Cancel button.
  • Your own async functions can accept a signal, call throwIfAborted(), and listen for its abort event.
  • Retry only temporary failures, with exponential backoff, jitter and a maximum number of attempts.
  • Never blindly retry requests that create or change data; use idempotency keys for those.
  • A concurrency limit runs a few jobs at a time and keeps results in their original order.

Exercise

Combine two tools from this lesson. Use mapWithLimit() to check the status of six orders, two at a time, where each check uses retry(). Pretend that order 3 fails once with a 503 before it succeeds. Print each order's final status.

Show solution
JavaScript
const failedOnce = new Set();

async function checkOrder(id) {
  await new Promise((resolve) => setTimeout(resolve, 100));
  if (id === 3 && !failedOnce.has(id)) {
    failedOnce.add(id);
    throw new HttpError(503);
  }
  return `Order ${id}: shipped`;
}

const orders = [1, 2, 3, 4, 5, 6];
const statuses = await mapWithLimit(orders, 2, (id) => retry(() => checkOrder(id)));
console.log(statuses.join("\n"));

Each lane calls retry(), so order 3 is retried after about 200 ms while the other lane keeps working. The final list is still in order 1 to 6.

Quick Quiz

1. A search for "pun" answers after the search for "pune". What should the code do to avoid showing the wrong results?

2. Which error is worth retrying?

3. What does AbortSignal.any([a, b]) return?

4. Why is exponential backoff better than retrying immediately?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call