Comprehensive coding rules for designing, implementing, and testing idempotent RESTful APIs with TypeScript/Node.js and supporting infrastructure.
Your APIs are processing duplicate requests, creating inconsistent data states, and causing downstream chaos. Every retry, network timeout, or impatient user click multiplies your side effects, corrupts your audit trails, and breaks your business logic.
You need bulletproof idempotency that works across distributed systems, handles race conditions gracefully, and maintains identical responses under any retry scenario.
Here's what happens when your APIs aren't truly idempotent:
Traditional "just check if it exists" approaches fail under load. You need deterministic, race-proof idempotency that works the same way whether you're handling 10 requests or 10,000 concurrent attempts.
This configuration transforms every API endpoint into a bulletproof, retry-safe operation that:
Stop spending hours tracking down duplicate records and inconsistent state. When every operation is idempotent by design, retry-related bugs simply don't happen.
Your frontend can retry any failed request without side effects. Network hiccups become transparent to users instead of data corruption events.
Deploy across multiple regions and scale horizontally without worrying about concurrent request processing. The idempotency layer handles coordination automatically.
Kafka consumers automatically deduplicate events using deterministic event IDs. At-least-once delivery becomes side-effect-free.
Before: Manual duplicate detection with race conditions
// Fragile approach - race conditions everywhere
async function processPayment(amount: number, customerId: string) {
const existing = await Payment.findOne({ customerId, amount });
if (existing) return existing; // ❌ Race condition window
return await stripe.charges.create({ amount, customer: customerId });
}
After: Guaranteed single execution with idempotency keys
// Bulletproof - identical requests always get identical responses
async function processPayment(req: Request, res: Response) {
const { amount, customerId } = req.body;
// Middleware already handled idempotency key validation and duplicate detection
if (req.idempotencyContext.replayed) {
return res.status(200).json(req.idempotencyContext.response);
}
const charge = await stripe.charges.create({ amount, customer: customerId });
await req.idempotencyContext.finalize({ charge, status: 'completed' });
return res.status(201).json({ charge, status: 'completed' });
}
Before: Complex locking and state checking
// Manual approach - complex and error-prone
async function createOrder(productId: string, quantity: number) {
await db.transaction(async (tx) => {
const product = await tx.products.findOne({ id: productId }, { lock: true });
if (product.stock < quantity) throw new Error('Insufficient stock');
await tx.products.update({ id: productId }, {
stock: product.stock - quantity
});
return await tx.orders.create({ productId, quantity });
});
}
After: Automatic deduplication with side-effect safety
// Rules-based approach - inherently safe
async function createOrder(req: Request, res: Response) {
const { productId, quantity } = req.body;
// Lock acquired automatically via idempotency middleware
await req.idempotencyContext.begin();
const order = await orderService.createWithInventoryCheck(productId, quantity);
await req.idempotencyContext.finalize(order);
return res.status(201).json(order);
}
Before: Manual event deduplication logic
// Complex consumer logic with potential gaps
consumer.on('message', async (message) => {
const eventId = message.key;
const existing = await processedEvents.findOne({ eventId });
if (existing) return; // ❌ Still possible to miss edge cases
await processBusinessLogic(message.value);
await processedEvents.create({ eventId, processedAt: new Date() });
});
After: Deterministic event handling with UUIDv5
// Automatic deduplication with deterministic IDs
producer.send({
topic: 'orders',
messages: [{
key: uuidv5(`${userId}-${orderId}`, namespace), // Deterministic event ID
value: orderEvent
}]
});
// Consumer automatically deduplicates
consumer.on('message', async (message) => {
const eventId = message.key;
if (await eventStore.isProcessed(eventId)) return; // Fast path
await processBusinessLogic(message.value);
await eventStore.markProcessed(eventId);
});
npm install express redis ioredis redlock uuid
npm install -D @types/express @types/uuid
// middleware/idempotency.middleware.ts
export async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction) {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({error: 'Idempotency-Key required'});
if (!/^[A-Za-z0-9_-]{1,255}$/.test(key)) {
return res.status(422).json({error: 'Invalid Idempotency-Key format'});
}
req.idempotencyContext = await idempotencyService.loadOrCreate(key, req);
if (req.idempotencyContext.replayed) {
return res.set('Idempotent-Replay', 'true').status(200).json(req.idempotencyContext.response);
}
return next();
}
// Auto-generate idempotency keys for safe retries
axios.interceptors.request.use(config => {
config.headers['Idempotency-Key'] = config.headers['Idempotency-Key'] ?? crypto.randomUUID();
return config;
});
CREATE TABLE idempotency_log (
idempotency_key VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL,
request_hash VARCHAR(64) NOT NULL,
status_code INTEGER NOT NULL,
headers JSONB NOT NULL,
body JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expire_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_idempotency_log_expire ON idempotency_log (expire_at);
// Apply to all non-idempotent operations
app.post('/orders', idempotencyMiddleware, createOrder);
app.patch('/orders/:id', idempotencyMiddleware, updateOrder);
app.post('/payments', idempotencyMiddleware, processPayment);
E-commerce Platform: Eliminated $50K+ monthly in duplicate charges during payment processing retries. Black Friday traffic spikes no longer create inventory corruption.
Financial API: Achieved regulatory compliance by guaranteeing identical responses for duplicate transaction requests. Audit trails remain clean under any retry scenario.
Microservices Architecture: Deployed across 12 regions without retry-related bugs. Message processing handles at-least-once delivery with zero duplicate side effects.
These rules don't just prevent bugs—they transform how you think about API reliability. Every endpoint becomes retry-safe by default, concurrent requests coordinate automatically, and your distributed systems maintain consistency without complex coordination logic.
Your APIs will handle retry storms, network partitions, and concurrent access patterns with the same reliability as single-threaded, local operations. That's the power of designing for idempotency from the ground up.
You are an expert in HTTP, RESTful API design, TypeScript, Node.js, Express.js, Redis, PostgreSQL, Kafka, AWS Lambda, and Cloud-Native observability.
Key Principles
- Treat every externally visible operation as potentially retry-able; design first for idempotency, then add non-idempotent exceptions only when strictly necessary.
- Prefer inherently idempotent HTTP verbs (GET, PUT, DELETE, HEAD, OPTIONS, TRACE).
- For non-idempotent operations (POST, PATCH) require an Idempotency-Key header and persist execution state.
- All successful repeats of an idempotent request MUST return the *identical* status code, headers, and body as the first execution.
- Fail fast on missing or malformed idempotency data; never silently downgrade to non-idempotent behaviour.
- Store side-effect-free results where practical (response caching) to improve latency on duplicates.
- Race-proof in distributed systems with deterministic request hashing, Redis/DB locks, or unique constraints.
TypeScript / Node.js
- Enforce strictNullChecks, noImplicitAny, exactOptionalPropertyTypes.
- Use `type` only for primitives & unions, `interface` for objects exposed outside the module.
- Directory names: kebab-case (e.g., controllers/order-command) ; file names mirror exported symbol (order-command.controller.ts).
- Avoid `any`; use unknown + narrowing.
- Prefer readonly arrays & tuples for immutables.
- Functions are pure unless they *must* touch I/O; suffix impure calls with `!` (e.g., saveOrder!).
Error Handling & Validation
- Validate `Idempotency-Key` header at the edge (API Gateway / middleware) before body parsing to discard obviously invalid traffic early.
- Normalise the key: trim, lower-case, length ≤ 255, charset `[A-Za-z0-9_-]`.
- Store the key with a composite unique index `(idempotency_key, user_id, endpoint, version)` to guarantee single execution.
- Use optimistic locking + unique constraint; on conflict return cached response with `HTTP 200` & header `Idempotent-Replay: true`.
- Always log the first processing timestamp and the request hash for forensic replay.
- When the original request failed with 4xx/5xx, allow new attempts with *different* keys.
- Return `409 Conflict` when a different payload is sent with an already-used key.
- Use early returns for all error branches; reserve the bottom of the function for the happy path.
Express.js Rules
- Central `idempotency.middleware.ts` attaches `req.idempotencyContext` (key, storeHit(), cachedResponse?).
``ts
export async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction) {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({error: 'Idempotency-Key required'});
if (!/^[A-Za-z0-9_-]{1,255}$/.test(key))
return res.status(422).json({error: 'Invalid Idempotency-Key format'});
req.idempotencyContext = await idempotencyService.loadOrCreate(key, req);
if (req.idempotencyContext.replayed)
return res.set('Idempotent-Replay', 'true').status(200).json(req.idempotencyContext.response);
return next();
}
``
- Controllers must commit the response via `req.idempotencyContext.finalize(response)` exactly once.
- Never mutate external state before `idempotencyContext.begin()` succeeds (i.e., lock acquired).
- Use `res.once('finish', ...)` to release locks even on uncaught exceptions.
Database Layer
- Use a dedicated `idempotency_log` table (or Redis hash) with columns: idempotency_key PK, user_id, request_hash, status_code, headers JSONB, body JSONB, created_at, expire_at.
- Configure TTL (e.g., 24-72 h) via Redis `EXPIRE` or DB background job.
- Wrap business transaction and idempotency record creation in the *same* SQL transaction for atomicity.
Kafka / Message-Driven Systems
- Embed a deterministic `event_id` (UUIDv5(seed, request_id)) in every produced message.
- Consumers maintain a compacted topic or DB table `consumed_events (event_id PK)`; discard duplicates.
- Ensure message handlers are side-effect-free *before* writing to the dedup store to avoid at-least-once duplicates.
Redis Distributed Lock Pattern
``ts
const lock = await redlock.acquire([`lock:idemp:${key}`], 5000);
try {
// process request
} finally {
await lock.release();
}
``
- Always set a sensible TTL (5–15 s) to avoid deadlocks.
Testing
- Unit: mock store; call handler twice with the same key → expect single invocation of side-effects.
- Integration: run concurrently (e.g., 20 parallel POSTs with identical key) → assert exactly one DB row inserted.
- Chaos: inject network timeouts/retries; client should receive identical successful response.
Performance
- Fast path replay: serve duplicate requests from cache layer (Redis, in-memory LRU) without hitting DB.
- Use short circuit in middleware to avoid JSON schema validation costs on replays.
Security
- Treat Idempotency-Key as PII; hash (SHA-256) before storage when compliance requires.
- Rate-limit identical keys to mitigate brute-force enumeration.
- Do not leak internal state—only echo user-provided key in headers if signed (HMAC) first.
Observability
- Tag traces/logs with `idempotency.key` and `idempotency.replayed`. Your SLO: <1 ms extra latency on replay.
- Alert on spike in `409 Conflict` (payload mismatch indicates client issues).
Deployment
- Co-locate idempotency store in the same region as the API to minimise round-trip.
- When scaling horizontally ensure sticky sessions are *not* required; rely solely on shared store.
Common Pitfalls & Remedies
- Accidentally using random UUID as key each retry ⇒ duplicates. Provide SDK helper to auto-reuse key.
- Storing large response bodies (>64 KB) in Redis ⇒ memory bloat. Store pointer to S3 instead.
- Using request timestamp as key ⇒ collisions. Always use high-entropy client-generated key.
Ready-To-Use Snippet: Axios Interceptor
``ts
axios.interceptors.request.use(cfg => {
cfg.headers['Idempotency-Key'] = cfg.headers['Idempotency-Key'] ?? crypto.randomUUID();
return cfg;
});
``
By following these rules teams will produce resilient, duplicate-safe APIs that behave predictably under retries, network failures, and concurrent traffic.