Idempotency - Complete Deep Dive

Prerequisites: API Design, Distributed Locking Used in: Digital Wallet, Payment Gateway, Notification System


What is Idempotency?

An operation is idempotent if performing it multiple times produces the same result as performing it once. No matter how many times you retry, the side effect happens exactly once.

Real-world analogy: An elevator button. You press “3” once — the elevator goes to floor 3. You press it 5 more times frantically — it still just goes to floor 3. The button is idempotent. Compare with a vending machine button: press it 5 times, get 5 sodas (not idempotent).

Idempotent:
  SET balance = 100        (do it 5 times → balance is still 100)
  DELETE /orders/123       (do it 5 times → order is still deleted)
  PUT /users/123 {name: X} (do it 5 times → name is still X)

NOT Idempotent:
  balance += 100           (do it 5 times → balance increases by 500!)
  POST /orders {items:[]}  (do it 5 times → 5 orders created!)
  INSERT INTO ledger(...)  (do it 5 times → 5 rows inserted!)

Why Does It Matter?

In distributed systems, requests can be retried due to:

sequenceDiagram
    participant Client
    participant Server
    participant DB

    Client->>Server: POST /pay $100
    Server->>DB: INSERT payment
    DB-->>Server: OK
    Note over Client,Server: Network timeout - no response
    Client->>Server: POST /pay $100 (retry!)
    Server->>DB: INSERT payment (DUPLICATE!)
    Note over DB: User charged $200 instead of $100!

Without idempotency: Network failures, timeouts, load balancer retries, and message queue redelivery all cause duplicate processing.


HTTP Methods and Idempotency

Method Idempotent? Safe? Why
GET Yes Yes Just reads, no side effects
HEAD Yes Yes Like GET but no body
PUT Yes No Sets to specific state, repeatable
DELETE Yes No Deleting deleted resource = no-op
POST No No Creates new resource each time
PATCH Depends No Can be idempotent if designed well

Key insight: POST is the problematic one. Creating a payment, sending a notification, placing an order — all are POST operations that must be made idempotent.


Idempotency Keys — The Solution

Client generates a unique key per logical operation. Server uses it to detect retries.

First attempt:
  POST /payments
  Idempotency-Key: "pay_a1b2c3d4e5f6"
  { "amount": 100, "to": "merchant_xyz" }

  Server: key "pay_a1b2c3d4e5f6" not seen before
          → process payment → store result with key
          → return 200 { "paymentId": "pm_789" }

Retry (network timeout on first attempt):
  POST /payments
  Idempotency-Key: "pay_a1b2c3d4e5f6"    ← same key!
  { "amount": 100, "to": "merchant_xyz" }

  Server: key "pay_a1b2c3d4e5f6" found!
          → return stored result: 200 { "paymentId": "pm_789" }
          → NO duplicate payment created

Implementation with Redis

flowchart TD
    A[1. Request with Idempotency-Key] --> B{Key exists in Redis?}
    B -->|YES| C[Return stored response - skip logic]
    B -->|NO| D[3. Acquire lock SET NX EX 30]
    D --> E[4. Process request - business logic]
    E --> F[5. Store result SET key response EX 86400]
    F --> G[6. Release lock]
    G --> H[7. Return response]

    classDef client fill:#f97316,stroke:#c2410c,color:#fff
    classDef service fill:#10b981,stroke:#065f46,color:#fff
    classDef data fill:#fbbf24,stroke:#92400e,color:#000
    class A client
    class B,D,E,G,H service
    class C,F data
# Pseudocode implementation
def handle_payment(request):
    idempotency_key = request.headers["Idempotency-Key"]

    # Step 1: Check if already processed
    cached = redis.get(f"idempotency:{idempotency_key}")
    if cached:
        return deserialize(cached)  # Return stored response

    # Step 2: Acquire lock (prevent race condition)
    lock_acquired = redis.set(
        f"idempotency:{idempotency_key}:lock",
        "1", nx=True, ex=30  # 30s lock timeout
    )
    if not lock_acquired:
        return 409  # Another request with same key in progress

    try:
        # Step 3: Process payment (actual business logic)
        result = payment_service.charge(request.body)

        # Step 4: Store response (24h TTL)
        redis.set(
            f"idempotency:{idempotency_key}",
            serialize(result),
            ex=86400  # 24 hours
        )
        return result
    finally:
        # Step 5: Release lock
        redis.delete(f"idempotency:{idempotency_key}:lock")

Implementation with Database

For stronger durability (Redis can lose data on restart):

CREATE TABLE idempotency_keys (
    key         VARCHAR(255) PRIMARY KEY,
    response    JSONB NOT NULL,
    status      VARCHAR(20) DEFAULT 'processing',  -- processing | completed
    created_at  TIMESTAMP DEFAULT NOW(),
    expires_at  TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);

-- Step 1: Try to insert (fails if duplicate)
INSERT INTO idempotency_keys (key, status)
VALUES ('pay_a1b2c3d4', 'processing')
ON CONFLICT (key) DO NOTHING;

-- If insert succeeded → process payment
-- If insert failed → fetch stored response
SELECT response FROM idempotency_keys WHERE key = 'pay_a1b2c3d4';

At-Least-Once + Idempotency = Exactly-Once

This is a crucial formula in distributed systems:

Delivery Guarantees:
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  At-Most-Once:  Fire and forget. May lose messages.         │
│                 (UDP, no retries)                            │
│                                                             │
│  At-Least-Once: Retry until acknowledged. May duplicate.    │
│                 (Kafka default, SQS, most queues)           │
│                                                             │
│  Exactly-Once:  Each message processed exactly once.        │
│                 (The holy grail — hard to achieve natively)  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

The trick:
  At-Least-Once delivery + Idempotent consumers = Exactly-Once semantics

  Queue delivers message 3 times (retries)?
  Consumer processes it only once (idempotency key = message ID).
  Net effect: exactly-once processing.
flowchart LR
    A[Producer] --> B[Queue<br/>retries]
    B --> C[Consumer - idempotent]

    classDef client fill:#f97316,stroke:#c2410c,color:#fff
    classDef async fill:#818cf8,stroke:#4338ca,color:#fff
    classDef service fill:#10b981,stroke:#065f46,color:#fff
    class A client
    class B async
    class C service
Consumer logic:
  if msg_id seen → skip (already done)
  else → process + store msg_id

Patterns for Making Operations Idempotent

Pattern How Example
Idempotency Key Client sends unique key, server deduplicates Payment creation
Natural Idempotency Key Use business identifier as key order_id + action = natural key
Conditional Write Only write if current state matches expected UPDATE SET x=new WHERE x=old
Upsert Insert or update if exists INSERT ON CONFLICT UPDATE
Token/Claim Pre-allocate a token, claim it once Seat reservation tokens
Deduplication Table Store processed message IDs Queue consumer dedup

When to Use / When NOT to Use

When to Use:

When NOT to Use:


Real-World Examples

Company Implementation
Stripe Requires Idempotency-Key header on all POST requests. Keys stored 24h. Same key returns cached response.
PayPal Uses PayPal-Request-Id header. Prevents duplicate payment execution.
AWS Many APIs use client tokens (e.g., ClientToken in EC2 RunInstances).
Google Pay requestId field in payment API. Same ID = same payment, no duplicates.
Razorpay Idempotency key on payment capture. Retrying capture with same key is safe.

Common Interview Questions

Q: “How do you prevent double payments in a payment system?” A: Use idempotency keys. Client generates a UUID per payment attempt and sends it with the request. Server checks Redis/DB for the key. If found, returns stored response. If not, processes payment and stores result with the key. Lock during processing prevents race conditions from concurrent retries.

Q: “What happens if the server crashes after processing but before storing the idempotency key?” A: This is the partial failure problem. Solution: wrap the business logic and idempotency key storage in a single database transaction. If using Redis for idempotency and Postgres for payments, use the Outbox Pattern: write payment + idempotency record to Postgres atomically, then sync to Redis asynchronously.

Q: “How long should you keep idempotency keys?” A: Typically 24-72 hours. Long enough for all possible retries to complete. Short enough to not consume excessive storage. Stripe uses 24 hours. After expiry, the same key can be reused (treated as new request).

Q: “What if two different requests use the same idempotency key?” A: Compare request bodies. If the key exists but the request body differs from the stored one, return 422 Unprocessable Entity with an error explaining the key was used with different parameters. This prevents misuse and bugs.

Q: “How is idempotency different from deduplication?” A: Idempotency is a broader concept — the operation itself is safe to retry. Deduplication is one technique to achieve it (track processed IDs and skip duplicates). Other techniques include conditional writes, upserts, and token-based claiming.


← Back to Fundamentals Next: Leader Election →

💬 Comments