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}`);
}
}