When building APIs, especially for payments or operations that modify state, you want to avoid executing the same request multiple times if the client retries. Stripe popularized the concept of idempotency keys: clients attach a unique key to a request, and the server ensures the same request isn’t processed twice.
In this post, we’ll walk through how to bring that Stripe-style idempotency pattern into a NestJS application using an Interceptor.
Why Idempotency Matters
Imagine your user submits a payment form. The frontend sends the request, but the network times out. The user refreshes and clicks “Pay” again. Without idempotency, you risk double-charging the card.
With idempotency:
- The client sends a unique key (e.g.,
Idempotency-Key
header). - The server stores the result of the first request.
- Subsequent requests with the same key return the cached result instead of executing the logic again.
This prevents double-processing and gives a consistent API contract.
High-Level Design
We’ll build a NestJS interceptor that:
- Checks for the
Idempotency-Key
header. - Looks up whether we’ve already processed a request with this key.
- If yes → return the stored response immediately.
- If no → process the request, store the response (or error), and return it.
For storage, you can use:
- In-Memory Cache (Redis/Memcached) → recommended in production, especially for distributed systems.
- Database → if you want persistence across services.
- NestJS In-Memory CacheModule → fine for demos or single-instance apps.
Implementation Step-by-Step
1. Set up a Cache (Redis recommended)
Install Redis and connect it with cache-manager-redis-store
:
npm install cache-manager ioredis cache-manager-redis-store
Configure it in your AppModule
:
import { CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
@Module({
imports: [
CacheModule.registerAsync({
useFactory: async () => ({
store: redisStore,
host: 'localhost',
port: 6379,
ttl: 60 * 5, // cache entries expire after 5 minutes
}),
}),
],
})
export class AppModule {}
Explanation:
- We register a global
CacheModule
using Redis as the backend. - Every cached entry will expire after 5 minutes (
ttl
). You can adjust this depending on your business rules. - Redis ensures your idempotency works across multiple app instances (important if you’re running in Kubernetes or load balanced environments).
2. Create the Idempotency Interceptor
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
BadRequestException,
Inject,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Observable, from } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const idempotencyKey = request.headers['idempotency-key'];
if (!idempotencyKey) {
throw new BadRequestException('Idempotency-Key header is required');
}
// Check if cached response exists
const cachedResponse = await this.cacheManager.get(idempotencyKey);
if (cachedResponse) {
return from([cachedResponse]);
}
// Otherwise, process the request and cache result
return next.handle().pipe(
tap(async (response) => {
await this.cacheManager.set(idempotencyKey, response, { ttl: 60 * 5 });
}),
);
}
}
Explanation:
ExecutionContext
lets us access the request. We pull theIdempotency-Key
header from it.- If the header is missing, we immediately throw a
400 Bad Request
. this.cacheManager.get(idempotencyKey)
checks if we’ve seen this key before:- If yes → return the cached response wrapped in an observable (
from([cachedResponse])
). - If no → let the request through with
next.handle()
. Once the controller returns a result, thetap()
operator stores it in Redis under that key.
- If yes → return the cached response wrapped in an observable (
- Next time the same key comes in, the interceptor will skip the controller and return the cached value.
3. Apply the Interceptor
At the controller level:
import { Controller, Post, UseInterceptors, Body } from '@nestjs/common';
import { IdempotencyInterceptor } from './idempotency.interceptor';
@Controller('payments')
export class PaymentsController {
@Post()
@UseInterceptors(IdempotencyInterceptor)
async createPayment(@Body() body: any) {
// Your business logic (e.g., charge user, save record)
return { status: 'success', chargeId: 'ch_12345' };
}
}
Explanation:
- We decorate the
POST /payments
endpoint with@UseInterceptors(IdempotencyInterceptor)
. - The interceptor wraps the controller:
- First call → executes
createPayment
, caches the result. - Second call with the same key → returns the cached
{ status: 'success', chargeId: 'ch_12345' }
immediately.
- First call → executes
Now, if the client retries with the same Idempotency-Key
, the exact same JSON response is returned.
Step 4: Test the Behavior
# First request
curl -X POST http://localhost:3000/payments \
-H "Idempotency-Key: test-123"
# => { "status": "success", "chargeId": "ch_12345" }
# Retry same request
curl -X POST http://localhost:3000/payments \
-H "Idempotency-Key: test-123"
# => same response instantly, controller never runs again
Additional Considerations
- Error responses: You can also cache errors, ensuring clients don’t retry a failing request endlessly.
- Expiration: Stripe defaults to 24 hours. Adjust
ttl
to your use case. - Scoping: For high-security APIs, scope idempotency keys per user. Example:
userId:idempotencyKey
. - Security: Don’t allow unbounded key growth. Enforce cleanup or TTLs.
- Replay protection: Idempotency is not just about avoiding duplication but also ensuring consistent results on retries.
Conclusion
By wrapping your business logic inside an IdempotencyInterceptor
, you bring Stripe’s robust retry-safety model into your NestJS apps.
This small addition dramatically improves the reliability of endpoints where retries are likely — payments, account creations, batch jobs, etc.
Your clients will thank you, and you’ll sleep better knowing duplicate requests won’t wreak havoc.
I really liked this articule. Tha is you.
I have a question:
Does this work in case the user does 2 or more requests in a very short period of time? For example, the same request is sent to the server within 10ms. I suspect that the second request would be processed because the first response didn’t have time to be saved in cache. If this is the case, then this type of implementation only works for requests with the same idempotency key where the second request arrives to the server after the first one was responded and the response saved in the cache db?