Idempotency keys: the one write that makes retries safe

A network timeout does not mean the server did nothing. An idempotency key identifies the same request so the server replays the first result instead of executing again.

A client sends a payment, the connection times out, it retries. If the first attempt actually succeeded, the money moved twice. A timeout is not a failure — that is the premise of every retry.

What the key does

The client generates one unique key per business intent (a UUID) and reuses it on retries:

POST /payments
Idempotency-Key: 6f4a1e2c-9b3d-4c8a-9f21-7e5d0b3a1c88

Server logic: insert a row keyed on that value. The first request really runs and stores its result; later requests with the same key return the stored result.

Implementation notes

async function handle(key: string, body: Body) {
  try {
    const row = await db.insert('idempotency', { key, state: 'in_progress' });
  } catch (e) {
    if (!isUniqueViolation(e)) throw e;
    // already exists: return the saved result or say it is running
    const existing = await db.get('idempotency', key);
    if (existing.state === 'done') return existing.response;
    return conflict('in progress');
  }
  const result = await doWork(body);
  await db.update('idempotency', key, { state: 'done', response: result });
  return result;
}

Three details that get skipped:

Detail If missing
Unique index at the database concurrent requests both insert and both run
Store the original response body a retry has to recompute, defeating the point
Keep an in_progress state cannot tell running from finished

Scope and lifetime

Bind the key to the authenticated principal plus the endpoint. Otherwise one user’s key can hit another user’s record.

Lifetime is a business decision: payments usually keep keys for a day to a few days. Too short drops genuine retries; too long grows the table forever. Clean up outside the business transaction so you never delete an in-flight record.

Request fingerprint

A strict implementation also stores a hash of the body to catch key reuse with different content:

if (existing.requestHash !== sha256(body)) {
  return unprocessable('key reused with different payload');
}

422 fits better than 409 here. It is a client programming error, not a state conflict.

When you do not need it

Naturally idempotent operations need no key: GET, a full-replacement PUT, DELETE. POST that creates a resource, anything that charges money, and anything that sends a message do. The test is whether running it twice produces two side effects.

An idempotency key does not make retries safe by itself. It gives the server a way to recognize that this is already the same request.

← Back to all posts

Comments

…