Frontend interviews increasingly include a round where the material is not an algorithm but the protocol the product runs on. The facts are few — status classes, idempotency, the anatomy of a URL, what a retry may safely re-send — and each one is not trivia but a policy decision in disguise: who retries, what may be repeated, how long to wait. Learn the decisions and the facts stop being memorization.

Status centuries decide who retries

Five classes, one hundred codes each, and the split that matters is fault ownership: 4xx is the caller's mistake, 5xx is the server's. That split is the retry policy's first draft.

export function statusClass(code: number): StatusClass {
  if (code >= 500) return 'server error'
  if (code >= 400) return 'client error'
  if (code >= 300) return 'redirect'
  if (code >= 200) return 'success'
  return 'informational'
}

A 503 with a Retry-After header is the server asking for a retry; a 401 is the client's token, and re-sending it unchanged fails identically forever; a 429 is a rate limit, where retrying immediately is the one guaranteed-wrong move. The boundaries are 200/300/400/500 sharp — 418 is a client error, however charming — and the years of API work compress into remembering that the class, not the exact code, usually names the response's policy.

Idempotency decides what may be repeated

Two properties, often confused. Safe means the method does not change server state — GET, HEAD, OPTIONS. Idempotent means sending it again yields the same result — safe methods plus PUT (the second identical put finds nothing new to set) and DELETE (deleting a deleted thing is the same state). POST and PATCH promise neither: a replayed POST can book a second ticket.

export function methodProfile(method: string): { idempotent: boolean; safe: boolean } {
  switch (method.toUpperCase()) {
    case 'GET':
    case 'HEAD':
    case 'OPTIONS':
      return { idempotent: true, safe: true }
    case 'PUT':
    case 'DELETE':
      return { idempotent: true, safe: false }
    case 'POST':
    case 'PATCH':
      return { idempotent: false, safe: false }
    default:
      return { idempotent: false, safe: false }
  }
}

This table is why a timeout is ambiguous and how to resolve it: the request may have died on the way out or on the way back, so the operation may already have happened. Re-issuing is safe exactly when the method is idempotent — auto-retry a timed-out GET with a clear conscience; auto-retry a POST and you are gambling with a double-charge. The escape hatch for non-idempotent operations is an idempotency key (a header the server uses to recognize replays), which converts a gamble into a lookup.

fill it in

1 blank · graded here, free

case 'PUT':
case 'DELETE':
  return { idempotent: , safe: false }

type into the gaps, then check

A retry loop is three decisions, not a loop

Exponential back with jitter, circuit breaking, budgets — the distributed-systems vocabulary grows fast. Underneath, a correct retry is three answers. How many attempts: one initial plus retries more — the word counts the extras, and a loop written as attempt < retries quietly deletes the final allowed try. How long to wait: doubling from a base — 100, 200, 400 — and never after the last failure, whose job is to fail fast, not to sleep politely. Whose error survives: the upstream's last error, verbatim, because a generic wrapper throws away the status and message the on-call engineer needed.

export async function fetchWithRetry<T>(
  load: () => Promise<T>,
  options: { retries: number; baseDelayMs: number },
  sleep: (ms: number) => Promise<void>
): Promise<T> {
  let lastError: unknown

  for (let attempt = 0; attempt <= options.retries; attempt++) {
    if (attempt > 0) {
      await sleep(options.baseDelayMs * 2 ** (attempt - 1))
    }
    try {
      return await load()
    } catch (error) {
      lastError = error
    }
  }
  throw lastError
}

The sleep sits at the top of the loop, which is what makes "never after the final failure" structural instead of aspirational — the last iteration never re-enters the loop, so it never sleeps. And sleep arrives as a parameter, the same clock-injection rule as every primitive in the caches chapter: a retry that sleeps on real timers cannot be tested at speed, and a retry suite that actually waits 400 ms per case will be deleted by whoever owns the CI budget.

fill it in

1 blank · graded here, free

for (let attempt = 0; attempt  options.retries; attempt++) {
  if (attempt > 0) {
    await sleep(options.baseDelayMs * 2 ** (attempt - 1))
  }
  try {
    return await load()
  } catch (error) {
    lastError = error
  }
}

type into the gaps, then check

The URL, taken apart by hand

Every framework owns a URL parser; interviews ask you to be the framework. The anatomy is three separators with meaning: :// splits the scheme, the first / after the host starts the path, and ? starts the query. The discipline is the same as any parsing: split on the outermost separator first, and never re-split what a segment owns — a query value may legally contain an encoded %3F that is not a separator. The defaults are contract too: no port means 80 for http and 443 for https; no path means /; a key with no = is a flag with the empty string for a value. The practice exercises for this concept grade exactly these edges — decoding, repeated keys, the lone ?.

How to debug a wire conversation

Log the triple that identifies a request — method, URL, attempt number — and the status that answered it. Most "the API is broken" mysteries resolve in that one line: three attempts where one was configured, a 429 nobody backed off from, a POST retried because a middleware classified timeouts as generic failures. When writing the client, make time and transport injectable (sleep, fetch as parameters) and assert on the sequence the fake recorded — sleeps [100, 200] and attempts 3 — because behavior that cannot be observed as a sequence cannot be regression-tested. And when a status surprises you, read the class before the number: the century names the policy, and the code only refines it.

Where this bites

  • Auto-retrying everything that fails. Timeouts and 5xx on idempotent methods, yes; 4xx and non-idempotent methods, no — the retry succeeds in making the same mistake faster. Counter: the retry predicate starts from method idempotency and status class, not from "it threw".
  • Counting retries as total attempts. attempt < retries runs one try fewer than the contract, and with retries: 0 never calls the function at all — the bug reports read "the feature is off". Counter: attempts are 1 + retries; write attempt <= retries and test the zero case first.
  • Wrapping the upstream error. A generic "gave up after N attempts" discards the 503 and its Retry-After; the operator sees effort, not cause. Counter: the loop's last caught error is the one thrown — store it on every failure, throw it after the loop.
  • Fixed-delay retries on a limiter. Hammering every 100 ms is a denial of service with good manners; doubling is not sophistication, it is the minimum courtesy that gives a recovering server room. Counter: backoff is a property of the remote's recovery time, which is why it doubles — and why jitter (randomizing the delay) is the next addition once more than one client shares the wire.