Idempotency in API Design: Safe Retries at Scale
Network failures mean requests get retried — idempotent API design makes processing the same request twice produce one result, not duplicate charges.

The client sends a payment request. The server processes it and sends a response — but the response is lost due to a network timeout. The client retries. Without idempotency, the payment is processed twice. The customer is charged double. Idempotency ensures that processing the same request multiple times has the same effect as processing it once. In distributed systems where network failures and retries are guaranteed, this isn't a nice-to-have — it's a requirement.
Naturally Idempotent vs. Non-Idempotent
Some HTTP methods are naturally idempotent. Others need explicit design to become safe for retries.
// GET — naturally idempotent (read-only)
app.get("/api/orders/:id", async (req, res) => {
const order = await getOrder(req.params.id);
res.json(order); // Same result no matter how many times you call it
});
// PUT — naturally idempotent (replace entire resource)
app.put("/api/orders/:id", async (req, res) => {
// Replaces the order completely — calling twice produces same state
const order = await replaceOrder(req.params.id, req.body);
res.json(order);
});
// DELETE — naturally idempotent
app.delete("/api/orders/:id", async (req, res) => {
await deleteOrder(req.params.id);
// Second call: order already deleted, same end state
res.status(204).send();
});
// POST — NOT naturally idempotent
app.post("/api/orders", async (req, res) => {
// ❌ Calling twice creates two orders!
const order = await createOrder(req.body);
res.status(201).json(order);
});The Idempotency Key Pattern
Clients include a unique key with each request. The server stores the key and its result — on retry, the stored result is returned without re-processing.
// Client sends: POST /api/payments
// Header: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
interface IdempotencyRecord {
key: string;
statusCode: number;
body: unknown;
createdAt: Date;
expiresAt: Date;
}
async function idempotencyMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const key = req.headers["idempotency-key"] as string;
if (!key) {
// Only require for mutating methods
if (req.method === "POST") {
res.status(400).json({ error: "Idempotency-Key header is required for POST requests" });
return;
}
return next();
}
// Check for existing result
const existing = await db.query<IdempotencyRecord>(
"SELECT * FROM idempotency_keys WHERE key = $1 AND expires_at > NOW()",
[key]
);
if (existing.rows.length > 0) {
const record = existing.rows[0];
res.status(record.statusCode).json(record.body);
return;
}
// Capture the response to store it
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
// Store the result for future retries
db.query(
`INSERT INTO idempotency_keys (key, status_code, body, created_at, expires_at)
VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '24 hours')
ON CONFLICT (key) DO NOTHING`,
[key, res.statusCode, JSON.stringify(body)]
);
return originalJson(body);
};
next();
}
app.use("/api/payments", idempotencyMiddleware);Database-Level Idempotency
For critical operations like payments, use database constraints to prevent duplicates even if the application-level check somehow fails.
// ❌ Race condition: two concurrent retries both pass the check
async function processPayment(idempotencyKey: string, amount: number) {
const existing = await db.query("SELECT * FROM payments WHERE idempotency_key = $1", [idempotencyKey]);
if (existing.rows.length > 0) return existing.rows[0];
// RACE: Both requests reach here before either inserts
const payment = await chargeCustomer(amount);
await db.query("INSERT INTO payments (idempotency_key, amount) VALUES ($1, $2)", [idempotencyKey, amount]);
return payment;
}
// ✅ Database constraint prevents duplicates
async function processPayment(idempotencyKey: string, amount: number) {
return await db.transaction(async (tx) => {
// Advisory lock on the idempotency key
await tx.query(
"SELECT pg_advisory_xact_lock(hashtext($1))",
[idempotencyKey]
);
const existing = await tx.query(
"SELECT * FROM payments WHERE idempotency_key = $1",
[idempotencyKey]
);
if (existing.rows.length > 0) {
return existing.rows[0]; // Return stored result
}
const payment = await chargeCustomer(amount);
await tx.query(
`INSERT INTO payments (idempotency_key, amount, status, created_at)
VALUES ($1, $2, $3, NOW())`,
[idempotencyKey, amount, payment.status]
);
return payment;
});
}Client-Side Implementation
Clients are responsible for generating and reusing idempotency keys correctly.
// ❌ Generating a new key on every retry — defeats the purpose
async function createPayment(amount: number): Promise<PaymentResult> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": crypto.randomUUID(), // New key each time!
},
body: JSON.stringify({ amount }),
}).then(r => r.json());
} catch {
// Retry with a different key — creates duplicate payments
}
}
throw new Error("Payment failed after 3 attempts");
}
// ✅ Generating the key once and reusing on retries
async function createPayment(amount: number): Promise<PaymentResult> {
const idempotencyKey = crypto.randomUUID(); // Generated once
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // Same key on every retry
},
body: JSON.stringify({ amount }),
});
if (response.ok) return response.json();
// Don't retry client errors (4xx) — they won't succeed on retry
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
} catch (error) {
if (attempt === 2) throw error;
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
throw new Error("Payment failed after 3 attempts");
}Cleanup and Expiration
Idempotency records should expire. A 24-hour window is typical — long enough for retries, short enough to avoid unbounded storage growth.
-- Idempotency key table with automatic expiration
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
status_code INTEGER NOT NULL,
body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
-- Index for cleanup queries
CREATE INDEX idx_idempotency_keys_expires ON idempotency_keys (expires_at);
-- Periodic cleanup (run via cron or scheduled job)
DELETE FROM idempotency_keys WHERE expires_at < NOW();Key Takeaways
- Idempotency prevents duplicate processing — essential for any operation where retries are possible
- Use idempotency keys for POST requests — clients generate a unique key, server stores the result
- Database constraints are the last line of defense — use advisory locks or unique constraints to prevent race conditions
- Clients must reuse the same key on retries — a new key per attempt defeats the entire pattern
- Keys should expire — 24 hours is typically sufficient, clean up expired records periodically
- GET, PUT, and DELETE are naturally idempotent — focus idempotency design on POST and PATCH endpoints


