Most retry code is easy to write. The interview signal is knowing when not to retry: a bad request will stay bad, an aborted request should stop, and synchronized retries can turn a small outage into a much larger one.
This is a 15-minute exercise for backend, platform, and DevOps interviews. You will build a small fetchJson helper, then use its boundaries to explain idempotency, rate limiting, cancellation, and failure containment.
What should a retry policy decide?
A retry is not error handling in the abstract. It is a deliberate decision to repeat an operation after a failure that may be temporary.
For a read-only GET, that is often reasonable. For a payment creation or a message enqueue, retrying without an idempotency key can duplicate real work. Start an interview answer by naming that distinction. It is more useful than jumping directly to exponential backoff.
This tutorial limits itself to a JSON fetch helper. Its policy is intentionally small:
| Signal | Decision | Why |
|---|---|---|
2xx |
return JSON | The request succeeded. |
408, 429, or 5xx
|
retry a limited number of times | These can represent overload or a transient server-side failure. |
other 4xx
|
return the error | Retrying an invalid request usually creates noise, not recovery. |
| caller aborts | stop immediately | Cancellation is a product decision and must win over persistence. |
That leaves room for product-specific choices. A client with a strict 800 ms budget may allow only one retry; an internal batch job may allow several. The point is to make the budget explicit.
Build the smallest useful helper
Put this in a Node 18+ file or a modern browser project. Passing fetchImpl is not production ceremony: it makes the behavior easy to test without a network.
const retryable = (status) =>
status === 408 || status === 429 || status >= 500;
const parseRetryAfter = (value) => {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const at = Date.parse(value);
return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
};
const sleep = (ms, signal) =>
new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true }
);
});
const doNotRetry = (message) =>
Object.assign(new Error(message), { retry: false });
export async function fetchJson(
url,
{ attempts = 4, baseDelayMs = 250, signal, fetchImpl = fetch } = {}
) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetchImpl(url, { signal });
if (response.ok) return response.json();
if (!retryable(response.status) || attempt === attempts - 1) {
throw doNotRetry(`HTTP ${response.status}`);
}
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
const ceiling = retryAfter ?? baseDelayMs * 2 ** attempt;
// Full jitter: spread callers across the retry window.
await sleep(Math.round(Math.random() * ceiling), signal);
} catch (error) {
if (signal?.aborted || error.retry === false || attempt === attempts - 1) {
throw error;
}
await sleep(
Math.round(Math.random() * baseDelayMs * 2 ** attempt),
signal
);
}
}
}
A detail worth saying out loud: this helper catches both a thrown network error and a retryable HTTP response. That is useful for a read request, but it still has a bounded attempt count. “Retry until it works” is not a reliability strategy.
Why add jitter instead of a fixed delay?
Imagine 5,000 clients receive a 503 at the same time. With a fixed 500 ms delay, they all return together 500 ms later and hit an already struggling dependency as one wave. With exponential backoff they return less often, but they can still synchronize. Jitter randomizes the wait inside the allowed window.
This is a compact version of a broader reliability principle: failure handling should reduce pressure on the dependency, not amplify it. Google's SRE book calls cascading failure one of the hardest distributed-systems failure modes; retries are one of the ways healthy clients can accidentally make it worse.
The exact random distribution is a trade-off. Full jitter is easy to reason about and a good default for this drill. In a real service, also enforce a request deadline, observe retry counts, and coordinate with the dependency owner's rate-limit guidance.
Test the behavior without a flaky endpoint
The following fake returns two 503 responses and then succeeds. It exercises the success-after-retry path without relying on a public test service.
let calls = 0;
const fakeFetch = async () => ({
ok: ++calls === 3,
status: calls < 3 ? 503 : 200,
headers: new Headers(),
json: async () => ({ source: "retry drill", calls }),
});
console.log(
await fetchJson("https://example.test", {
baseDelayMs: 1,
fetchImpl: fakeFetch,
})
);
// { source: 'retry drill', calls: 3 }
Now change the fake status to 400. A strong implementation should fail after one call. Change it to 429 and add Retry-After: 2; explain why the server's explicit wait should take priority over a locally guessed delay. Finally, pass an AbortController signal and abort it during sleep; the promise should reject rather than quietly continuing.
Those three edits test the judgment an interviewer is actually looking for.
How would you narrate this in an interview?
Use this order when the prompt is “How would you make an API client resilient?”
- Scope the operation. “Is this idempotent? What is the caller's deadline and user impact?”
-
Classify failures. “I would retry transient transport errors and selected overload/server responses, not every
4xx.” -
Bound and spread retries. “I would cap attempts, use backoff plus jitter, and respect
Retry-Afterfor rate limiting.” - Preserve cancellation. “A canceled request should not remain alive just because a retry timer exists.”
- Close the loop. “I would log retry reason and count, and alert on an elevated retry rate before it becomes an outage.”
Notice that none of this requires reciting a particular library API. The code is evidence that you can implement the policy; the policy is evidence that you understand the system around it.
Common answers that sound plausible but fail follow-ups
-
“I retry all errors three times.” Ask what a
401or malformed request gains from repetition. - “I use exponential backoff.” Ask what happens when thousands of clients choose the same schedule.
- “The backend will handle it.” Ask how the client behaves while the backend is degraded.
- “I catch errors.” Ask whether that preserves a caller's cancellation and latency budget.
Turn each weak answer into a concrete decision in your own code. That gives you a truthful story to reuse, even if the interview uses Kafka, Kubernetes, or a vendor SDK rather than fetch.
A short practice loop
Set a timer for 15 minutes:
- Spend 5 minutes implementing the helper from memory.
- Spend 5 minutes changing the fake response sequence and explaining each branch aloud.
- Spend 5 minutes answering: “Would you use the same policy for a POST that creates an order?” Your answer should include idempotency keys or a refusal to retry.
If spoken practice is where you lose the thread, aceround.app — AI interview assistant can be used to rehearse the explanation and surface follow-up prompts. The goal is still to defend your own decisions, not memorize a script.
References
-
MDN: Using Fetch for
fetch, response handling, and cancellation behavior. - Google SRE Book: Addressing Cascading Failures for the reliability rationale behind bounded, jittered retries.
Disclosure: AI assisted with an initial outline and copy edit. The code path, technical claims, and references were reviewed before publication.
Top comments (1)
The strongest choice here is giving cancellation priority over persistence. It is easy to add exponential backoff and still miss that an aborted request should kill the timer immediately. I also like the three tiny tests: 400 fails once, 429 honors Retry-After, and an AbortController interrupts sleep. Together they test policy rather than library trivia. One addition I would make in production is a total time budget across all attempts, not just an attempt count. Four legal retries can still violate the user-facing latency promise if each request runs long.