Skip to main content

Command Palette

Search for a command to run...

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.

Updated
5 min read
How to Implement Idempotency in a NestJS + PostgreSQL Stack (Without Losing Sleep Over Duplicate Requests)
E

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:

  1. The client generates a unique key (UUID) per user action

  2. It sends that key in every request for that action

  3. The server stores the result of the first execution

  4. 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.

More from this blog

Ernesto — Backend Engineering & Beyond

6 posts

Backend Engineer with 12+ years building scalable systems in Node.js, NestJS, TypeScript, and GCP. I write about backend architecture, API design, data pipelines, DevOps, and the engineering decisions that matter in production. Expect practical posts grounded in real fintech and SaaS experience — SQL/NoSQL, distributed systems, cloud infrastructure, and occasionally the full stack. No filler, just engineering.