Prevent duplicate payments, double orders, and data corruption. This guide covers Idempotency Keys— the pattern used by Stripe, AWS, and every payment processor to guarantee exactly-once processing.
How to use: Toggle between Visual to understand the pattern and Code to implement it. Try the simulator to see idempotency in action.
The Problem
Network fails mid-request. User clicks "Pay" twice. Your retry logic fires 3 times. Without idempotency, you charge the customer $300 instead of $100.
The Solution: Idempotency Keys
Client generates a unique key for each logical operation. Server stores the key with the response. If the same key arrives again, return the cached response instead of processing again.
Idempotency Simulator
Client
Server
How It Works
Store key + response
Don't reprocess
Express Middleware
const idempotencyCache = new Map();
function idempotent(req, res, next) {
const key = req.headers['idempotency-key'];
if (!key) {
return res.status(400).json({
error: 'Idempotency-Key header required'
});
}
// Check if we've seen this key before
const cached = idempotencyCache.get(key);
if (cached) {
return res.status(cached.status).json(cached.body);
}
// Intercept the response to cache it
const originalJson = res.json.bind(res);
res.json = (body) => {
idempotencyCache.set(key, {
status: res.statusCode,
body,
timestamp: Date.now()
});
return originalJson(body);
};
next();
}
// Usage
app.post('/payments', idempotent, async (req, res) => {
const payment = await processPayment(req.body);
res.json(payment); // Automatically cached
});Client-Side: Generate & Retry
async function chargeWithRetry(amount, maxRetries = 3) {
// Generate key ONCE for this logical operation
const idempotencyKey = `pay_${crypto.randomUUID()}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey // Same key for retries!
},
body: JSON.stringify({ amount })
});
return await response.json();
} catch (err) {
if (attempt === maxRetries - 1) throw err;
await sleep(1000 * (attempt + 1)); // Exponential backoff
}
}
}Production: Redis + PostgreSQL
-- PostgreSQL: Store idempotency keys with responses
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
response JSONB NOT NULL,
status_code INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);
-- Index for cleanup job
CREATE INDEX idx_expires ON idempotency_keys(expires_at);// Node.js with Redis for locking + Postgres for storage
async function handleIdempotent(key, processor) {
// 1. Try to get cached response
const cached = await db.query(
'SELECT response, status_code FROM idempotency_keys WHERE key = $1',
[key]
);
if (cached.rows[0]) {
return cached.rows[0]; // Return cached!
}
// 2. Acquire distributed lock (prevent race condition)
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 30);
if (!lock) {
throw new Error('Request in progress'); // 409 Conflict
}
try {
// 3. Process the request
const result = await processor();
// 4. Store response for future retries
await db.query(
'INSERT INTO idempotency_keys (key, response, status_code) VALUES ($1, $2, $3)',
[key, result.body, result.status]
);
return result;
} finally {
await redis.del(`lock:${key}`);
}
}- Keys should expire (24h is common) to prevent unbounded growth
- Return
409 Conflictif same key is in-flight (being processed) - Include request body hash to detect key reuse with different data
When to Use
- Payment processing (charges, refunds)
- Order creation / inventory updates
- Any non-reversible mutation
- Webhook handlers
When to Skip
- GET requests (naturally idempotent)
- DELETE by ID (already idempotent)
- Stateless operations
Edge Cases & Pitfalls
Real-World Examples
Stripe: Idempotency-Key header on all POST requests.
AWS: ClientToken parameter.
PayPal: PayPal-Request-Id header.