npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@backendkit-labs/idempotency

v0.1.2

Published

Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations, pluggable store (in-memory / Redis)

Readme

@backendkit-labs/idempotency

npm version CI License Node Docs

Idempotency key enforcement for NestJS — replay cached responses, prevent duplicate mutations.

A client that retries a timed-out POST /orders request should not create two orders. This library intercepts duplicate requests at the HTTP layer, returns the original response from a store, and sets an Idempotent-Replayed: true header so the client knows it received a cached result — without any changes to your business logic.

Key design decisions: the composite key (METHOD:path:client-key) isolates the same client key across different endpoints. The store interface is pluggable — InMemoryIdempotencyStore works out of the box; RedisIdempotencyStore uses SET NX EX (a single atomic command) to prevent race conditions across multiple instances. When a handler throws, the key is deleted from the store so the client can retry with the same key.


Table of Contents


Installation

npm install @backendkit-labs/idempotency

Peer dependencies:

npm install @nestjs/common @nestjs/core rxjs

TypeScript Configuration

{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

And import reflect-metadata once at application startup:

// main.ts
import 'reflect-metadata';

Quick Start

1. Register the module (once, in AppModule):

import { IdempotencyModule } from '@backendkit-labs/idempotency';

@Module({
  imports: [
    IdempotencyModule.forRoot({
      ttlSeconds:      86_400,  // cache responses for 24 h
      pendingStrategy: 'reject', // 409 while in-flight (default)
    }),
  ],
})
export class AppModule {}

2. Decorate the endpoints that need protection:

import { Idempotent } from '@backendkit-labs/idempotency';

@Controller('orders')
export class OrdersController {
  @Post()
  @HttpCode(HttpStatus.CREATED)
  @Idempotent()
  async createOrder(@Body() dto: CreateOrderDto) {
    return this.ordersService.createOrder(dto);
  }
}

3. Clients send the Idempotency-Key header:

POST /orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: order-checkout-7f3a9b

{ "customerId": "cust-42", "items": [...] }

First call → 201 Created with the order body.
Same key again → 201 Created with the exact same body + Idempotent-Replayed: true.


Core Concepts

Key Lifecycle

Client sends request with Idempotency-Key
         │
         ▼
  Key exists in store?
  ├── YES, status=completed → replay cached response (skip handler)
  ├── YES, status=pending   → apply pendingStrategy (reject 409 / replay 202)
  └── NO  ─────────────────────────────────────────────────────────────┐
              Atomically insert pending record                          │
                       │                                               │
                       ▼                                               │
              Execute handler                                          │
              ├── SUCCESS → store.complete(key, statusCode, body) ─────┤
              └── ERROR   → store.delete(key)   ← client can retry ◄──┘

On success, the store entry transitions from pendingcompleted with the response body and status code persisted. On error, the key is deleted so the client can retry with the same idempotency key (the error was not a successful response, so there's nothing to replay).

Composite Key

The internal store key is always METHOD:path:client-key:

POST:/orders:order-checkout-7f3a9b
POST:/payments/charge:order-checkout-7f3a9b

The same client-supplied key is therefore isolated per endpoint. A client can reuse order-checkout-7f3a9b across /orders and /payments/charge without collision.

Pending Conflict Strategies

When two requests with the same key arrive concurrently (before the first one completes), the second sees a pending record. The behavior depends on pendingStrategy:

| Strategy | Response | Use when | |----------|----------|----------| | 'reject' (default) | 409 Conflict with a descriptive message | Client should wait and retry — safest for mutations | | 'replay' | 202 Accepted + Retry-After: 1 | Client will poll until it gets the real response |

// Per-endpoint override
@Idempotent({ pendingStrategy: 'replay' })
async createOrder(@Body() dto: CreateOrderDto) { ... }

Key Validation

The Idempotency-Key header is validated before the store is touched:

| Condition | Response | |-----------|----------| | Header missing | 422 Unprocessable Entity | | Header present but not 1–256 printable ASCII characters | 422 Unprocessable Entity | | Valid key, first request | 2xx (your handler's response) | | Valid key, cached response | 2xx + Idempotent-Replayed: true |


Module Setup

forRoot()

Synchronous setup with a plain options object:

import { IdempotencyModule } from '@backendkit-labs/idempotency';

IdempotencyModule.forRoot({
  ttlSeconds:      3_600,   // 1 hour
  pendingStrategy: 'reject',
  keyHeader:       'idempotency-key', // default — clients send this header
})

forRootAsync()

Asynchronous setup — useful when options come from ConfigService or another injectable:

import { IdempotencyModule } from '@backendkit-labs/idempotency';
import { ConfigService } from '@nestjs/config';

IdempotencyModule.forRootAsync({
  imports:    [ConfigModule],
  inject:     [ConfigService],
  useFactory: (config: ConfigService) => ({
    ttlSeconds:      config.get<number>('IDEMPOTENCY_TTL_SECONDS', 86_400),
    pendingStrategy: config.get<'reject' | 'replay'>('IDEMPOTENCY_PENDING_STRATEGY', 'reject'),
  }),
})

Module Options Reference

| Option | Type | Default | Description | |--------|------|---------|-------------| | ttlSeconds | number | 86400 | How long to cache a completed response (24 h). | | pendingStrategy | 'reject' \| 'replay' | 'reject' | What to do when a request arrives while an identical one is in-flight. | | keyHeader | string | 'idempotency-key' | HTTP header name to read the idempotency key from. |

IdempotencyModule is registered as global — import it once in AppModule and @Idempotent() is available everywhere.


@Idempotent() Decorator

Applied to individual controller methods. Routes without this decorator are completely unaffected — the interceptor does nothing.

import { Idempotent } from '@backendkit-labs/idempotency';

@Controller('orders')
export class OrdersController {
  // Uses module defaults
  @Post()
  @Idempotent()
  async createOrder(@Body() dto: CreateOrderDto) { ... }

  // Per-endpoint TTL override
  @Post('bulk')
  @Idempotent({ ttlSeconds: 300 })
  async bulkCreate(@Body() dto: BulkCreateDto) { ... }

  // Per-endpoint strategy override
  @Post('async-job')
  @Idempotent({ pendingStrategy: 'replay' })
  async startJob(@Body() dto: JobDto) { ... }
}

@Idempotent() options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | ttlSeconds | number | module default | Per-endpoint TTL override. | | pendingStrategy | 'reject' \| 'replay' | module default | Per-endpoint pending strategy override. |


Store Implementations

InMemoryIdempotencyStore

The default store. No configuration needed — registered automatically by IdempotencyModule.forRoot().

// Used automatically, no setup required
IdempotencyModule.forRoot({ ttlSeconds: 3600 })

Characteristics:

  • Entries expire lazily on the next access (no background timer).
  • Safe under Node.js's single-threaded execution model — setIfAbsent is atomic without locks.
  • Not suitable for multiple instances — each process has its own map. Use RedisIdempotencyStore in production.
  • Does not survive restarts — entries are lost on process exit.

RedisIdempotencyStore

For production deployments with multiple instances. Atomicity guaranteed by a single SET key value NX EX ttl command — no GET + SET race condition.

import { IdempotencyModule, RedisIdempotencyStore, IDEMPOTENCY_STORE } from '@backendkit-labs/idempotency';
import { createClient } from 'redis';

// node-redis adapter
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

IdempotencyModule.forRoot({
  ttlSeconds: 86_400,
  // Override the default InMemoryStore with Redis
  // (inject the store via IDEMPOTENCY_STORE token in forRootAsync)
})

For full Redis store setup, use forRootAsync and inject your Redis client:

import { IdempotencyModule, RedisIdempotencyStore, IDEMPOTENCY_STORE } from '@backendkit-labs/idempotency';

@Module({
  imports: [
    IdempotencyModule.forRootAsync({
      imports:    [RedisModule],
      inject:     [REDIS_CLIENT],
      useFactory: (redisClient) => ({
        ttlSeconds: 86_400,
      }),
    }),
  ],
  providers: [
    {
      provide:  IDEMPOTENCY_STORE,
      inject:   [REDIS_CLIENT],
      useFactory: (redisClient) => new RedisIdempotencyStore(redisClient),
    },
  ],
})
export class AppModule {}

RedisIdempotencyStore expects a client that satisfies the minimal RedisClient interface:

interface RedisClient {
  set(key: string, value: string, options: { nx: boolean; ex: number }): Promise<string | null>;
  get(key: string): Promise<string | null>;
  setex(key: string, seconds: number, value: string): Promise<unknown>;
  del(key: string): Promise<unknown>;
}

Both ioredis and node-redis satisfy this interface.

Custom Store

Implement the IdempotencyStore interface to plug in any persistence layer (DynamoDB, Postgres, Memcached):

import type { IdempotencyStore, IdempotencyRecord } from '@backendkit-labs/idempotency';
import { Injectable } from '@nestjs/common';

@Injectable()
export class DynamoIdempotencyStore implements IdempotencyStore {
  async setIfAbsent(record: IdempotencyRecord, ttlSeconds: number): Promise<IdempotencyRecord | null> {
    // Attempt a conditional write — return null if inserted, existing record if key already present
  }

  async get(key: string): Promise<IdempotencyRecord | null> { ... }

  async complete(key: string, statusCode: number, body: unknown, ttlSeconds: number): Promise<void> { ... }

  async delete(key: string): Promise<void> { ... }
}

Then register it via the IDEMPOTENCY_STORE token:

{
  provide:  IDEMPOTENCY_STORE,
  useClass: DynamoIdempotencyStore,
}

Error Reference

All errors are standard NestJS HttpException subclasses and are handled by NestJS's built-in exception filter.

| Error | Status | When thrown | |-------|--------|------------| | IdempotencyKeyMissingError | 422 | The configured keyHeader is absent from the request | | IdempotencyKeyInvalidError | 422 | The key is present but not 1–256 printable ASCII characters | | IdempotencyPendingConflictError | 409 | A request with this key is already in-flight and pendingStrategy is 'reject' |

// Example 422 response body
{
  "statusCode": 422,
  "error": "Unprocessable Entity",
  "message": "Missing required header: idempotency-key"
}

// Example 409 response body
{
  "statusCode": 409,
  "error": "Conflict",
  "message": "Request with idempotency key \"order-checkout-7f3a9b\" is still in progress"
}

Response Headers

| Header | Value | When present | |--------|-------|-------------| | Idempotent-Replayed | true | The response was served from the store — the handler was NOT called | | Retry-After | 1 | Set on 202 Accepted when pendingStrategy: 'replay' and the request is in-flight |


Architecture

IdempotencyModule.forRoot()
  ├── registers IdempotencyInterceptor as APP_INTERCEPTOR (global)
  ├── provides InMemoryIdempotencyStore via IDEMPOTENCY_STORE token
  └── provides Reflector (required for reading @Idempotent() metadata)

IdempotencyInterceptor
  ├── reads @Idempotent() metadata via Reflector — skips routes without it
  ├── validates Idempotency-Key header (presence + format)
  ├── builds composite key: METHOD:path:client-key
  ├── store.get()         — check for existing record
  ├── store.setIfAbsent() — atomic claim (first writer wins)
  ├── next.handle()       — execute handler if key was claimed
  ├── store.complete()    — persist response on success (awaited via mergeMap)
  └── store.delete()      — release key on handler error (client can retry)

IdempotencyStore (interface)
  ├── InMemoryIdempotencyStore  — Map<string, Entry> with lazy TTL eviction
  └── RedisIdempotencyStore     — SET NX EX (atomic, no GET+SET race)

License

Apache-2.0 — BackendKit Labs