Retry a Promise-Returning Function with Delay
What you'll practice:
Settling one outer promise from a chain of attempts without resolving or rejecting it twice Counting attempts so that retries means extra attempts, not total attempts Placing the delay between attempts only, never before the first or after the last
Expected behavior
Your retryWithDelay() implementation should:
Return a promise and call fn() immediately for the first attempt Resolve with the first value fn resolves with and never call fn again after that After a failed attempt, wait delayMs and call fn again while attempts remain Make at most retries + 1 attempts, so retries = 0 means a single attempt Reject with the error from the last failed attempt Treat a synchronous throw inside fn like a rejected promise
Arguments
fn: () => Promise<T> | T —The operation to run. It may resolve, reject or throw synchronously. retries: number —How many extra attempts to make after the first one fails. Defaults to 3. delayMs: number —Milliseconds to wait between attempts. Defaults to 100.
Returns
Promise<T> — Solution idea
Interview answer: define the contract before writing code. One attempt happens immediately; each failure is followed by a delay and another attempt while attempts remain; the total number of attempts is retries + 1; the first success resolves the outer promise and the last failure rejects it. Starting each attempt inside Promise.resolve().then(fn) turns a synchronous throw into a rejection, so a single catch handles both failure kinds. Because a promise settles only once, resolving on the first success automatically ignores any later work, and counting attempts left makes the exit test a single comparison.
// Succeeds on the third attempt after two failures
let calls = 0;
const flaky = () => (++calls < 3 ? Promise.reject(new Error('busy')) : Promise.resolve('ok'));
await retryWithDelay(flaky, 3, 50); // => 'ok' after about 100 ms, calls === 3
// Gives up after retries + 1 attempts and surfaces the last error
const down = () => Promise.reject(new Error('down'));
await retryWithDelay(down, 2, 10); // rejects with Error('down') after 3 attempts
// retries = 0 is a single attempt
await retryWithDelay(down, 0, 10); // rejects immediately after 1 attemptPublic interview reports
These public, self-reported interview accounts informed this practice version. The exercise is modelled on reported rounds, not copied from them, and the reports are not verified official questions or endorsements.
- LeetCode Discuss: Google L3/L4 frontend interview experience, June 2021
One Google L3/L4 frontend account describes a domain round that started with implementing a retry function and moved on to implementing promise methods. This drill is a practice version of that exercise, not its reported wording.
- LeetCode Discuss: Intuit senior frontend engineer interview experience, November 2025
One senior Intuit account describes implementing a retry function that takes a promise-returning function and retries it a given number of times with a delay between attempts. This drill practises that exact contract; the wording and tests are ours.
Solution
Overview
Interview answer: define the contract before writing code. One attempt happens immediately; each failure is followed by a delay and another attempt while attempts remain; the total number of attempts is retries + 1; the first success resolves the outer promise and the last failure rejects it. Starting each attempt inside Promise.resolve().then(fn) turns a synchronous throw into a rejection, so a single catch handles both failure kinds. Because a promise settles only once, resolving on the first success automatically ignores any later work, and counting attempts left makes the exit test a single comparison.
Mental model: a loop over attempts where the loop body is "run, and on failure sleep then continue", written either as a recursive function or as an async for loop.
Approach 1: Recursive attempt with a closure
Wrap the whole thing in one outer Promise and define an inner attempt(attemptsLeft) function. Each attempt starts the operation inside Promise.resolve().then(() => fn()) so synchronous throws become rejections, resolves the outer promise on success, and on failure either rejects (no attempts left) or schedules the next attempt with setTimeout after delayMs. Time is bounded by (retries + 1) attempts plus retries delays; memory is one closure per pending attempt.
export default function retryWithDelay(fn, retries = 3, delayMs = 100) {
return new Promise((resolve, reject) => {
const attempt = (attemptsLeft) => {
Promise.resolve()
.then(() => fn())
.then(resolve)
.catch((error) => {
if (attemptsLeft <= 0) {
reject(error);
return;
}
setTimeout(() => attempt(attemptsLeft - 1), delayMs);
});
};
attempt(retries);
});
}
export default function retryWithDelay<T>(fn: () => Promise<T> | T, retries = 3, delayMs = 100): Promise<T> {
return new Promise<T>((resolve, reject) => {
const attempt = (attemptsLeft: number): void => {
Promise.resolve()
.then(() => fn())
.then(resolve)
.catch((error: unknown) => {
if (attemptsLeft <= 0) {
reject(error);
return;
}
setTimeout(() => attempt(attemptsLeft - 1), delayMs);
});
};
attempt(retries);
});
}
Approach 2: async loop with await
Loop from attempt 0 to retries. Inside try, return await fn(); the await inside the try block makes both rejections and synchronous throws land in the catch, which records the error and, when more attempts remain, awaits a timer promise before the next iteration. After the loop, throw the last error. Same complexity, fewer moving parts, at the cost of async/await support.
export default async function retryWithDelay(fn, retries = 3, delayMs = 100) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
export default async function retryWithDelay<T>(fn: () => Promise<T> | T, retries = 3, delayMs = 100): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < retries) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
Notes & Pitfalls
- Calling fn() directly and only catching the returned promise misses synchronous throws.
- Counting total attempts instead of extra attempts makes retries = 0 mean zero attempts.
- Delaying before the first attempt or after the final failure adds latency the caller never asked for.
- Resolving and rejecting the same outer promise on different paths is harmless but hides a bug: make sure only one path runs per attempt.
- retries = 0 → one attempt, immediate rejection on failure.
- fn returns a plain value → resolve with it, no retry.
- fn throws synchronously on the first call → counts as a failed attempt.
- delayMs = 0 → attempts still run in separate macrotasks.
- Normalising sync and async failures with Promise.resolve().then(fn).
- Settle-once semantics of a promise to end a retry loop.
- setTimeout as a delay between asynchronous attempts.
Common mistakes on this challenge
Make exactly retries + 1 attempts and surface the last error
The first attempt runs immediately, each failure may be followed by another attempt while attempts remain, and the final failure must reject with its own error.
- Start every attempt with `Promise.resolve().then(() => fn())` so a synchronous throw becomes a rejection.
- Resolve the outer promise on the first success and never call `fn` again after that.
- Count attempts so that `retries = 0` means one attempt, and reject with the error from the last failure.
Common interview follow-ups:
- How would you add exponential backoff with jitter, and what should the cap be?
- How would you stop retrying early for errors that cannot succeed on retry, such as a 400 response?
- How would you let the caller cancel pending retries with an AbortSignal?
- How would you test this with fake timers instead of real delays?
Resources
Similar questions
Guides
Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.
Built in public
The code behind FrontendAtlas lives on GitHub. A star helps other frontend engineers find it.