How to Implement Idempotency in a NestJS + PostgreSQL Stack (Without Losing Sleep Over Duplicate Requests)
A practical guide to implementing idempotency in a Vue + NestJS + PostgreSQL stack using idempotency keys, a custom Guard, and raw SQL — no ORM required.

Developing web/mobile applications, from the server-side to the client-side. With 6 years of experience in software development. I am skilled in using a range of data mining language and technologies working as Big Data Engineer for 4 years.
How to Implement Idempotency in a NestJS + PostgreSQL Stack
If you've ever built a payment flow or an order creation endpoint, you've probably faced this scenario: the user clicks "Submit", the network hiccups, the client retries — and suddenly you've charged them twice. That's exactly the problem idempotency solves.
Idempotency means that executing the same operation multiple times produces the same result as executing it once. It's a foundational concept in distributed systems, and something every senior backend engineer should have in their toolkit.
In this post I'll walk through one practical way to implement it in a Vue + NestJS + PostgreSQL stack. It's not the only approach — I'll touch on alternatives at the end — but it's clean, pragmatic, and battle-tested.
The Core Pattern: Idempotency Keys
The idea is simple:
The client generates a unique key (UUID) per user action
It sends that key in every request for that action
The server stores the result of the first execution
On duplicate requests, it returns the cached result without re-executing
Implementation
1. Client (Vue)
Generate the key once per user action and hold it until the operation is confirmed. Don't regenerate on retry — that defeats the purpose.
const idempotencyKey = crypto.randomUUID()
await axios.post('/payments', payload, {
headers: { 'Idempotency-Key': idempotencyKey }
})
2. NestJS Guard
A Guard is a clean place to intercept the request before it hits your controller. If we've seen this key before and have a stored result, we short-circuit the response immediately.
@Injectable()
export class IdempotencyGuard implements CanActivate {
constructor(private readonly idempotencyService: IdempotencyService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest()
const res = context.switchToHttp().getResponse()
const key = req.headers['idempotency-key']
if (!key) return true
const cached = await this.idempotencyService.get(key)
if (cached) {
res.status(cached.status).json(cached.body)
return false
}
req.idempotencyKey = key
return true
}
}
3. Service
Store the result After executing the business logic, persist the result tied to the key. Next time the same key arrives, the Guard handles it before your service is even called.
async createPayment(dto: CreatePaymentDto, key: string) {
const result = await this.processPayment(dto)
await this.idempotencyService.save(key, {
status: 201,
body: result,
expiresAt: addHours(new Date(), 24)
})
return result
}
4. PostgreSQL Table
CREATE TABLE idempotency_keys (
key UUID PRIMARY KEY,
status INT NOT NULL,
body JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
5. IdempotencyService — Raw SQL
You'll notice we're using raw SQL here rather than an ORM like TypeORM or Prisma. That's a deliberate choice for this example — raw SQL gives you full control over query execution, and in a case like this the queries are simple enough that an ORM adds more abstraction than value. That said, the approach works equally well with any ORM; it's up to you and your team's conventions.
async get(key: string) {
return this.db.query(
`SELECT * FROM idempotency_keys
WHERE key = $1 AND expires_at > NOW()`,
[key]
)
}
async save(key: string, data: { status: number, body: object, expiresAt: Date }) {
await this.db.query(
`INSERT INTO idempotency_keys (key, status, body, expires_at)
VALUES (\(1, \)2, \(3, \)4)
ON CONFLICT (key) DO NOTHING`,
[key, data.status, data.body, data.expiresAt]
)
}
The ON CONFLICT DO NOTHING clause is doing important work here — it handles race conditions at the database level, so even if two identical requests arrive simultaneously, only one will ever be written.
Things Worth Knowing Key expiry.
Keys don't need to live forever. 24 hours is a common default for most use cases. Clean up expired rows periodically with a scheduled job. Scope by user. If your system is multi-tenant, consider prefixing keys with the user or account ID (userId:uuid) to avoid any risk of cross-user collisions. Only apply it where it matters. Idempotency is relevant for non-idempotent operations: POST, PATCH, and anything with side effects. Don't wrap GET endpoints in this — they're already idempotent by nature.
Other Ways to Achieve This
This is one approach, but it's worth knowing the alternatives depending on your constraints: Redis as the idempotency store. Instead of PostgreSQL, use Redis with a TTL. It's faster for lookups and handles expiry natively. The trade-off is that Redis is a separate dependency and isn't durable by default — a restart could lose your idempotency records. A common pattern is to use Redis as a fast cache layer and PostgreSQL as the source of truth. Database-level unique constraints. If your operation maps to inserting a unique record (e.g. an order), a unique constraint on a client_reference_id column can be enough. The insert fails silently on duplicate, and you return the existing record. Simpler, but only works when the idempotency maps directly to a unique row. Transactional outbox pattern. For event-driven systems, combining idempotency with an outbox pattern ensures that both the DB write and the event emission happen exactly once. Overkill for most cases, but the right tool for complex distributed flows. Stripe's approach. Worth studying — Stripe stores idempotency keys server-side, tied to the authenticated user and the request path, and rejects keys reused with a different request body. A stricter but more robust implementation for financial systems.
Idempotency isn't glamorous, but it's one of those things that separates systems that work from systems that almost work. Pick the approach that fits your architecture, be consistent, and your future self will thank you.
Found this useful? Follow for more backend engineering content on NestJS, PostgreSQL, and distributed systems.



