A popular DEV discussion recently asked to retry or not to retry. The honest answer is: retry, but only once you've made it safe. On mobile networks, where requests time out after the server has already done the work, the dangerous case isn't "the request failed". It's "the client doesn't know whether it succeeded."
This post covers three things:
- Which requests are safe to retry by definition.
- How to make the unsafe ones (usually
POST) safe with an idempotency key stored in Postgres, in both Spring Boot and Node. - How to retry politely: exponential backoff with jitter.
1. What HTTP already guarantees
RFC 9110 defines a method as idempotent if the intended effect of several identical requests is the same as one. GET, HEAD, OPTIONS, PUT and DELETE are idempotent; POST and PATCH are not.
That's a contract about intended effect, so your implementation has to honour it (a PUT that appends a row isn't idempotent, whatever the RFC says). But it gives a clear starting point:
- Idempotent methods: retry with backoff.
-
POSTwith an idempotency key: retry with the same key. -
POSTwithout a key: don't auto-retry. Query the status first. - 4xx like 400/401/403/422: don't retry; the request itself is wrong.
-
429 / 503: retry, honour
Retry-Afterif present, add jitter.
2. Idempotency keys
The pattern, popularised by payment APIs like Stripe and being standardised by the IETF HTTPAPI working group as the Idempotency-Key header (an Internet-Draft, not yet an RFC), is simple:
- The client generates a unique key (a UUID) once per logical operation and sends it on every attempt.
- The server records the key before doing the work. If it sees the same key again, it returns the stored result instead of redoing the work.
The table
CREATE TABLE idempotency_keys (
key text NOT NULL,
user_id bigint NOT NULL,
request_hash text NOT NULL,
status text NOT NULL DEFAULT 'in_progress', -- in_progress | done
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, key)
);
Two details matter:
- Scope keys per user/client. Two users must never collide on the same key.
-
Store a hash of the request body. If the same key arrives with a different payload, that's a client bug: reject it (Stripe and the IETF draft both treat key reuse with a different payload as an error; the draft suggests
422).
Claiming a key atomically
Postgres' INSERT ... ON CONFLICT DO NOTHING gives you an atomic "claim": exactly one concurrent request inserts the row, the others get zero rows back.
INSERT INTO idempotency_keys (key, user_id, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO NOTHING
RETURNING key;
-
One row returned: you own the key, do the work, then update
status='done'with the response. -
No row returned: load the existing row.
-
doneand same hash → replay the stored response. -
in_progress→ return409 Conflictso the client retries later. - different hash →
422.
-
Ideally, do the business write and the "done" update in the same transaction, so you never end up with work done and no stored result.
Spring Boot
@PostMapping("/payments")
public ResponseEntity<?> create(@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest req,
@AuthenticationPrincipal User user) {
String hash = DigestUtils.sha256Hex(json.writeValueAsString(req));
int claimed = jdbc.update("""
INSERT INTO idempotency_keys (key, user_id, request_hash)
VALUES (?, ?, ?) ON CONFLICT (user_id, key) DO NOTHING""",
key, user.getId(), hash);
if (claimed == 0) {
return idempotency.replayOrConflict(user.getId(), key, hash);
}
PaymentResult result = payments.execute(req, user, key); // stores result + status='done'
return ResponseEntity.status(201).body(result);
}
(DigestUtils here is Apache Commons Codec; replayOrConflict implements the three branches above. Exception handling trimmed for readability.)
Node (Express + node-postgres)
app.post("/payments", async (req, res) => {
const key = req.get("Idempotency-Key");
if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
const hash = sha256(JSON.stringify(req.body));
const claim = await pool.query(
`INSERT INTO idempotency_keys (key, user_id, request_hash)
VALUES ($1, $2, $3) ON CONFLICT (user_id, key) DO NOTHING RETURNING key`,
[key, req.user.id, hash]
);
if (claim.rowCount === 0) return replayOrConflict(req.user.id, key, hash, res);
const result = await executePayment(req.body, req.user, key); // stores result, status='done'
res.status(201).json(result);
});
Expiry
Keys don't need to live forever. Stripe documents (for its API v1) that keys can be removed after they're at least 24 hours old. Pick a window longer than your longest client retry, and clean up with a scheduled job:
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
3. Retrying politely: backoff with jitter
If every client retries at exactly 1s, 2s, 4s, they all hit your recovering server at the same moments. AWS's architecture blog post on exponential backoff and jitter compares strategies and shows that adding randomness spreads retries out. "Full jitter" is the simplest:
async function withRetry<T>(fn: () => Promise<T>, max = 5, base = 300, cap = 10_000) {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= max - 1 || !isRetryable(err)) throw err;
const delay = Math.random() * Math.min(cap, base * 2 ** attempt);
await new Promise(r => setTimeout(r, delay));
}
}
}
Crucially, the idempotency key is created outside this function, once, so every attempt reuses it:
const key = crypto.randomUUID();
await withRetry(() => api.post("/payments", body, { headers: { "Idempotency-Key": key } }));
When not to retry
- Validation and auth errors. Retrying a 422 just produces another 422.
- Non-idempotent calls without a key. Show the user a "checking status" state and query the server instead.
- When the user is waiting. Cap total retry time. A spinner that lasts a minute is worse than a clear error with a "try again" button.
- Retries at every layer. If the mobile client, the API gateway and the backend's HTTP client all retry 3 times, one failure can become 27 calls. Retry at one layer.
Checklist
- [ ] Every non-idempotent endpoint that clients may retry accepts an
Idempotency-Key. - [ ] Keys are scoped per user and stored with a request hash.
- [ ] The claim uses
INSERT ... ON CONFLICT DO NOTHING. - [ ] In-progress duplicates get
409, mismatched payloads get422. - [ ] Clients generate the key once, outside the retry loop.
- [ ] Backoff uses jitter and a cap; only one layer retries.
- [ ] Keys expire after a documented window.
What's the worst duplicate-request bug you've shipped (or caught just in time)?


Top comments (0)