Idempotent operations produce the same result regardless of how many times they're called. This is essential for reliable APIs that handle retries and network failures.
Why Idempotency Matters#
Network Failures:
- Request sent but response lost
- Client retries not knowing if it succeeded
- Without idempotency: duplicate charges, orders, etc.
With Idempotency:
- Client can safely retry
- Server detects duplicate requests
- Same result returned for repeated calls
HTTP Method Idempotency#
Naturally Idempotent:
- GET: Read operations
- PUT: Replace resource
- DELETE: Remove resource
- HEAD: Read headers
- OPTIONS: Read options
NOT Idempotent by Default:
- POST: Create resource (needs idempotency key)
- PATCH: May or may not be idempotent
Idempotency Keys#
1import { Redis } from 'ioredis';
2import crypto from 'crypto';
3
4const redis = new Redis(process.env.REDIS_URL);
5
6interface IdempotencyRecord {
7 status: 'processing' | 'completed';
8 response?: {
9 statusCode: number;
10 body: any;
11 };
12 createdAt: string;
13}
14
15const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hours
16
17async function getIdempotencyRecord(key: string): Promise<IdempotencyRecord | null> {
18 const data = await redis.get(`idempotency:${key}`);
19 return data ? JSON.parse(data) : null;
20}
21
22async function setIdempotencyRecord(
23 key: string,
24 record: IdempotencyRecord
25): Promise<void> {
26 await redis.setex(
27 `idempotency:${key}`,
28 IDEMPOTENCY_TTL,
29 JSON.stringify(record)
30 );
31}
32
33// Middleware
34function idempotent() {
35 return async (req: Request, res: Response, next: NextFunction) => {
36 const idempotencyKey = req.headers['idempotency-key'] as string;
37
38 // Only apply to POST/PATCH
39 if (!['POST', 'PATCH'].includes(req.method)) {
40 return next();
41 }
42
43 // Key is optional but recommended
44 if (!idempotencyKey) {
45 return next();
46 }
47
48 // Validate key format
49 if (idempotencyKey.length > 255) {
50 return res.status(400).json({
51 error: 'Idempotency key too long',
52 });
53 }
54
55 // Create unique key including user context
56 const fullKey = `${req.user?.id || 'anonymous'}:${req.path}:${idempotencyKey}`;
57
58 // Check for existing record
59 const existing = await getIdempotencyRecord(fullKey);
60
61 if (existing) {
62 if (existing.status === 'processing') {
63 // Request still in progress
64 return res.status(409).json({
65 error: 'Request is being processed',
66 retryAfter: 1,
67 });
68 }
69
70 if (existing.status === 'completed' && existing.response) {
71 // Return cached response
72 res.setHeader('X-Idempotent-Replay', 'true');
73 return res.status(existing.response.statusCode).json(existing.response.body);
74 }
75 }
76
77 // Mark as processing
78 await setIdempotencyRecord(fullKey, {
79 status: 'processing',
80 createdAt: new Date().toISOString(),
81 });
82
83 // Capture response
84 const originalJson = res.json.bind(res);
85 res.json = (body) => {
86 // Store completed response
87 setIdempotencyRecord(fullKey, {
88 status: 'completed',
89 response: {
90 statusCode: res.statusCode,
91 body,
92 },
93 createdAt: new Date().toISOString(),
94 }).catch((err) => {
95 console.error('Failed to store idempotency record:', err);
96 });
97
98 return originalJson(body);
99 };
100
101 next();
102 };
103}
104
105app.use(idempotent());Payment Processing Example#
1// Client-side: Generate idempotency key
2async function createPayment(amount: number): Promise<Payment> {
3 const idempotencyKey = crypto.randomUUID();
4
5 // Store key for potential retries
6 localStorage.setItem('pendingPayment', idempotencyKey);
7
8 const response = await fetch('/api/payments', {
9 method: 'POST',
10 headers: {
11 'Content-Type': 'application/json',
12 'Idempotency-Key': idempotencyKey,
13 },
14 body: JSON.stringify({ amount }),
15 });
16
17 if (response.ok) {
18 localStorage.removeItem('pendingPayment');
19 }
20
21 return response.json();
22}
23
24// Server-side: Process payment idempotently
25app.post('/api/payments', idempotent(), async (req, res) => {
26 const { amount, customerId } = req.body;
27
28 try {
29 // Create payment in database
30 const payment = await prisma.payment.create({
31 data: {
32 id: crypto.randomUUID(),
33 amount,
34 customerId,
35 status: 'pending',
36 },
37 });
38
39 // Charge payment provider
40 const charge = await stripe.charges.create({
41 amount: amount * 100,
42 currency: 'usd',
43 customer: customerId,
44 idempotencyKey: req.headers['idempotency-key'],
45 });
46
47 // Update payment status
48 await prisma.payment.update({
49 where: { id: payment.id },
50 data: {
51 status: 'completed',
52 stripeChargeId: charge.id,
53 },
54 });
55
56 res.status(201).json(payment);
57 } catch (error) {
58 // Handle specific errors
59 if (error.type === 'StripeIdempotencyError') {
60 // Key was reused with different parameters
61 return res.status(400).json({
62 error: 'Idempotency key was used with different request parameters',
63 });
64 }
65
66 throw error;
67 }
68});Database-Level Idempotency#
1// Use unique constraints
2const schema = `
3 CREATE TABLE orders (
4 id UUID PRIMARY KEY,
5 idempotency_key VARCHAR(255) UNIQUE,
6 customer_id UUID NOT NULL,
7 total DECIMAL(10,2) NOT NULL,
8 created_at TIMESTAMP DEFAULT NOW()
9 );
10`;
11
12async function createOrder(
13 idempotencyKey: string,
14 orderData: CreateOrderInput
15): Promise<Order> {
16 try {
17 // Try to create order
18 const order = await prisma.order.create({
19 data: {
20 id: crypto.randomUUID(),
21 idempotencyKey,
22 ...orderData,
23 },
24 });
25
26 return order;
27 } catch (error) {
28 // Check for unique constraint violation
29 if (error.code === 'P2002' && error.meta?.target?.includes('idempotency_key')) {
30 // Return existing order
31 const existing = await prisma.order.findUnique({
32 where: { idempotencyKey },
33 });
34
35 if (existing) {
36 return existing;
37 }
38 }
39
40 throw error;
41 }
42}Optimistic Locking#
1// Use version numbers for idempotent updates
2async function updateInventory(
3 productId: string,
4 quantity: number,
5 expectedVersion: number
6): Promise<Inventory> {
7 const result = await prisma.inventory.updateMany({
8 where: {
9 productId,
10 version: expectedVersion,
11 },
12 data: {
13 quantity,
14 version: { increment: 1 },
15 },
16 });
17
18 if (result.count === 0) {
19 // Version mismatch - concurrent modification
20 throw new ConcurrentModificationError();
21 }
22
23 return prisma.inventory.findUnique({
24 where: { productId },
25 });
26}
27
28// Client handles conflicts
29async function decrementStock(productId: string, amount: number): Promise<void> {
30 let retries = 3;
31
32 while (retries > 0) {
33 const inventory = await getInventory(productId);
34
35 try {
36 await updateInventory(
37 productId,
38 inventory.quantity - amount,
39 inventory.version
40 );
41 return;
42 } catch (error) {
43 if (error instanceof ConcurrentModificationError && retries > 1) {
44 retries--;
45 continue;
46 }
47 throw error;
48 }
49 }
50}Event Processing Idempotency#
1// Deduplicate webhook events
2async function processWebhook(event: WebhookEvent): Promise<void> {
3 const eventId = event.id;
4
5 // Check if already processed
6 const existing = await prisma.processedEvent.findUnique({
7 where: { eventId },
8 });
9
10 if (existing) {
11 console.log(`Event ${eventId} already processed`);
12 return;
13 }
14
15 // Process in transaction
16 await prisma.$transaction(async (tx) => {
17 // Mark as processing
18 await tx.processedEvent.create({
19 data: {
20 eventId,
21 type: event.type,
22 processedAt: new Date(),
23 },
24 });
25
26 // Handle event
27 switch (event.type) {
28 case 'payment.succeeded':
29 await handlePaymentSuccess(tx, event.data);
30 break;
31 case 'subscription.created':
32 await handleSubscriptionCreated(tx, event.data);
33 break;
34 }
35 });
36}
37
38// Message queue deduplication
39async function processMessage(message: QueueMessage): Promise<void> {
40 const messageId = message.id;
41
42 // Use Redis for deduplication with TTL
43 const key = `processed:${messageId}`;
44 const wasSet = await redis.setnx(key, '1');
45
46 if (!wasSet) {
47 // Already processed
48 return;
49 }
50
51 // Set expiry
52 await redis.expire(key, 24 * 60 * 60);
53
54 // Process message
55 await handleMessage(message);
56}Retry Handling#
1// Client-side retry with backoff
2async function fetchWithRetry<T>(
3 fn: () => Promise<T>,
4 options: {
5 maxRetries?: number;
6 baseDelay?: number;
7 maxDelay?: number;
8 } = {}
9): Promise<T> {
10 const { maxRetries = 3, baseDelay = 1000, maxDelay = 10000 } = options;
11
12 let lastError: Error;
13
14 for (let attempt = 0; attempt <= maxRetries; attempt++) {
15 try {
16 return await fn();
17 } catch (error) {
18 lastError = error;
19
20 // Don't retry client errors
21 if (error.status >= 400 && error.status < 500 && error.status !== 429) {
22 throw error;
23 }
24
25 if (attempt < maxRetries) {
26 const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
27 const jitter = delay * 0.2 * Math.random();
28 await sleep(delay + jitter);
29 }
30 }
31 }
32
33 throw lastError!;
34}
35
36// Usage with idempotency key
37const payment = await fetchWithRetry(
38 () => createPayment(amount, idempotencyKey),
39 { maxRetries: 3 }
40);Best Practices#
Implementation:
✓ Generate keys client-side (UUIDs)
✓ Include user context in key
✓ Set appropriate TTL (24-48 hours)
✓ Handle "processing" state
Error Handling:
✓ Return same response for duplicates
✓ Handle parameter mismatches
✓ Log duplicate requests
✓ Clean up on failure
Performance:
✓ Use fast storage (Redis)
✓ Index idempotency columns
✓ Set TTL to auto-cleanup
✓ Consider eventual consistency
Conclusion#
Idempotency is essential for reliable APIs. Use idempotency keys for non-idempotent operations, store responses for replay, and design your data models to handle duplicates gracefully. This enables safe client retries and improves system reliability.