User clicks "Pay Now". Request times out. They click again. Now theyve been charged twice.
This happens way more then you'd think. Networks are unreliable. Clients retry. If your API isnt idempotent, you get double charges, duplicate orders, and angry customers.
What Is Idempotency?
An operation is idempotent if doing it multiple times has the same effect as doing it once.
- GET: Always idempotent. Reading doesnt change anything.
- DELETE: Idempotent. Delete twice? Still deleted.
- PUT: Idempotent. Full replacement, same input = same result.
- POST: Usually NOT idempotent. Creates new resource each time.
- PATCH: Depends. Increment operations aren't idempotent.
The Idempotency Key Pattern
For non-idempotent operations, use a client-generated unique key:
Implementation
async function handlePayment(req: Request) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({
error: 'Idempotency-Key header required'
});
}
// Check if we've seen this key
const existing = await db.query(`
SELECT response, status_code
FROM idempotency_keys
WHERE key = $1 AND endpoint = $2
`, [idempotencyKey, '/payments']);
if (existing.rows[0]) {
// Return cached response
return res
.status(existing.rows[0].status_code)
.json(JSON.parse(existing.rows[0].response));
}
// Process the payment
const result = await processPayment(req.body);
// Store the response
await db.query(`
INSERT INTO idempotency_keys (key, endpoint, response, status_code)
VALUES ($1, $2, $3, $4)
`, [idempotencyKey, '/payments', JSON.stringify(result), 200]);
return res.json(result);
}
Key Storage Schema
CREATE TABLE idempotency_keys (
key VARCHAR(255) NOT NULL,
endpoint VARCHAR(255) NOT NULL,
response JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (key, endpoint)
);
-- Clean up old keys periodically
CREATE INDEX idx_created_at ON idempotency_keys(created_at);
Handle In-Progress Requests
What if the same request comes while we're still processing?
async function withIdempotency(key: string, fn: () => Promise<any>) {
// Try to acquire lock
const lock = await db.query(`
INSERT INTO idempotency_keys (key, status)
VALUES ($1, 'processing')
ON CONFLICT (key) DO NOTHING
RETURNING key
`, [key]);
if (!lock.rows[0]) {
// Key exists - check if done or still processing
const existing = await db.query(`
SELECT status, response FROM idempotency_keys WHERE key = $1
`, [key]);
if (existing.rows[0].status === 'processing') {
throw new ConflictError('Request in progress');
}
return JSON.parse(existing.rows[0].response);
}
try {
const result = await fn();
await db.query(`
UPDATE idempotency_keys
SET status = 'complete', response = $1
WHERE key = $2
`, [JSON.stringify(result), key]);
return result;
} catch (error) {
// Clean up on failure so client can retry
await db.query(`DELETE FROM idempotency_keys WHERE key = $1`, [key]);
throw error;
}
}
Client-Side Best Practices
Generate keys that are:
- Unique: UUIDs work great
- Stable: Same action = same key
// Generate once, reuse on retries
const idempotencyKey = `order-${orderId}-${Date.now()}`;
async function createOrderWithRetry(order: Order) {
for (let i = 0; i < 3; i++) {
try {
return await fetch('/api/orders', {
method: 'POST',
headers: {
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(order)
});
} catch (e) {
if (i === 2) throw e;
await sleep(1000 * Math.pow(2, i));
}
}
}
Key Expiration
Dont store keys forever. 24 hours is usually enough:
// Cleanup job
async function cleanupOldKeys() {
await db.query(`
DELETE FROM idempotency_keys
WHERE created_at < NOW() - INTERVAL '24 hours'
`);
}
// Run daily
cron.schedule('0 3 * * *', cleanupOldKeys);
Further Reading
Idempotency isnt optional for financial operations. But even for non-critical endpoints, it makes your API more robust and your clients' lives easier. A little upfront work saves a lot of debugging later.
