The exact problem: a submit fails on a flaky connection, the form retries immediately three times, and the server β€” which received all three β€” creates three records.

Context and Prerequisites

Two prerequisites make retrying safe rather than dangerous: an idempotency key that survives the retry, described in handling double submit and idempotency, and a response classification that distinguishes β€œyour input was wrong” from β€œwe could not reach the service”, described in server error reconciliation. Retrying without either is how a network blip becomes a billing incident.

Core Pattern

interface RetryPolicy {
  readonly maxAttempts: number;
  readonly baseMs: number;
  readonly ceilingMs: number;
}

const DEFAULT: RetryPolicy = { maxAttempts: 4, baseMs: 500, ceilingMs: 20_000 };

/** Only these are worth retrying. Everything else is a decision, not a blip. */
function isRetryable(res: Response | null): boolean {
  if (res === null) return true;                    // network failure or timeout
  if (res.status === 429) return true;              // rate limited β€” honour Retry-After
  return res.status >= 500 && res.status !== 501;   // 501 will never succeed
}

function delayFor(attempt: number, res: Response | null, p: RetryPolicy): number {
  const header = res?.headers.get('retry-after');
  // A server that tells you when to come back is always right; obey it.
  if (header) return Number(header) * 1000;
  const exponential = Math.min(p.ceilingMs, p.baseMs * 2 ** attempt);
  // Full jitter: without it, every client that failed together retries together
  // and reproduces the load spike that caused the failure.
  return Math.random() * exponential;
}

export async function submitWithRetry(
  send: (key: string, signal: AbortSignal) => Promise<Response>,
  key: string, signal: AbortSignal, p: RetryPolicy = DEFAULT,
): Promise<Response> {
  let lastRes: Response | null = null;
  for (let attempt = 0; attempt < p.maxAttempts; attempt++) {
    if (signal.aborted) throw new DOMException('aborted', 'AbortError');
    try {
      // The SAME key on every attempt. This is what makes the retry safe:
      // a request the server already processed returns its original result.
      lastRes = await send(key, signal);
      if (lastRes.ok || !isRetryable(lastRes)) return lastRes;
    } catch (err) {
      if ((err as Error).name === 'AbortError') throw err;
      lastRes = null;
    }
    if (attempt === p.maxAttempts - 1) break;
    await sleep(delayFor(attempt, lastRes, p), signal);
  }
  return lastRes ?? Response.error();
}
Retry the failures that are about the network, not about the payload Retried: a network failure or timeout, where nothing is known about whether the request arrived; a 429, where the server has explicitly asked for a delay and usually named one; and a 5xx other than 501, where the service failed rather than the payload. Not retried: any 2xx, which succeeded; a 422 or other validation failure, where the same payload will be rejected identically; a 409 conflict, which needs reconciliation rather than repetition; a 401 or 403, which needs a different credential; and a 501, which will never succeed. Every retry carries the same idempotency key, so a request the server already processed returns its original result rather than creating a second record. retry β€” the network failed network failure or timeout nothing is known about whether it arrived 429 β€” honour Retry-After the server named a time; obey it 5xx, except 501 the service failed, not the payload do not retry β€” it is a decision 422 β€” the same payload fails the same way 409 β€” reconcile, do not repeat 401, 403 β€” a different credential is needed 501 β€” it will never succeed every retry reuses the same idempotency key Full jitter on the delay: without it, every client that failed at the same moment comes back at the same moment. A worked backoff, with full jitter With a base of five hundred milliseconds and a ceiling of twenty seconds, the exponential bound doubles each attempt: five hundred milliseconds, one second, two seconds, four seconds. Full jitter picks a random delay between zero and that bound, so the actual waits are shorter and, crucially, different for every client that failed at the same moment. The fourth attempt is the last, after which the reader gets a manual retry control rather than an indefinite wait. Attempt Exponential bound Actual wait Cumulative 1 β†’ 2 500ms 0–500ms, random under 1s 2 β†’ 3 1s 0–1s, random under 2s 3 β†’ 4 2s 0–2s, random under 4s after 4 β€” hand it to the reader a visible retry Four attempts inside about four seconds. Longer than that and an automatic retry stops being help and starts being a hang.

Step-by-Step Walkthrough

  1. Mint the key before the first attempt. Every retry reuses it; a fresh key per attempt is what turns a retry into a duplicate.

  2. Classify before retrying. A 422 retried is a wasted round trip; a 409 retried is a lost edit.

  3. Honour Retry-After. A server that names a time knows something you do not.

  4. Jitter the delay. Full jitter β€” a random value between zero and the exponential bound β€” prevents a synchronised thundering herd.

  5. Cap attempts and total time. Four attempts and a twenty-second ceiling is a reasonable default; beyond that the reader deserves a manual retry control instead.

  6. Keep the reader informed. β€œTrying again…” with an attempt count is honest; a spinner that never resolves is not.

Failure Modes and Edge Cases

1. Retrying a request that already succeeded

A response lost in transit looks identical to a request that never arrived. Only the idempotency key distinguishes them, which is why it is a prerequisite rather than an enhancement.

2. Retrying past a session expiry

A long backoff can outlive the token. Refresh before retrying, or the last attempt fails with a 401 that the classification correctly refuses to retry β€” and the reader is told to sign in, having waited twenty seconds for it.

3. Retrying after the reader navigated away

Abort in the teardown. A retry that resolves into an unmounted form writes nowhere at best.

4. Retrying a body that has been consumed

A ReadableStream body can be sent once. Serialise the payload before the loop and re-create the request each attempt.

5. Backoff that ignores reconnection

If the connection returns after two seconds of a twenty-second wait, waiting out the remainder looks broken. Listen for online and retry immediately.

What the reader is told while this happens During the automatic attempts the reader needs to know that something is still happening and that their work is safe, without being asked to do anything: a polite status that says it is trying again. Once the attempts are exhausted the message changes character β€” it becomes assertive, it says plainly that the submission did not go through, and it offers a control. Saying nothing during the retries and then failing silently is the version readers report as the button not working. while retrying "Trying again…" polite β€” no interruption their work is stated as safe no action asked of them once exhausted "Not submitted. Try again." assertive β€” it changes what to do a real, focusable retry control the draft is still intact Silence during the retries followed by silence at the end is the version readers report as "the button does nothing".

Verification Checklist

Common Pitfalls

  • Minting a key per attempt. Each retry then looks like an unrelated request, and a reader on a flaky connection is charged once per attempt. One key per intent, reused.
  • Retrying a 422. The payload was understood and rejected, so the same payload gets the same answer. The retry has to wait for the reader to change something.
  • Fixed delays. Every client that failed together comes back together, reproducing the load spike that caused the failure. Full jitter costs one call and prevents it.
  • Ignoring Retry-After. A server that names a time knows something about its own recovery that your exponential curve does not.
  • Retrying forever. A spinner that never resolves gives the reader nothing to act on and no idea whether their work is safe. Cap it and hand them a visible retry.

Related

← Submission State and Optimistic Updates

Frequently Asked Questions

Which responses should never be retried?

Anything that will fail identically the second time: a 422, because the payload is understood and rejected; a 409, because repeating the request does not resolve a conflict and can lose the reader’s edit; a 401 or 403, which need a different credential rather than another attempt; and a 501, which will never be implemented by that server. Everything else worth retrying is either a network failure, a rate limit, or a 5xx.

Why jitter the delay?

Because a service failure usually fails many clients at once, and if they all compute the same exponential delay they all come back at the same instant β€” reproducing the load spike that caused the failure, on a schedule. Full jitter, a random value between zero and the exponential bound, spreads the retries out. It is one Math.random call and it is the difference between a recovery and a second outage.

Should a retry be automatic or offered to the reader?

Automatic for the first few attempts, over a few seconds, with an honest status message β€” readers do not want to press a button because a packet was dropped. After the cap, hand it to them: a visible retry control with the reason. Silent indefinite retrying is the worst option, because a spinner that never resolves gives them nothing to act on and no idea whether their work is safe.