ESC

Type to search the knowledge base.

Retry a Promise N Times

Retry an async function with a bounded count, delay, and a shouldRetry predicate. Do not retry 4xx you caused, and never hide the last error.

intermediate6 min read
  • javascript
  • promises
  • async
  • interview

retry(fetch(url), 3) is already wrong. fetch(url) started once. A fulfilled or rejected promise stays settled. Calling retry on that object cannot “try again.” The helper has to take a factory: a function that returns a new promise each attempt.

The second classic miss is retrying a 400 you caused, or wrapping the last rejection in new Error('retry failed') so the caller loses status, body, and stack. Bounded attempts, a delay, a shouldRetry predicate, and rethrowing the same last error are the whole API.

Key takeaways

  • task is a function you call per attempt. Never retry an already-built Promise.
  • attempts is a hard cap. After the last failure, throw that failure, not a wrapper without cause.
  • shouldRetry(error, attempt) decides 429/5xx vs 4xx. Default-retry-everything is how you hammer a bad request.
  • Optional AbortSignal cancels the wait and should be passed into task so fetch can abort too.

Attempts, backoff, and a reason to stop

Name the count. This implementation uses attempts as total tries (first call + retries). attempts: 3 means at most three calls, not “three retries after a first success path.”

function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
      return;
    }
    const id = setTimeout(resolve, ms);
    const onAbort = () => {
      clearTimeout(id);
      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
    };
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

function isAbortError(err) {
  return err?.name === 'AbortError' || err?.name === 'TimeoutError';
}

function defaultShouldRetry(err) {
  if (isAbortError(err)) return false;
  const status = err?.status ?? err?.response?.status;
  if (status == null) return true; // network / TypeError from fetch
  if (status === 429) return true;
  if (status >= 500) return true;
  return false; // 4xx you caused, 2xx should not be here
}

async function retry(task, options = {}) {
  const {
    attempts = 3,
    delay = 0,
    backoff = 2,
    shouldRetry = defaultShouldRetry,
    signal,
  } = options;

  if (typeof task !== 'function') {
    throw new TypeError('retry: task must be a function that returns a promise');
  }
  if (!Number.isInteger(attempts) || attempts < 1) {
    throw new RangeError('retry: attempts must be an integer >= 1');
  }

  let waitMs = delay;
  let lastError;

  for (let i = 0; i < attempts; i++) {
    if (signal?.aborted) {
      throw signal.reason ?? new DOMException('Aborted', 'AbortError');
    }
    try {
      return await task({ attempt: i + 1, signal });
    } catch (err) {
      lastError = err;
      const hasMore = i < attempts - 1;
      if (!hasMore || !shouldRetry(err, i + 1)) {
        throw err;
      }
      if (waitMs > 0) {
        await wait(waitMs, signal);
        waitMs *= backoff;
      }
    }
  }

  throw lastError;
}
const data = await retry(
  ({ signal }) => fetch('/api/unreliable', { signal }).then(async (res) => {
    if (!res.ok) {
      const err = new Error(`HTTP ${res.status}`);
      err.status = res.status;
      throw err;
    }
    return res.json();
  }),
  { attempts: 4, delay: 200, backoff: 2 },
);

task({ attempt, signal }) returns a new promise every loop. await adopts thenables (Promises).

If the interviewer says “retry N times,” ask whether N is total attempts or retries after the first failure. This helper treats attempts: 3 as three calls. retries: 2 would be the same idea with a different name. Mixing them without saying which is how off-by-one bugs show up in production dashboards (“we retried 3 times” meaning 4 HTTP calls).

What not to retry

Failure Retry?
Network drop, TypeError: Failed to fetch yes
HTTP 502 / 503 / 504 yes
HTTP 429 yes, preferably with delay
HTTP 400 / 401 / 403 / 404 no
AbortError from the caller’s signal no
Programmer TypeError in task usually no; shouldRetry can filter by name

fetch does not throw on 404. If you forget if (!res.ok) throw, retry never sees an HTTP error. That is a Fetch bug, not a retry bug.

Do not retry a mutation blindly (POST creating orders). Idempotent GET/PUT with an idempotency key is the safe default. Say that when the prompt is “retry the save.” If you do retry a POST, send the same idempotency key on every attempt so the server can collapse duplicates.

Never hide the last error

// wrong
throw new Error(`Failed after ${attempts} attempts`);

// acceptable if you must wrap
throw new Error(`Failed after ${attempts} attempts`, { cause: lastError });

// best
throw lastError;

Callers branch on err.status, err.name, and instanceof. A string wrapper forces message parsing. If you wrap, Error cause keeps the original.

The unreachable throw lastError after the loop is a belt for type checkers. Every path already threw or returned.

Delay, backoff, jitter

Constant delay is fine for a whiteboard. Production adds:

  • Exponential backoff: waitMs *= backoff (the sketch).
  • Cap: Math.min(waitMs, maxDelay).
  • Jitter: waitMs * (0.5 + Math.random()) so a fleet does not retry in lockstep.

Sleep must observe signal, or abort waits until the timer fires. clearTimeout in the abort listener is the same leak-avoidance idea as Timeout a Promise.

Abort

Pass the same AbortSignal into fetch. Retrying after the user left the page is wasted work and can apply a late response if you ignore abort.

const controller = new AbortController();
button.addEventListener('click', () => controller.abort());

await retry(({ signal }) => fetch('/api/slow', { signal }), {
  attempts: 3,
  delay: 300,
  signal: controller.signal,
});

AbortSignal.any([user.signal, AbortSignal.timeout(8000)]) combines user cancel and a deadline. See AbortController. Retry should treat abort as terminal (shouldRetry returns false).

Procedure

  1. Reject a non-function task. Decide attempts (total tries, ≥ 1).
  2. Loop: if aborted, throw. Call task({ attempt, signal }). Return on success.
  3. On failure: if last attempt or !shouldRetry(err), throw err.
  4. Else wait (delay with backoff), observing the signal.
  5. Never retry 4xx you caused. Never swallow the last error.

Interview out-loud answer

Retry takes a factory, not a Promise, because a settled promise cannot run again. I cap total attempts, wait with optional exponential backoff, and consult shouldRetry so 400s do not loop. The last rejection is rethrown as-is. An AbortSignal cancels both the sleep and the in-flight fetch if task forwards it. I would not retry non-idempotent POSTs without an idempotency key.

Further reading

Related guides