Lesson 22 of 30
Knowing that backoff matters is a decision-level answer. Actually writing a retry wrapper means deciding what counts as retryable, how long to wait, and when to give up — and getting any of those wrong is a real, visible bug.
12 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
Question 1 of 3
A retry wrapper receives a 404 Not Found response. What should it do?
Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The earlier system-design coverage of resilience explains why a flaky dependency should be retried with increasing delay instead of hammered immediately. This lesson is the actual code: what a retry wrapper looks like, and the details that separate a wrapper that helps from one that quietly makes things worse.
A working retry wrapper needs three pieces:
async function fetchWithRetry(
url: string,
options: RequestInit = {},
maxAttempts = 4,
): Promise<Response> {
let attempt = 0;
while (true) {
attempt++;
try {
const res = await fetch(url, options);
// A response came back — but "came back" isn't the same as "worth retrying on failure."
if (res.ok) return res;
if (!isRetryableStatus(res.status) || attempt >= maxAttempts) return res;
} catch (networkError) {
// A thrown error here means the request never completed (offline, DNS, timeout) —
// that's a genuinely transient condition, so it's always worth retrying.
if (attempt >= maxAttempts) throw networkError;
}
const baseDelay = 500 * 2 ** (attempt - 1); // 500ms, 1s, 2s, 4s...
const jitter = Math.random() * baseDelay * 0.3; // up to 30% random variation
await sleep(baseDelay + jitter);
}
}
function isRetryableStatus(status: number): boolean {
// 429 (rate limited) and 503 (service unavailable) are transient — the
// server is asking for a slowdown, not rejecting the request as invalid.
if (status === 429 || status === 503) return true;
// 500-range errors other than 501 (not implemented) are usually transient too.
if (status >= 500 && status !== 501) return true;
// 400, 404, 401, 403 and similar: the request itself is the problem.
// Retrying sends the exact same broken request and gets the exact same result.
return false;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
The isRetryableStatus check is the part a naive retry wrapper skips. A 404 Not Found or 400 Bad Request will fail identically on every retry, because the problem is the request itself, not a temporary condition on the server. Retrying those anyway wastes several seconds doing something that was never going to succeed, and from the user's side it looks exactly like the app hanging.
400/404/401 means the request itself is wrong — retrying it burns time and makes the failure feel slower and more confusing, not more resilient.GET is safe by default; retrying a POST that creates something can create it twice unless the request is designed to be safe to repeat (an idempotency key, or checking whether the effect already happened)."Would you retry this failed request, and how?" is a common follow-up to any resilience discussion, and the strong answer distinguishes retryable failures from non-retryable ones by name, not just recites "exponential backoff" as a phrase.