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();
}
Step-by-Step Walkthrough
-
Mint the key before the first attempt. Every retry reuses it; a fresh key per attempt is what turns a retry into a duplicate.
-
Classify before retrying. A 422 retried is a wasted round trip; a 409 retried is a lost edit.
-
Honour
Retry-After. A server that names a time knows something you do not. -
Jitter the delay. Full jitter β a random value between zero and the exponential bound β prevents a synchronised thundering herd.
-
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.
-
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.
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
- Handling Double Submit and Idempotency β the key that makes retrying safe
- Rolling Back Optimistic Updates on Failure β what happens when retries run out
- Server Error Reconciliation β the classification this depends on
β 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.