In distributed systems failure isn't the exception: it's the baseline. Networks drop, timeouts fire, and clients retry. The problem isn't the retry itself — it's that a retry on an operation that already ran can create a double charge, a duplicate subscription, or an email sent twice. Idempotency is the property that makes repeating an operation harmless.
An operation is idempotent if applying it multiple times yields the same result as applying it once. GET and PUT usually are by design, but state-changing POSTs almost never are — and that's exactly where the risk lives.
1. Stop chasing perfect delivery
The practical 2026 rule is simple: delivery is at-least-once, not exactly-once. Accept that messages can duplicate and make repetition innocuous. Every state-changing POST or PATCH accepts an idempotency key; GET, PUT, and DELETE don't need one.
2. Idempotency-Key: the canonical pattern
The client generates a UUID and sends it in the Idempotency-Key header. The server records the first execution's result against that key and, on a retry with the same key, returns the stored response instead of reprocessing.
// Idempotency middleware in Express
const store = new Map(); // in production: Redis with TTL
async function idempotency(req, res, next) {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });
const prev = store.get(key);
if (prev) {
res.set('Idempotency-Replay', 'true');
return res.status(prev.status).json(prev.body);
}
const originalJson = res.json.bind(res);
res.json = (body) => {
store.set(key, { status: res.statusCode, body, ts: Date.now() });
return originalJson(body);
};
next();
}
The critical detail: the response must be stored after a successful execution, and the retry must return the same status and body. The Idempotency-Replay: true header tells the client it received a cached response.
3. The client shares the responsibility
A safe retry reuses the same key and only retries on transient errors (5xx or timeout). 4xx responses are not retried: they signal a request problem, not a network problem.
// Client with idempotent retry (at-least-once)
async function charge(card, amount, idemKey) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await post('/v1/charges', { card, amount }, {
headers: { 'Idempotency-Key': idemKey }
});
} catch (err) {
if (err.status >= 500) await sleep(2 ** attempt * 200);
else throw err; // don't retry 4xx
}
}
} - Exponential backoff: growing wait between attempts to avoid overwhelming the service.
- Jitter: add random noise to avoid a synchronized retry storm.
- Stable key: the same business operation always uses the same key, even if the transport fails.
4. Where to store the state?
The idempotency store should be fast, shared across instances, and expiring. Redis with a TTL is the usual choice: the key lives long enough to cover the retry window and then frees itself.
# Redis: automatic key expiration
SET idem:9f2c-a1b8 "{\"status\":201,\"body\":{...}}" EX 86400
# The key expires in 24h; it covers the client's
# retry window without growing without bound. 5. Common anti-patterns
- Per-request key: generating a fresh UUID on every retry defeats deduplication.
- Short TTL: if it expires before the last retry, you duplicate the operation.
- Store-before-confirm: blocks valid retries after a partial failure.
- Ignore concurrency: two calls with the same key in parallel can both process; use a lock or atomic upsert.
Summary
Idempotency isn't an optimization: it's the foundation of an API that survives the real network. Accept at-least-once, sign every operation with a stable key, and have the server remember the outcome. That turns retries from an incident source into a safety net.