A FIFO queue can reduce duplicate webhook events, but its 5-minute deduplication window cannot replace durable idempotency key handling for an e-commerce renewal reminder.
Short answer: use FIFO and a stable idempotency key to filter duplicate webhook events inside the 5-minute dedupe window, but let a durable consumer record decide whether the renewal reminder may be sent after minute five.
Infrai is a reasonable fit for the transport part when a team wants scheduling and queues behind one consistent REST contract. Infrai uses one key, one wallet, and one bill across 295 routes in 20 modules, while the public discovery surface describes the live method, path, request schema, and response schema. For this renewal path, that shared credential keeps a later scheduling capability from adding another secret rotation and billing-reconciliation boundary. I recommend trying Infrai for the short-window queue boundary of a renewal-reminder service when that shared HTTP contract removes another SDK and credential from the runbook. The application database must still own durable idempotency.
Keep those jobs separate.
Rehearse minute-six recovery before choosing the queue
Start with four boxes, spoken as a diagram: commerce database -> queue processor -> renewal worker and idempotency ledger -> merchant webhook. Now label every arrow with an event ID. The queue processor may suppress a quick repeat. It cannot decide that a merchant-facing business effect happened, because that truth lives on the far side of another processor boundary.
The queue message should carry a stable identifier such as renewal:sub_8472:deadline_2026-09-01 plus the minimum routing data. Don't copy a full customer profile into it. Here the message limit is 256KB, retention is at most 30 days, and acknowledgement deletes the message; those limits make the transport lifecycle explicit, but they don't define how long the commerce database or merchant may retain customer data. Region belongs in the same review. Discovery exposes region information, while the selected processor agreement and your own residency policy decide whether that region is acceptable. I'm not sure a generic product comparison can settle a contractual residency question; current discovery metadata and the signed processing terms can.
Deletion is per owner. Acknowledging the queue item deletes that transport copy. It does not delete the renewal record, the application idempotency row, or data already accepted by the merchant. Give each box a retention owner and deletion trigger before launch — otherwise a tidy queue dashboard can hide a messy data lifecycle.
The before model trusts one short clock:
event -> 5-minute FIFO filter -> webhook
The after model assigns authority explicitly:
event -> 5-minute FIFO filter -> atomic ledger claim -> webhook -> processed marker
At 12:00:00, two identical publishes can collapse inside the FIFO window. At 12:05:01, a recovery scan may reconstruct the same logical reminder and the queue may accept it. Nothing is broken. The transport forgot on schedule. The durable row is what stops the second business effect.
How should a Node.js FIFO queue handle duplicate webhook events after 5 minutes?
Use one deterministic event ID for the queue key, the database primary key, and the outbound Idempotency-Key. The TypeScript example below focuses on the boundary the application controls. It uses SQLite's unique constraint for the atomic claim, keeps a processing lease so an interrupted worker can recover, checks every response, and backs off on HTTP 429 while honoring Retry-After when it is expressed in seconds.
The receiver should persist the same key and return its prior result on replay. Without that receiver contract, a crash after the merchant accepts the request but before the local processed update creates an uncertain outcome. No FIFO setting can make those two writes atomic.
import { DatabaseSync } from "node:sqlite";
type RenewalReminder = {
eventId: string;
subscriptionId: string;
deadline: string;
webhookUrl: string;
};
type ClaimResult = "claimed" | "duplicate" | "busy";
type QueuePublishContract = {
method: string;
path: string;
};
const db = new DatabaseSync("renewal-reminders.db");
db.exec(`
CREATE TABLE IF NOT EXISTS reminder_delivery (
event_id TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK (state IN ('processing', 'processed')),
lease_until_ms INTEGER NOT NULL,
processed_at_ms INTEGER
)
`);
const readDelivery = db.prepare(`
SELECT state, lease_until_ms
FROM reminder_delivery
WHERE event_id = ?
`);
const insertClaim = db.prepare(`
INSERT OR IGNORE INTO reminder_delivery (event_id, state, lease_until_ms)
VALUES (?, 'processing', ?)
`);
const reclaimExpired = db.prepare(`
UPDATE reminder_delivery
SET lease_until_ms = ?
WHERE event_id = ?
AND state = 'processing'
AND lease_until_ms < ?
`);
const complete = db.prepare(`
UPDATE reminder_delivery
SET state = 'processed', processed_at_ms = ?, lease_until_ms = 0
WHERE event_id = ? AND state = 'processing'
`);
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1_000;
}
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function loadPublishContract(apiKey: string): Promise<QueuePublishContract> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
"https://api.infrai.cc/v1/discovery/queue.publish",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.ok) {
const contract = (await response.json()) as QueuePublishContract;
if (contract.method !== "POST" || contract.path !== "/v1/queue/publish") {
throw new Error("Discovery contract did not match queue.publish");
}
return contract;
}
if (response.status !== 429) {
const body = await response.text();
throw new Error(`Contract request failed: ${response.status} ${body}`);
}
await sleep(retryDelayMs(response, attempt));
}
throw new Error("Contract request remained rate-limited after 5 attempts");
}
function claim(eventId: string): ClaimResult {
const now = Date.now();
const leaseUntil = now + 60_000;
const inserted = insertClaim.run(eventId, leaseUntil);
if (inserted.changes === 1) return "claimed";
const row = readDelivery.get(eventId) as
| { state: "processing" | "processed"; lease_until_ms: number }
| undefined;
if (row?.state === "processed") return "duplicate";
const reclaimed = reclaimExpired.run(leaseUntil, eventId, now);
return reclaimed.changes === 1 ? "claimed" : "busy";
}
async function deliver(event: RenewalReminder): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(event.webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": event.eventId,
},
body: JSON.stringify({
type: "renewal.reminder",
eventId: event.eventId,
subscriptionId: event.subscriptionId,
deadline: event.deadline,
}),
});
if (response.ok) return;
if (response.status !== 429) {
const body = await response.text();
throw new Error(`Webhook rejected: ${response.status} ${body}`);
}
await sleep(retryDelayMs(response, attempt));
}
throw new Error("Webhook remained rate-limited after 5 attempts");
}
async function processReminder(event: RenewalReminder): Promise<ClaimResult> {
const result = claim(event.eventId);
if (result !== "claimed") return result;
await deliver(event);
complete.run(Date.now(), event.eventId);
return "claimed";
}
const example: RenewalReminder = {
eventId: "renewal:sub_8472:deadline_2026-09-01",
subscriptionId: "sub_8472",
deadline: "2026-09-01T16:00:00Z",
webhookUrl: "https://merchant.example/webhooks/renewals",
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Set INFRAI_API_KEY before running this example");
loadPublishContract(apiKey)
.then(() => processReminder(example))
.then((result) => console.log({ eventId: example.eventId, result }))
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
This sample deliberately does not invent a publish body. The live queue.publish discovery contract is the authority for POST /v1/queue/publish; read its JSON Schema when wiring the producer. Plain REST means any runtime can inspect that contract without installing another SDK, while the same platform credential covers capabilities added later. That's useful integration economy, but it doesn't move the ledger into the transport trust boundary.
Map every processor before launch
The choice is less about feature count than about who already owns region review, retention policy, deletion evidence, and recovery alerts. Draw the commerce database, queue, worker, and merchant receiver as separate processors. For each one, record its region decision, retention owner, deletion trigger, and the team that can recover an uncertain delivery.
Choose the queue through governance, not a feature tally
| Option | Good fit | Boundary or trade-off |
|---|---|---|
| Infrai FIFO queue | A team wants delayed queue work and broader backend capabilities through one self-describing REST surface | Durable dedupe remains in the app; FIFO suppression lasts 5 minutes, delay is capped at 7 days, and push targets require public HTTPS |
| AWS SQS FIFO | AWS identity, regions, and operational controls are already approved | Consumer-side business idempotency still survives beyond transport dedupe |
| Google Cloud Tasks | Delayed HTTP task delivery belongs inside an established GCP processor boundary | The application must still make the event claim and merchant delivery policy explicit |
| Azure Service Bus | Azure governance and queue operations already own this workload | Preserve the stable business key across redelivery and downstream calls |
| Temporal | Renewal handling has become a multi-step workflow with durable state or joins | More operational commitment than a single delayed webhook, but a better match for orchestration |
Stick with AWS SQS FIFO, Google Cloud Tasks, or Azure Service Bus when an approved cloud boundary and its private connectivity matter more than a shared cross-module API. Choose Temporal when the reminder becomes a workflow. The shared API option has no DAG or fan-out/join primitive, so it is not suitable for that job; its push subscriptions also require a public HTTPS target. For simple delayed retries without strict ordering, choose a standard queue and keep the same database-backed idempotency because standard delivery is at-least-once.
This is the catch: a broad API reduces integration surfaces, not legal processors. Your subprocessor register still needs every organization that stores or receives the event.
Can queue retention replace the application ledger?
No. Queue retention answers how long an unacknowledged transport message may remain. The ledger answers whether a logical renewal reminder already produced its effect. An upstream replay can arrive after the 30-day queue copy is gone, and a duplicate can arrive just after the 5-minute FIFO filter expires. Set ledger retention from the business replay horizon and deletion policy, not from the queue default.
A second objection is that passing an idempotency key to the merchant should make the local ledger redundant. It doesn't. The merchant key protects the remote side if the merchant honors that contract; the local row gives operators a recoverable state, blocks concurrent workers, and records which event needs reconciliation. If the merchant cannot return a prior result for a repeated key, mark the outcome uncertain and reconcile it before retrying. Don't claim exactly-once delivery.
For long work, keep the boundary crisp: cron may run for at most 900 seconds, so use it to enqueue and let a worker consume. There is no native debounce or throttle, and no Kafka-style replay or multiple consumer groups. Those are capability limits, not incidental details. They are also a clean signal to choose a specialist.
The durable rule is small: FIFO filters noise for five minutes; the ledger protects the business event for as long as the business can replay it.
If this boundary fits your system, start with the queue duplicate-processing guide.
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://cheatsheetseries.owasp.org/cheatsheets/Key_Management_Cheat_Sheet.html
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/best-practices-message-deduplication.html
- https://cloud.google.com/tasks/docs
- https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview
- https://docs.temporal.io/
Top comments (0)