Why idempotency matters
When you build an API, retries are inevitable. Clients lose connections, timeouts happen, and users double-click buttons. Without idempotency, a retried request can create duplicate orders, charge a credit card twice, or send two emails. Idempotency ensures that the same request, applied multiple times, has the same effect as applying it once.
What is idempotency?
An operation is idempotent if performing it once or multiple times produces the same result. In HTTP, GET, PUT, DELETE are naturally idempotent. POST is not, because it creates new resources. But we can make POST idempotent by using an idempotency key.
The idempotency key pattern
The client generates a unique key (usually a UUID) and sends it in a header, like Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000. The server stores this key along with the response for a certain period. If the same key arrives again, the server returns the stored response instead of processing the request again.
Implementation example
Let's build a simple idempotent endpoint in Node.js with Express. We'll use an in-memory store for simplicity, but in production you'd use Redis or a database.
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// In-memory store: key -> { response, expiresAt }
const idempotencyStore = new Map();
const TTL_MS = 60 * 60 * 1000; // 1 hour
app.post('/orders', async (req, res) => {
const key = req.headers['idempotency-key'];
if (!key) {
return res.status(400).json({ error: 'Missing Idempotency-Key header' });
}
// Check existing
const existing = idempotencyStore.get(key);
if (existing && existing.expiresAt > Date.now()) {
return res.status(existing.status).json(existing.body);
}
// Process the order (simulate work)
const order = {
id: crypto.randomUUID(),
product: req.body.product,
quantity: req.body.quantity,
};
// Store the response
const response = { status: 201, body: order };
idempotencyStore.set(key, { ...response, expiresAt: Date.now() + TTL_MS });
// Clean up expired entries periodically
setTimeout(() => idempotencyStore.delete(key), TTL_MS);
res.status(201).json(order);
});
app.listen(3000);
This works, but there's a race condition: if two identical requests arrive simultaneously, both might pass the existing check and process twice. To prevent that, use a lock or atomic operation.
Atomic handling with a database
In a real system, you'd store the idempotency key in a database with a unique constraint. For example, using PostgreSQL:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
response JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Then in your service, try to insert the key. If it fails due to duplicate, fetch the existing response. This gives you atomicity.
const { Pool } = require('pg');
const pool = new Pool();
app.post('/orders', async (req, res) => {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: 'Missing key' });
const client = await pool.connect();
try {
// Try to insert a placeholder; on conflict, we know it's a retry
const insertResult = await client.query(
`INSERT INTO idempotency_keys (key, response, status_code)
VALUES ($1, '{}', 0)
ON CONFLICT (key) DO NOTHING
RETURNING key`,
[key]
);
if (insertResult.rowCount === 0) {
// Key exists, fetch stored response
const existing = await client.query(
'SELECT response, status_code FROM idempotency_keys WHERE key = $1',
[key]
);
return res.status(existing.rows[0].status_code).json(existing.rows[0].response);
}
// Process the order
const order = { id: crypto.randomUUID(), product: req.body.product };
// Update the row with the actual response
await client.query(
'UPDATE idempotency_keys SET response = $1, status_code = $2 WHERE key = $3',
[order, 201, key]
);
res.status(201).json(order);
} finally {
client.release();
}
});
Client-side pattern
Clients should generate a new key for each logical operation, and reuse it when retrying the same operation. For example, if a user submits a form and the request fails, the client should retry with the same key.
const key = crypto.randomUUID();
async function createOrder(payload) {
try {
const res = await fetch('/orders', {
method: 'POST',
headers: { 'Idempotency-Key': key },
body: JSON.stringify(payload),
});
return res.json();
} catch (e) {
// Retry with same key
return createOrder(payload);
}
}
Common pitfalls
- Key reuse across different operations: A key should be unique per operation. Don't reuse the same key for different payloads.
- Expiry too short: If you expire keys too quickly, a delayed retry might slip through. Use a reasonable TTL (e.g., 24 hours).
- Not returning the stored response: When a duplicate key arrives, you must return the exact same response, including status code and body.
- Ignoring idempotency for non-idempotent endpoints: Not all endpoints need it, but any that create resources or trigger side effects should have it.
Conclusion
Idempotency is a simple concept with a big impact on reliability. By adding an idempotency key to your POST endpoints, you make retries safe and give clients confidence. Start with a small implementation, test it under concurrency, and you'll prevent a whole class of bugs.
Remember: the goal is that the user sees one result, no matter how many times the request is sent.
Top comments (0)