By the end of this lesson, you will understand:
- Why duplicate requests happen in real systems
- How idempotency keys prevent duplicate processing
- How to store and replay responses
- When to use idempotency (and when not to)
A client tries to reserve an item:
Client → Server: POST /reserve
Timeout... (no response)
Client → Server: POST /reserve (retry)
Both requests succeed!
Result: User gets charged twice, double reservations created.
- Network timeouts - Request sent but response lost
- Browser refresh - User refreshes page after POST
- Mobile app retry - Background job retries on failure
- Load balancer retries - Gateway retries upstream
- User double-click - Submit button clicked twice
Idempotent operation = Can be applied multiple times with the same result
- ✅ Idempotent:
x = 5(setting x to 5 always gives same result) - ✅ Idempotent:
DELETE /items/123(deleting twice = already deleted) - ❌ Not idempotent:
x++(incrementing changes state each time)
First Request:
POST /reserve
Idempotency-Key: abc-123
→ Process request
→ Store response with key "abc-123"
→ Return response
Second Request (same key):
POST /reserve
Idempotency-Key: abc-123
→ Look up stored response
→ Return cached response (skip processing)
File: src/idempotency/index.ts
export function findStoredResponse(
key: string,
route: string,
userId: string
): StoredResponse | null {
const row = db.prepare(`
SELECT responseJson FROM idempotency_keys
WHERE key = ? AND route = ? AND userId = ?
`).get(key, route, userId);
return row ? JSON.parse(row.responseJson) : null;
}
export function storeResponse(
key: string,
route: string,
userId: string,
status: number,
body: ApiResponse
): void {
db.prepare(`
INSERT INTO idempotency_keys (key, route, userId, responseJson, createdAt)
VALUES (?, ?, ?, ?, ?)
`).run(key, route, userId, JSON.stringify(body), Date.now());
}File: src/routes/index.ts
app.post('/reserve',
idempotencyMiddleware('/reserve'),
async (req, res) => {
const result = reserveItem(req.body);
if (result.kind === 'OK') {
// Store response for future requests with same key
storeResponse(key, '/reserve', userId, 201, {
ok: true,
data: result.reservation
});
}
return res.status(201).json(result);
}
);export function idempotencyMiddleware(route: string) {
return (req, res, next) => {
const key = req.headers['idempotency-key'];
const userId = req.body.userId;
// Check for cached response
const previous = findStoredResponse(key, route, userId);
if (previous) {
return res.status(previous.status).json(previous.body);
}
// Hook into response.json() to store successful responses
const originalJson = res.json.bind(res);
res.json = function(body) {
if (res.statusCode >= 200 && res.statusCode < 300) {
storeResponse(key, route, userId, res.statusCode, body);
}
return originalJson(body);
};
next();
};
}// UUID v4 (recommended)
"550e8400-e29b-41d4-a716-446655440000"
// Client-generated
"user_123-reserve-item_456-1706720400"
// ULID (time-ordered)
"01ARZ3NDEKTSV4RRFFQ69G5FAV"// Too short (likely to collide)
"abc"
// Not unique per request
"user_123" // Same key for all user's requests
// Predictable
"timestamp-123" // Attacker can guessexport function isValidIdempotencyKey(key: string): boolean {
return key.length >= 8 && key.length <= 255 &&
/^[a-zA-Z0-9\-_]+$/.test(key);
}- Mutation operations (POST, PUT, PATCH)
- Payment processing
- Inventory allocation
- Any operation with side effects
- GET requests (already idempotent)
- Idempotent operations (setting a value)
- Pure queries (no side effects)
# First request - creates reservation
curl -X POST http://localhost:3000/api/v1/reserve \
-H "Idempotency-Key: test-key-456" \
-d '{"userId":"user_1","itemId":"item_1","qty":1}'
# Second request with SAME key - returns cached response
curl -X POST http://localhost:3000/api/v1/reserve \
-H "Idempotency-Key: test-key-456" \
-d '{"userId":"user_1","itemId":"item_1","qty":1}'
# Both return the same reservation ID!// Keys expire after 24 hours
if (Date.now() - stored.createdAt > 24 * 60 * 60 * 1000) {
deleteIdempotencyKey(key);
return null; // Key expired, process request
}- Storage - Don't store infinite keys
- Freshness - Old keys may not be relevant
- Privacy - Don't keep response data forever
// Bad: Key depends only on user
const key = `user:${userId}`;
// First request: reserve item_1
// Second request: reserve item_2
// Both use same key → second returns wrong response!// Good: Key includes user + operation
const key = `user:${userId}:op:reserve:${itemId}:${timestamp}`;// Don't cache errors for too long
if (status >= 500) {
// Don't store 500 errors
return res.status(500).json({ error: 'Server error' });
}res.json = function(body) {
if (res.statusCode >= 200 && res.statusCode < 300) {
storeResponse(key, route, userId, res.statusCode, body);
}
return originalJson(body);
};curl https://api.stripe.com/v1/charges \
-u sk_test_xxx: \
-d idempotency_key=my-key-123 \
-d amount=2000 \
-d currency=usd \
-d source=tok_visaaws s3api put-object \
--bucket my-bucket \
--key file.txt \
--body filecontent \
--x-amz-request-payer requester \
--x-amz-idempotency-key my-key-123- Duplicate requests happen - Network failures, retries, UI bugs
- Idempotency keys = unique identifier per request
- Store response = Return cached response on duplicate
- Expire keys = Don't store forever (24 hours is typical)
- Include context = Key should include user + operation
Task: Test idempotency failure
- Send a reserve request with key "test-key-789"
- Try to confirm a different reservation with the same key
- What happens? (Hint: Routes are scoped, so it should process normally)
Continue to Lesson 5: Caching to learn how to improve performance with intelligent caching.
💡 Tip: Always include Idempotency-Key in mutation requests for production APIs!