# Idempotency

> Use when creating mutation endpoints. Use when trusting frontend to prevent duplicates. Use when payments or critical operations can be repeated.

- Skill: `yanko-belov/idempotency` (Agent Skill)
- Install (CLI): `npx skillmds@latest add yanko-belov/idempotency`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yanko-belov/idempotency/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: yanko-belov (https://skillmd.com/u/yanko-belov)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yanko-belov/idempotency

---


# Idempotency

## Overview

**Critical operations must be safe to retry. Use idempotency keys.**

Networks fail. Clients retry. Users double-click. Without idempotency, retries cause duplicate charges, orders, or data corruption.

## When to Use

- Payment processing endpoints
- Order creation
- Any operation that shouldn't happen twice
- Asked to "trust the frontend" to prevent duplicates

## The Iron Rule

```
NEVER rely on frontend to prevent duplicate requests.
```

**No exceptions:**
- Not for "frontend disables the button"
- Not for "we show a loading state"
- Not for "it rarely happens"
- Not for "users won't double-click"

## Detection: Duplicate Risk Smell

If mutations have no duplicate protection, STOP:

```typescript
// ❌ VIOLATION: No idempotency protection
app.post('/payments', async (req, res) => {
  const { userId, amount, cardToken } = req.body;
  
  // If this request retries, user gets charged twice!
  const payment = await stripeCharge(amount, cardToken);
  await db.payments.create({ userId, amount, stripeId: payment.id });
  
  res.json({ success: true });
});
```

What can go wrong:
- Network timeout → client retries → double charge
- User double-clicks → two requests → double charge
- Mobile app retry logic → multiple requests

## The Correct Pattern: Idempotency Keys

```typescript
// ✅ CORRECT: Idempotency key protection

app.post('/payments', async (req, res) => {
  // Require idempotency key
  const idempotencyKey = req.headers['idempotency-key'];
  if (!idempotencyKey) {
    return res.status(400).json({ 
      error: 'Idempotency-Key header is required' 
    });
  }
  
  const { userId, amount, cardToken } = validated(req.body);
  
  // Check for existing request with this key
  const existing = await db.idempotencyKeys.findOne({
    where: { key: idempotencyKey, userId }
  });
  
  if (existing) {
    // Return cached response
    return res.status(existing.statusCode).json(existing.response);
  }
  
  try {
    // Process the payment
    const payment = await stripeCharge(amount, cardToken);
    await db.payments.create({ userId, amount, stripeId: payment.id });
    
    const response = { success: true, paymentId: payment.id };
    
    // Cache the response
    await db.idempotencyKeys.create({
      key: idempotencyKey,
      userId,
      statusCode: 200,
      response,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
    });
    
    res.json(response);
  } catch (error) {
    // Cache error responses too (optional, depends on error type)
    throw error;
  }
});

// Client usage:
// POST /payments
// Headers: { "Idempotency-Key": "user-123-order-456-attempt-1" }
```

## Idempotency Key Design

### Key Generation (Client Side)
```typescript
// Option 1: UUID per request
const key = crypto.randomUUID();

// Option 2: Deterministic (better for retries)
const key = `${userId}-${orderId}-${timestamp}`;

// Option 3: Hash of request content
const key = hash(JSON.stringify({ userId, items, amount }));
```

### Key Storage (Server Side)
```typescript
interface IdempotencyRecord {
  key: string;
  userId: string;
  statusCode: number;
  response: any;
  createdAt: Date;
  expiresAt: Date;  // Clean up old keys
}
```

## What Needs Idempotency

| Operation | Risk | Solution |
|-----------|------|----------|
| Payments | Double charge | Idempotency key |
| Order creation | Duplicate orders | Idempotency key |
| Inventory decrement | Over-decrement | Idempotency key |
| Email sending | Duplicate emails | Idempotency key |
| Account creation | Duplicate accounts | Unique constraint + idempotency |

## Pressure Resistance Protocol

### 1. "Frontend Prevents Duplicates"
**Pressure:** "We disable the button, show loading state"

**Response:** Networks retry automatically. JavaScript crashes. Users have fast fingers.

**Action:** Backend idempotency. Frontend UX is not protection.

### 2. "It Rarely Happens"
**Pressure:** "Duplicates are rare edge cases"

**Response:** Rare × many users = many angry users. One duplicate charge = support nightmare.

**Action:** Protect all critical mutations.

### 3. "Users Won't Double-Click"
**Pressure:** "Our users are careful"

**Response:** Users have slow connections. Buttons are small. Frustration leads to clicking.

**Action:** Never rely on user behavior.

### 4. "Database Has Unique Constraint"
**Pressure:** "Duplicate insert will fail"

**Response:** Unique constraint throws error. User sees error. UX is terrible.

**Action:** Idempotency returns same success response.

## Red Flags - STOP and Reconsider

- Payment endpoints without idempotency
- "Frontend handles duplicate prevention"
- Network retries causing side effects
- Users reporting double charges
- No Idempotency-Key header support

**All of these mean: Add idempotency protection.**

## Quick Reference

| Unsafe | Safe |
|--------|------|
| Trust frontend | Require idempotency key |
| Error on duplicate | Return cached response |
| Assume single request | Design for retries |
| POST = new resource always | POST + key = at-most-once |

## Common Rationalizations (All Invalid)

| Excuse | Reality |
|--------|---------|
| "Frontend prevents it" | Networks retry. Users double-click. |
| "Rarely happens" | Rare × scale = many incidents. |
| "Users are careful" | Users are human. |
| "Unique constraint" | Constraints throw errors, not success. |
| "Too complex" | Simpler than handling support tickets. |

## The Bottom Line

**Require idempotency keys for all critical mutations.**

Never trust frontend protection. Cache responses by idempotency key. Return the same response for duplicate requests. Clean up old keys periodically.

