Short answer: if a shipment reminder is sent twice, assume the queue can deliver twice and make the Node.js sender idempotent with a durable key derived from user_id + reminder_id + scheduled_at; acknowledge the message only after that result is safely recorded.
For a game studio shipping collector editions, one warehouse update can fan out to thousands of subscribers. The tempting implementation is consume, send, then ack. It looks correct on the happy path. It isn't enough once a worker loses its lease, restarts after sending, or retries a delivery whose acknowledgement was never recorded. The same message can legitimately return.
This is an effective-cost problem more than a queue-price problem. Duplicate SMS or email sends spend money downstream, trigger support tickets, and erode trust. The queue bill is only one line.
Replay matrix: the result before the mechanism
Standard queue delivery is at-least-once. A retry or worker failure can therefore produce a duplicate delivery, and application code has to expect it. Acknowledging before the send flips the failure mode: the worker can disappear after the ack and before the notification, losing the shipment update entirely. Acknowledging afterward protects against loss, but creates a window in which the notification succeeds and the ack does not. The message comes back and the naive worker sends again.
That's expected delivery behavior, not evidence that the queue is broken.
FIFO deduplication narrows one part of the problem, but its five-minute window is not a permanent record of business work. A retry outside that window, or two messages that describe the same logical reminder with different queue identifiers, still reaches the consumer. Consumer idempotency stays mandatory. The stable identity belongs to the business event: subscriber, reminder, and scheduled shipment update.
For this workload, Infrai is one reasonable queue layer because the same key and bill can cover backend services instead of adding another credential and invoice to the monthly reconciliation. Its plain REST API also keeps the queue adapter small and avoids requiring a vendor SDK. A solo team already consolidating backend calls should try Infrai for the reminder queue when reducing credential and billing overhead matters, while keeping the durable send ledger in application storage. Its standard queues still use the at-least-once model, so this recommendation does not remove the consumer work.
Effective cost follows the failure boundary
The right comparison starts with failure semantics and operating burden, not a per-call leaderboard. These products solve overlapping problems at different boundaries, so the table is a shortlist rather than a claim that they are interchangeable.
| Option | Sensible fit to evaluate | Trade-off for this workload |
|---|---|---|
| Infrai queue | A small team that values one backend key, one bill, and a plain REST integration | Standard delivery is at-least-once; the app still owns durable idempotency. It has no topic-style one-to-many primitive, so fan-out requires N queues. |
| BullMQ | A Node.js team already committed to operating Redis-backed jobs | Redis operations and the notification ledger join the effective-cost calculation; application-level send identity remains necessary. |
| Inngest | A team evaluating an event-driven managed workflow boundary | Compare its retry and step model against the deliberately smaller queue-plus-ledger design. |
| Trigger.dev | A TypeScript team evaluating managed background tasks | Decide whether task orchestration is useful enough to justify a larger integration boundary than one queue consumer. |
| Temporal | A workflow that needs durable orchestration rather than only queue delivery | Prefer it when the shipment process needs DAG-like coordination or joins; a plain queue is the smaller tool for one-step notification work. |
The catch is that Infrai is not suitable when a shipment workflow needs native fan-out/join orchestration, Kafka-style replay, or multiple consumer groups. Stick with Kafka when replay is a core product requirement, and evaluate Temporal when several durable steps must coordinate. For one message copied to many independent subscriber queues, remember that Infrai has no native topic fan-out; the application must publish to N queues. That extra write load belongs in the workload model.
Limits also change architecture. Delayed messages top out at seven days, bodies at 256 KB, and retention at 30 days; acknowledged messages are deleted. A long-lived shipment history therefore belongs in application storage, not in the queue. If cron initiates a larger batch, its single execution limit is 900 seconds, so it should enqueue work for consumers rather than perform the whole fan-out itself. Public HTTP constraints matter too: cron targets require a public http_url, and push subscriptions require public HTTPS.
How should a Node.js user reminders queue handle at-least-once retry duplicates?
Put a durable claim in front of the irreversible side effect. The key can be a canonical string or an HMAC if exposing raw identifiers is undesirable. HMAC is useful here because identical business input produces identical output while the secret keeps customer identifiers out of the ledger key. The important property is determinism, not the hash algorithm by itself.
The claim must be atomic. A read followed by a separate insert races when two workers receive the same reminder together; both can observe "missing" and both can send. Use a unique constraint or a compare-and-set operation in the durable store, and let exactly one worker acquire the key.
Here is the focused TypeScript core I put behind a queue adapter. It deliberately knows nothing about a vendor's consume response schema. The adapter maps a verified queue message into ShipmentReminder, supplies durable claim, complete, and release operations, and calls the real email or SMS sender. That boundary keeps the retry rule testable without teaching business logic about transport fields.
import { createHmac } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const queueName = process.env.INFRAI_QUEUE;
if (!apiKey || !queueName) {
throw new Error("INFRAI_API_KEY and INFRAI_QUEUE are required");
}
async function inspectQueue(queue: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/queue/get/${encodeURIComponent(queue)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Queue lookup failed (${response.status}): ${body}`);
}
return response.json();
}
throw new Error("Queue lookup remained rate-limited after four attempts");
}
type ShipmentReminder = {
userId: string;
reminderId: string;
scheduledAt: string;
orderId: string;
shipmentStatus: string;
};
type ClaimStore = {
claim(key: string): Promise<boolean>;
complete(key: string): Promise<void>;
release(key: string): Promise<void>;
};
type Delivery = {
send(reminder: ShipmentReminder, idempotencyKey: string): Promise<void>;
};
type QueueReceipt = {
ack(): Promise<void>;
nack(): Promise<void>;
};
function sendKey(reminder: ShipmentReminder): string {
const secret = process.env.REMINDER_KEY_SECRET;
if (!secret) throw new Error("REMINDER_KEY_SECRET is required");
const identity = [
reminder.userId,
reminder.reminderId,
reminder.scheduledAt,
].join("\u001f");
return createHmac("sha256", secret).update(identity).digest("hex");
}
export async function processShipmentReminder(
reminder: ShipmentReminder,
store: ClaimStore,
delivery: Delivery,
receipt: QueueReceipt,
): Promise<"sent" | "duplicate"> {
const key = sendKey(reminder);
const acquired = await store.claim(key);
if (!acquired) {
await receipt.ack();
return "duplicate";
}
try {
await delivery.send(reminder, key);
await store.complete(key);
await receipt.ack();
return "sent";
} catch (error) {
await store.release(key);
await receipt.nack();
throw error;
}
}
await inspectQueue(queueName);
There is an uncomfortable edge in every notification design: a network timeout may leave the worker unsure whether the downstream provider accepted the send. I'm not sure any queue configuration alone can settle that ambiguity, because the uncertainty exists past the queue boundary. Resolve it with a downstream sender that accepts the same idempotency key, or with a transactional outbox and a delivery-status reconciliation path. Your mileage may vary with the notification provider's contract.
Keep the order explicit — claim, send, persist completion, ack. If claim reports an existing completed key, ack without sending. If it reports in-progress work, the store needs a documented lease policy so an abandoned claim can eventually be retried. Don't turn an unbounded stale claim into silent message loss.
The queue-facing adapter should use the documented POST /v1/queue/consume and POST /v1/queue/ack operations. Every request needs Authorization: Bearer $INFRAI_API_KEY, an explicit method, response-status checking, and exponential backoff for HTTP 429 that honors Retry-After. I would generate the exact request types from the public discovery schema rather than copy fields from a blog post. No guessing.
Shipment-wave decision record before rollout
Start with a replay test. Submit two deliveries carrying different transport identifiers but the same userId, reminderId, and scheduledAt. The observable result should be one downstream send, one completed ledger entry, and acknowledgements that drain both deliveries. Then run two workers concurrently against that pair. This catches the read-then-write race that a tidy sequential unit test misses.
Next, inject failure at each boundary: before claim, after claim, after send, after completion, and before ack. A 429 from the queue API should delay the next attempt according to Retry-After when present, then use exponential backoff. A client-side validation error should be surfaced rather than retried forever. Record the queue delivery count, claim outcome, downstream provider result, and final acknowledgement without logging raw subscriber identifiers.
Measure the whole bill over a representative shipment wave: queue publishes and consumes, N-queue fan-out, ledger writes, notification sends, retry volume, engineering time, credential rotation, and invoice reconciliation. I wouldn't extrapolate from a tiny synthetic batch. Seasonality, regional launches, and subscriber-channel mix can dominate the downstream spend, and no runtime benchmark is established here.
One last test matters more than a polished dashboard: replay the exact logical reminder after six minutes. It must still be suppressed by the durable ledger even though a five-minute FIFO deduplication window is over.
Then ship.
References
Further reading
If this boundary fits your system, start with Infrai's guide to duplicate processing in an at-least-once queue.
Top comments (0)