Why idempotency matters in distributed systems
Network timeouts, infrastructure hiccups, and retry logic are facts of life in production. When a payment endpoint times out mid-request, the client retries—but how do we know if that charge already went through? An idempotency interceptor: patterns, pitfalls and fixes is exactly what teams building e-commerce, SaaS, and financial systems need to sleep soundly at night. Without it, duplicate operations slip through, causing duplicate charges, double-booked reservations, or phantom subscription renewals.
The solution is deceptively simple: make your POST endpoints idempotent by using a client-supplied unique key, locking it during processing, and replaying cached responses on retries. Let's build this pattern in NestJS using Redis, covering the implementation details, the traps we've seen catch teams, and how to sidestep them.
Read the Idempotency-Key header
The foundation of idempotency is a unique identifier supplied by the client. The Idempotency-Key header (standardized in draft specs and widely adopted) carries this identifier. The server uses it as a cache key, ensuring that identical requests with the same key produce identical results.
In NestJS, we'll extract this header early in the request lifecycle. Here's a practical approach:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class IdempotencyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
// Optional: reject if key is missing for POST/PUT endpoints
// For now, we'll generate one
req['idempotencyKey'] = uuidv4();
} else {
req['idempotencyKey'] = idempotencyKey;
}
next();
}
}
Attach this middleware to your application. For production, enforce the header on mutation endpoints and reject requests without it. For safety, validate that the key is a reasonable length (UUID-like) and doesn't contain special characters that could break Redis operations.
Lock the key in Redis while processing
Once we have the key, we need to prevent concurrent requests with the same key from all executing their business logic. Picture two simultaneous requests hitting your payment endpoint with identical keys: without a lock, both might charge the customer. Redis SET with NX (not-exists) and EX (expiry) gives us a lightweight distributed lock.
Here's how we create an interceptor that locks before processing:
import { Injectable, NestInterceptor, ExecutionContext, ConflictException } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { Redis } from 'ioredis';
import { InjectRedis } from '@nestjs-modules/ioredis';
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
private readonly lockTTL = 30; // seconds
private readonly responseCacheTTL = 86400; // 24 hours
constructor(@InjectRedis() private readonly redis: Redis) {}
async intercept(context: ExecutionContext, next: any): Promise> {
const request = context.switchToHttp().getRequest();
const idempotencyKey = request.idempotencyKey;
if (!idempotencyKey) {
return next.handle();
}
const lockKey = `idempotency:lock:${idempotencyKey}`;
const cacheKey = `idempotency:response:${idempotencyKey}`;
// Check if we already have a cached response
const cachedResponse = await this.redis.get(cacheKey);
if (cachedResponse) {
return new Observable(subscriber => {
subscriber.next(JSON.parse(cachedResponse));
subscriber.complete();
});
}
// Try to acquire the lock
const lockAcquired = await this.redis.set(lockKey, '1', 'EX', this.lockTTL, 'NX');
if (!lockAcquired) {
throw new ConflictException('Request already in progress. Retry after a moment.');
}
return next.handle().pipe(
tap(async (response) => {
// Store the response in cache
await this.redis.setex(cacheKey, this.responseCacheTTL, JSON.stringify(response));
// Release the lock
await this.redis.del(lockKey);
}),
catchError(async (error) => {
// Release the lock even on error
await this.redis.del(lockKey);
return throwError(() => error);
})
);
}
}
Register this interceptor globally or on specific controllers:
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: IdempotencyInterceptor,
},
],
})
export class AppModule {}
The flow is tight: check cache first (instant replay for retries), then attempt lock, then proceed. If the lock fails, we know another request is already processing—reject with 409 Conflict and let the client retry.
Store and replay the response
Caching the response is where idempotency becomes visible to the client. When a retry comes in with the same key, we return the exact same response without re-executing the handler. This is critical for operations like charging a payment card: the client sees the same transaction ID, knows the operation already completed, and moves forward confidently.
In our interceptor above, we stored the response in Redis with the key idempotency:response:{idempotencyKey}. When a retry arrives, we fetch and deserialize it, returning it as if the operation just completed. The client can't distinguish between a fresh response and a replayed one—which is exactly the goal.
However, response serialization matters. For complex DTOs with Date objects, buffers, or circular references, JSON.stringify might lose fidelity. In production, we've seen teams hit issues where serialization misses class methods or circular references crash the process. Use a serialization library like class-transformer if your responses are heavily typed:
import { classToPlain, plainToClass } from 'class-transformer';
// Store
await this.redis.setex(cacheKey, this.responseCacheTTL, JSON.stringify(classToPlain(response)));
// Retrieve
const cached = await this.redis.get(cacheKey);
const parsed = plainToClass(YourResponseDTO, JSON.parse(cached));
Expire keys after 24 hours
Cached responses can't live forever. At some point, enough time has passed that a retry is effectively a new request. The tradeoff between safety and resource usage typically lands at 24 hours for most business operations. Payment providers, for instance, often use similar windows.
In our Redis calls, we're already setting SETEX with 86400 seconds (24 hours). This is the responseCacheTTL. Once 24 hours elapse, the cache entry expires and Redis deletes it automatically. A retry after that point will not find a cached response, triggering a fresh execution.
For the lock key, we use a much shorter TTL (30 seconds in our example). This prevents a crashed or hung request from blocking retries indefinitely. If a payment processor times out mid-operation but doesn't respond with either success or failure, the lock expires, allowing a retry to proceed. This introduces a small risk of duplicate execution if the original request completes after the lock expires but before the retry, so set this value based on your infrastructure's tolerance. A safe heuristic: 2–3× your typical request duration.
Common mistakes
We've learned these lessons the hard way in production:
- Forgetting to release the lock on error. If your handler throws an exception and you don't delete the lock in the catch block, legitimate retries hang for 30 seconds. Always clean up the lock in both success and error paths.
- Using timestamps as idempotency keys. Clients might accidentally send the same timestamp for two genuinely different requests. Require universally unique values (UUIDs) and validate them.
- Caching error responses. If a handler fails with "Insufficient balance" and you cache that error, the retry will also fail identically, even if the user later adds funds. Cache only success responses (2xx status codes).
- Not enforcing the header on all mutation endpoints. It's easy to add idempotency to the payment endpoint but forget the subscription renewal endpoint. Enforce it application-wide or via strong code review discipline.
- Storing responses without versioning. If you deploy a new version of your API that changes response structure, old cached responses from the previous version become inconsistent. Consider versioning the cache key or invalidating it on deployment.
Key takeaways
- Idempotency is essential for any POST or PUT endpoint that performs mutations; network retries are inevitable, and duplicates are costly.
- The pattern requires three Redis operations: check the cache, acquire a lock, and store the response with a sensible TTL.
- Always release the lock, even on errors. A leaked lock blocks legitimate retries and breaks the system's resilience.
- Cache successful responses (2xx) for 24 hours; use a shorter TTL (30 seconds) for locks to prevent hung requests from blocking recovery.
- Validate idempotency keys (enforce UUID format), reject requests without them on mutation endpoints, and test retry scenarios in your test suite.






Comments
Loading comments…