Skip to content

Recipes ​

Webhook Redelivery ​

Providers (Stripe, GitHub, Shopify) redeliver on timeouts. Key by the provider's event id over the redelivery horizon:

typescript
import { Injectable } from '@nestjs/common';
import { Dedupe } from '@nestjs-redisx/dedup';
import { StripeEvent } from './types';

@Injectable()
export class WebhookService {
  // Providers redeliver: each event id is handled once per 5 minutes,
  // fleet-wide. Duplicate deliveries resolve to undefined without running
  // the body. A THROWN first attempt releases the marker, so the provider's
  // retry is not locked out for the rest of the window.
  @Dedupe({ key: 'stripe:{0}', window: 300_000 })
  async handleStripeEvent(eventId: string, event: StripeEvent): Promise<void> {
    // ...apply the event exactly once per window...
  }
}

At-Least-Once Queue Consumers ​

SQS/RabbitMQ redeliveries carry the same message id - gate the handler:

typescript
async handleMessage(msg: QueueMessage) {
  await this.dedup.dedupe(`queue:${msg.id}`, () => this.process(msg), {
    window: 15 * 60_000, // ≥ visibility timeout × max receives
  });
}

A thrown process() releases the marker, so the queue's redrive actually retries.

Double-Click / Double-Submit Guard ​

typescript
@Dedupe({ key: (userId, action) => `ui:${String(userId)}:${String(action)}`, window: 3_000 })
async performAction(userId: string, action: string) { /* one per 3s per user+action */ }

For response replay on retried HTTP requests, use idempotency instead.

Collapsing Bursts (Distributed Debounce) ​

A hundred product updates in a minute should trigger one index rebuild - after the burst, wherever the last update landed:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { DEDUP_SERVICE, IDedupService } from '@nestjs-redisx/dedup';
import { SearchIndex } from './types';

@Injectable()
export class IndexingService {
  constructor(
    @Inject(DEDUP_SERVICE)
    private readonly dedup: IDedupService,
    private readonly searchIndex: SearchIndex,
  ) {}

  // Trailing debounce ACROSS INSTANCES: a burst of product updates triggers
  // ONE rebuild, after the burst goes quiet for 30s, on the instance that
  // received the LAST update. Superseded calls resolve undefined.
  async onProductChanged(): Promise<void> {
    await this.dedup.debounce('reindex:products', () => this.searchIndex.rebuild(), { window: 30_000 });
  }
}

Manual Steering ​

When the flow needs the boolean rather than a wrapped function:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { DEDUP_SERVICE, IDedupService } from '@nestjs-redisx/dedup';
import { Mailer } from './types';

@Injectable()
export class OrderEventsService {
  constructor(
    @Inject(DEDUP_SERVICE)
    private readonly dedup: IDedupService,
    private readonly mailer: Mailer,
  ) {}

  // dedupe(): run the side effect for the FIRST occurrence only.
  async onOrderCreated(orderId: string): Promise<void> {
    await this.dedup.dedupe(`order-created:${orderId}`, () => this.mailer.sendOrderConfirmation(orderId));
  }

  // isFirst(): plain boolean check when you steer the flow yourself.
  async shouldProcess(messageId: string): Promise<boolean> {
    return this.dedup.isFirst(`queue:${messageId}`, { window: 600_000 });
  }

  // release(): manually re-admit a key (support tooling, forced retry).
  async allowRetry(orderId: string): Promise<boolean> {
    return this.dedup.release(`order-created:${orderId}`);
  }
}

Released under the MIT License.