async
promise
error-handling
retry-pattern
JavaScript

Retrying a failed async/promise function?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Retrying a failed asynchronous operation in JavaScript is a common need, but there is one critical rule: retry the function that creates the promise, not the already-settled promise object itself. A promise represents one attempt. Once it has resolved or rejected, it cannot be restarted. Good retry logic therefore wraps a function and decides when to call it again.

Retry the Function, Not the Promise

This is the wrong mental model:

javascript
const p = fetch("/api/data");
// "retry p" later

That promise is already tied to a single request attempt.

This is the right model:

javascript
const makeRequest = () => fetch("/api/data");

Now each call to makeRequest() creates a fresh promise and a fresh network attempt.

That distinction is the foundation of every retry helper.

A Simple Retry Helper

Here is a basic async retry wrapper:

javascript
1async function retry(fn, retries = 3, delayMs = 500) {
2  let lastError;
3
4  for (let attempt = 1; attempt <= retries; attempt += 1) {
5    try {
6      return await fn();
7    } catch (error) {
8      lastError = error;
9
10      if (attempt === retries) {
11        throw lastError;
12      }
13
14      await new Promise(resolve => setTimeout(resolve, delayMs));
15    }
16  }
17
18  throw lastError;
19}

Usage:

javascript
1async function loadData() {
2  const response = await retry(() => fetch("https://example.com/api"), 3, 1000);
3  const json = await response.json();
4  console.log(json);
5}

This retries up to three attempts with a one-second delay between failures.

Add Exponential Backoff

Constant delays are fine for simple cases, but exponential backoff is often better for transient failures such as rate limiting or temporary network issues.

javascript
1async function retryWithBackoff(fn, retries = 4, baseDelayMs = 300) {
2  let lastError;
3
4  for (let attempt = 1; attempt <= retries; attempt += 1) {
5    try {
6      return await fn();
7    } catch (error) {
8      lastError = error;
9
10      if (attempt === retries) {
11        throw lastError;
12      }
13
14      const delay = baseDelayMs * 2 ** (attempt - 1);
15      await new Promise(resolve => setTimeout(resolve, delay));
16    }
17  }
18
19  throw lastError;
20}

This reduces the pressure on the failing service compared with hammering it immediately again.

Retry Only When It Makes Sense

Not every failure should be retried. Retrying makes sense for:

  • temporary network issues
  • '503 Service Unavailable'
  • rate limiting with backoff
  • timeouts

Retries are often a bad idea for:

  • validation failures
  • authentication errors
  • clearly malformed requests
  • non-idempotent actions unless you are certain repeated attempts are safe

The retry wrapper can incorporate that logic:

javascript
1async function fetchWithRetry(url) {
2  return retry(async () => {
3    const response = await fetch(url);
4
5    if (!response.ok && response.status >= 500) {
6      throw new Error(`Retryable server error: ${response.status}`);
7    }
8
9    if (!response.ok) {
10      throw new Error(`Non-retryable error: ${response.status}`);
11    }
12
13    return response;
14  });
15}

That prevents pointless retries on errors that will not fix themselves.

Idempotency Matters

Be careful with operations that create side effects. Retrying:

  • 'GET'
  • some safe reads

is usually straightforward.

Retrying:

  • payments
  • record creation
  • external side-effect APIs

can be dangerous unless the operation is idempotent or protected by an idempotency key.

This is one of the most important production concerns around retry logic. A technically correct retry helper can still cause business-level bugs if it blindly replays unsafe operations.

Abort and Cancellation

In UI code, retries should usually be cancellable. If the user navigates away, continuing to retry in the background may be wasteful or incorrect.

With fetch, this often means using an AbortController and deciding whether cancellation should stop the retry loop entirely.

That is not always necessary in a tiny utility, but it matters in real applications and background data loaders.

Common Pitfalls

The biggest mistake is retrying the same promise object instead of calling a function that creates a new promise each time. Settled promises do not restart.

Another issue is retrying every error indiscriminately. Some failures are permanent until the request or credentials change.

Developers also often forget about idempotency. Retrying a side-effecting action can duplicate work or create inconsistent state.

Finally, constant tight retries can make a failing service worse. Backoff and retry limits are part of the solution, not optional extras.

Summary

  • Retry the async function that creates the promise, not the settled promise object.
  • A good retry helper wraps await fn() in a loop with a maximum retry count.
  • Exponential backoff is usually better than immediate repeated retries.
  • Only retry errors that are likely to be transient.
  • Be especially careful when retrying side-effecting or non-idempotent operations.

Course illustration
Course illustration

All Rights Reserved.