typescript 126 lines · 4 tabs

Idempotency-Key Interceptor in NestJS to Debounce Duplicate Form Submissions

Shared by codesnips Sep 2026
4 tabs
import { Injectable } from '@nestjs/common';

type Completed = { status: 'completed'; statusCode: number; body: unknown; expiresAt: number };
type InFlight = { status: 'in-flight'; startedAt: number };
type Record = Completed | InFlight;

export type BeginResult =
  | { outcome: 'claimed' }
  | { outcome: 'in-flight' }
  | { outcome: 'replay'; statusCode: number; body: unknown };

@Injectable()
export class IdempotencyStore {
  private readonly records = new Map<string, Record>();
  private readonly ttlMs = 24 * 60 * 60 * 1000;

  begin(key: string): BeginResult {
    const existing = this.records.get(key);
    if (existing) {
      if (existing.status === 'completed' && existing.expiresAt > Date.now()) {
        return { outcome: 'replay', statusCode: existing.statusCode, body: existing.body };
      }
      if (existing.status === 'in-flight') {
        return { outcome: 'in-flight' };
      }
      this.records.delete(key); // expired completed record
    }
    this.records.set(key, { status: 'in-flight', startedAt: Date.now() });
    return { outcome: 'claimed' };
  }

  complete(key: string, statusCode: number, body: unknown): void {
    this.records.set(key, { status: 'completed', statusCode, body, expiresAt: Date.now() + this.ttlMs });
  }

  release(key: string): void {
    this.records.delete(key);
  }
}
4 files · typescript Explain with highlit

This snippet shows how a NestJS request pipeline can absorb accidental double-submits — a user double-clicking a button, a mobile client retrying on a flaky connection, or a proxy replaying a POST — by keying on a client-supplied Idempotency-Key header. The core idea is that a mutating request should produce the same effect and the same response whether it arrives once or five times, so the server records the outcome under that key and replays it for later duplicates.

The IdempotencyStore tab is a small in-memory store that models three states per key: in-flight (a request is currently being processed), completed (a cached response body and status), and absent. begin uses a single Map lookup plus insert to atomically claim a key, returning a discriminated result so callers can tell whether they won the race or found an existing record. Because Node runs request handlers cooperatively, this check-then-set is safe within a single process; a TTL sweep in complete and lazy expiry in begin keep the map from growing unbounded. The obvious trade-off is that this store is per-instance — behind a load balancer it must be swapped for Redis, which is why the store is isolated behind a narrow interface.

The IdempotencyInterceptor tab wires the store into the request lifecycle. It reads the header, and when a key is present it calls store.begin. If a completed record exists it short-circuits with of(...) and never touches the controller. If another request is in-flight it throws 409 Conflict rather than double-processing. Otherwise it lets the handler run, and tap/catchError persist the result or release the key on failure so a genuine retry can proceed.

The @Idempotent decorator tab combines SetMetadata with UseInterceptors so a route opts in with one annotation, and the interceptor reads that metadata via the Reflector to require the header only where it matters. The PaymentsController tab ties it together: create is marked @Idempotent(), so replays of the same charge return the original result instead of billing twice. A key pitfall to note is that clients must reuse the same key across retries — generating a fresh UUID per attempt defeats the mechanism entirely.


Related snips

Share this code

Here's the card — post it anywhere.

Idempotency-Key Interceptor in NestJS to Debounce Duplicate Form Submissions — share card
Link copied