API Idempotency: Resilient Design for Safe Retries · Daniel Tinizaray

API Idempotency: Resilient Design for Safe Retries · Daniel Tinizaray

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.
⚠️ Red flag If you store the key before processing and processing fails, legitimate retries get blocked returning the error. Store the result only after success, or use an explicit intermediate state.

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.


Enjoyed this article?

If you're dealing with these challenges in your company, let's talk. No obligation. 30 minutes to understand your situation.

Book a free call →