scoped to HTTP_METHOD + PATH. Reusing the same key across /v1/orders and /v1/refunds would cause cross-resource collisions. Prefixing the cache key with the route eliminates this risk.
2. Response Interception: The middleware must capture the final HTTP status code and serialized body before the response leaves the server. This ensures retries return the exact original outcome, including validation errors.
3. Payload Hashing: Clients may accidentally reuse a key with a modified request body. Computing a SHA-256 hash of the request payload and storing it alongside the key allows the server to detect mismatches and return 409 Conflict.
4. TTL Policy: A 24-hour TTL (86,400 seconds) aligns with industry standards. It covers typical client retry windows while preventing unbounded cache growth. Error responses (4xx) receive the same TTL; transient server errors (5xx) are intentionally excluded from caching to allow legitimate retries.
5. Atomic Cache Operations: Concurrent retries can bypass naive GET then SET logic. Using Redis SETNX (set if not exists) with a Lua script guarantees atomic check-and-store behavior under high concurrency.
Server-Side Implementation
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
import Redis from 'ioredis';
import { createHash } from 'crypto';
const app = Fastify();
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const IDEMPOTENCY_TTL = 86400; // 24 hours
const RESPONSE_SIZE_LIMIT = 1024 * 50; // 50KB cache limit
async function idempotencyGuard(req: FastifyRequest, reply: FastifyReply) {
const idempotencyKey = req.headers['idempotency-key'] as string | undefined;
if (!idempotencyKey) return;
const scopeKey = `${req.method}:${req.routeOptions.url}`;
const cacheKey = `idem:${scopeKey}:${idempotencyKey}`;
const payloadHash = createHash('sha256')
.update(JSON.stringify(req.body))
.digest('hex');
// Atomic check: retrieve existing result or prepare for first execution
const existing = await redis.get(cacheKey);
if (existing) {
const parsed = JSON.parse(existing);
// Conflict detection: same key, different payload
if (parsed.payloadHash !== payloadHash) {
return reply.status(409).send({
error: 'idempotency_conflict',
message: 'Key reused with different request parameters.'
});
}
// Return cached outcome
return reply.status(parsed.statusCode).send(parsed.responseBody);
}
// Intercept response serialization to cache the outcome
const originalSend = reply.send.bind(reply);
reply.send = function (payload: unknown) {
const statusCode = reply.statusCode;
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
// Skip caching for large payloads or transient server errors
if (body.length > RESPONSE_SIZE_LIMIT || statusCode >= 500) {
return originalSend(payload);
}
const cachePayload = JSON.stringify({
statusCode,
responseBody: payload,
payloadHash
});
// Fire-and-forget cache write; does not block response
redis.setex(cacheKey, IDEMPOTENCY_TTL, cachePayload).catch(err => {
req.log.warn({ err, cacheKey }, 'Idempotency cache write failed');
});
return originalSend(payload);
};
}
app.addHook('preHandler', idempotencyGuard);
app.post('/transactions', {
schema: {
body: {
type: 'object',
required: ['amount', 'currency', 'recipient_id'],
properties: {
amount: { type: 'number' },
currency: { type: 'string' },
recipient_id: { type: 'string' }
}
}
}
}, async (req, reply) => {
const { amount, currency, recipient_id } = req.body as any;
// Simulate business logic
const transactionId = `txn_${Date.now()}_${Math.random().toString(36).slice(2)}`;
return reply.status(201).send({
id: transactionId,
status: 'completed',
amount,
currency,
recipient_id
});
});
await app.listen({ port: 3000 });
Client-Side Retry Pattern
The client must generate the identifier once and persist it until a terminal state is reached. Generating a new key on each retry defeats the entire mechanism.
import { randomUUID } from 'crypto';
interface RetryConfig {
maxAttempts: number;
backoffMs: number;
}
async function executeWithIdempotency<T>(
url: string,
payload: Record<string, unknown>,
config: RetryConfig = { maxAttempts: 3, backoffMs: 1000 }
): Promise<T> {
const operationKey = randomUUID();
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': operationKey
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok) return data as T;
if (response.status === 409) {
throw new Error('Idempotency conflict: payload modified on retry');
}
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
// 5xx errors trigger retry with same key
lastError = new Error(`Server error: ${response.status}`);
} catch (err) {
if (err instanceof Error && err.message.includes('conflict')) throw err;
lastError = err as Error;
}
if (attempt < config.maxAttempts) {
await new Promise(res => setTimeout(res, config.backoffMs * attempt));
}
}
throw lastError ?? new Error('Max retry attempts exceeded');
}
Why this structure works: The server hook intercepts all mutations before business logic executes. The atomic Redis lookup prevents race conditions during concurrent retries. Payload hashing catches client-side bugs where parameters drift between attempts. The client loop preserves the original key, applies exponential backoff, and distinguishes between terminal client errors (4xx) and retryable server errors (5xx).
Pitfall Guide
1. Cross-Endpoint Key Collision
Explanation: Using the same identifier across different routes causes the cache to return results from unrelated operations.
Fix: Always prefix the cache key with METHOD:PATH. Never allow keys to leak across resource boundaries.
2. Caching Transient Server Errors
Explanation: Storing 500 or 503 responses blocks legitimate retries until TTL expires, masking infrastructure recovery.
Fix: Exclude 5xx status codes from caching. Only persist 2xx and 4xx outcomes. Let transient failures pass through to the business logic layer.
3. Race Conditions in Cache Writes
Explanation: A naive GET β execute β SET sequence allows two concurrent retries to both pass the cache check and execute the operation twice.
Fix: Use Redis SETNX or a Lua script to atomically check-and-store. The first request to acquire the lock executes; subsequent requests wait or read the cached result.
4. Payload Mutation on Retry
Explanation: Clients sometimes modify request parameters between attempts (e.g., adjusting amounts, changing timestamps) while reusing the same key.
Fix: Hash the request body at generation time. Compare the hash on the server. Return 409 Conflict if hashes diverge.
5. Unbounded Cache Growth
Explanation: Storing every request indefinitely exhausts memory and increases eviction pressure.
Fix: Enforce strict TTLs. Implement response size caps (e.g., 50KB). Consider storing only operation IDs for large payloads instead of full bodies.
6. Client Key Regeneration
Explanation: Generating a new UUID inside the retry loop creates a fresh cache entry per attempt, nullifying deduplication.
Fix: Generate the key once before the loop. Persist it in local state, session storage, or a durable queue until a terminal response is received.
7. Ignoring Idempotency on Webhooks
Explanation: Outbound webhooks or event streams often lack idempotency keys, causing duplicate event processing downstream.
Fix: Attach Idempotency-Key or Event-ID headers to all outbound deliveries. Require consumers to implement deduplication on their ingestion endpoints.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput payment API | Redis cache + payload hash | Sub-10ms deduplication, absorbs retry storms | Low (ephemeral storage) |
| Strict audit/compliance requirement | Database unique constraint | Persistent record, immutable audit trail | Medium (write amplification) |
| Low-traffic internal service | In-memory LRU cache | Zero external dependencies, simple deployment | Minimal (RAM bounded) |
| Multi-region active-active | Distributed cache with consistent hashing | Avoids cross-region cache misses | High (network egress) |
| Event-driven webhook delivery | Idempotency key + consumer-side dedup | Guarantees exactly-once processing downstream | Low (header overhead) |
Configuration Template
// idempotency.config.ts
export const IdempotencyConfig = {
headerName: 'Idempotency-Key',
cachePrefix: 'idem',
ttlSeconds: 86400,
maxResponseSizeBytes: 51200,
excludeStatusCodes: [500, 502, 503, 504],
conflictStatusCode: 409,
retryBackoffMultiplier: 1.5,
maxRetryAttempts: 3
} as const;
// redis.client.ts
import Redis from 'ioredis';
import { IdempotencyConfig } from './idempotency.config';
export const idempotencyStore = new Redis({
host: process.env.IDEMPOTENCY_REDIS_HOST || '127.0.0.1',
port: Number(process.env.IDEMPOTENCY_REDIS_PORT) || 6379,
maxRetriesPerRequest: 2,
retryStrategy: (times) => Math.min(times * 50, 2000),
enableReadyCheck: true
});
idempotencyStore.on('error', (err) => {
console.error('Idempotency store connection failed:', err.message);
});
Quick Start Guide
- Install dependencies:
npm install fastify ioredis
- Add the middleware hook: Copy the
idempotencyGuard function into your Fastify setup and register it via app.addHook('preHandler', idempotencyGuard).
- Configure Redis: Point
IDEMPOTENCY_REDIS_HOST and IDEMPOTENCY_REDIS_PORT to your cache cluster. Ensure TTL and size limits match IdempotencyConfig.
- Update client SDK: Generate
randomUUID() once per operation, attach it to the Idempotency-Key header, and implement the retry loop with exponential backoff.
- Verify behavior: Send a request, simulate a network drop, retry with the same key, and confirm the server returns the cached response without re-executing business logic. Monitor Redis
HIT/MISS ratios to validate cache effectiveness.