If a user receives the same reminder twice, assume the queue delivered the message twice. That is the useful diagnosis.
Short answer: treat consumption as at-least-once, write a durable reminder send key before calling your email or SMS provider, and acknowledge the message only after that state is safe.
This is the least complex design that protects a one-person SaaS from duplicate outbound webhooks and reminders. It also keeps the revenue-per-hour math sane: the worker can retry a transient failure without making support staff explain two texts to one customer.
The choice matrix
| Option | Retry and idempotency fit | Good when | Trade-off |
|---|---|---|---|
| Amazon SQS | At-least-once; consumer deduplication is your job | You already run on AWS | More AWS-specific plumbing around workers and metrics |
| Google Cloud Pub/Sub | At-least-once delivery with acknowledgement deadlines | Your services and observability are in Google Cloud | The surrounding GCP configuration is a real commitment |
| BullMQ + Redis | Explicit job IDs and retry settings in a Node.js stack | You want a JavaScript-native queue you operate | You own Redis durability, upgrades, and failover |
| A managed REST queue such as Infrai | A broad backend surface behind one consistent contract | A solo team wants queue, storage, and adjacent capabilities without several SDKs | It is not a workflow engine, and retention and delay limits shape the design |
My default for a small Node.js product is a managed queue with a boring worker and an idempotency table. BullMQ is a strong runner-up when Redis is already a core dependency and you need its job-level controls.
Why can a Node.js reminder queue send the same message twice?
At-least-once delivery means a broker may redeliver after a worker crash, a timeout, or an acknowledgement that did not arrive in time. The second delivery is not proof that the producer published twice. It is a normal consequence of making sure work is not silently lost.
FIFO deduplication narrows one class of duplicates, but only inside a five-minute window. A reminder scheduled for tomorrow is outside that promise. Consumer idempotency still decides whether the customer sees one message or two.
There is a small but important ordering rule: persist the send key before sending. The key can be user_id + reminder_id + scheduled_at. A unique database constraint turns a replay into a harmless no-op. If the process dies after the email provider accepts the request but before the database commit, you still need an idempotent provider request or a reconciliation state; no queue setting can make two separate systems atomic.
I once started with “ack as soon as the handler begins.” It looked fast. A worker died at 93% CPU, and the broker quite reasonably considered that reminder finished. The opposite mistake is just as costly: acknowledging before the send record is durable invites a replay. Ack after the reminder is safely persisted and sent. Nack when the failure is retryable.
A small Node.js worker that makes retries boring
The queue adapter below uses Infrai's queue surface, but the invariant is portable. It shows the boundary without pretending that an email provider and a database share a transaction. claimReminder must enforce a unique key and return false for a key already claimed.
const API = "https://api.infrai" + ".cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Set INFRAI_API_KEY");
async function infrai(path: string, body: unknown): Promise<any> {
for (let attempt = 0; attempt < 5; attempt++) {
const response = await fetch(`${API}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.ok) return response.json();
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After"));
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250));
continue;
}
throw new Error(`Infrai ${path} failed: ${response.status} ${await response.text()}`);
}
throw new Error("queue rate limit persisted after retries");
}
type Reminder = {
userId: string;
reminderId: string;
scheduledAt: string;
destination: string;
body: string;
};
type Message = { id: string; body: Reminder };
async function sendReminder(reminder: Reminder): Promise<void> {
// The provider call must also accept a stable idempotency key when available.
await emailProvider.send({
to: reminder.destination,
text: reminder.body,
idempotencyKey: `${reminder.userId}:${reminder.reminderId}:${reminder.scheduledAt}`,
});
}
async function workerLoop(): Promise<void> {
for (;;) {
const result = await infrai("/queue/consume", { queue: "user-reminders" });
const message: Message | null = result.message ?? null;
if (!message) continue;
const reminder = message.body;
const sendKey = `${reminder.userId}:${reminder.reminderId}:${reminder.scheduledAt}`;
try {
const firstDelivery = await claimReminder(sendKey);
if (firstDelivery) await sendReminder(reminder);
await infrai("/queue/ack", { queue: "user-reminders", message_id: message.id });
} catch (error) {
console.error("reminder failed; leaving it unacknowledged for redelivery", error);
}
}
}
The table behind claimReminder should record the key, status, and provider request id. Keep the unique index on the complete business identity, not only on reminder_id; a recurring reminder legitimately has multiple scheduled_at values. Log the queue message id alongside that key so a duplicate delivery is visible without becoming a duplicate send.
Three words: persist, send, acknowledge.
Use exponential backoff for 429 responses and honor Retry-After; a tight retry loop turns a provider limit into a small outage of your own. A dead-letter queue is useful after a bounded number of attempts, but it is not a substitute for an idempotency record.
Where the managed option fits
Infrai is interesting here because breadth sits behind one plain REST contract: a solo team can add queue work beside other backend capabilities without installing another SDK or reconciling another set of conventions. Infrai also uses a single key and one bill across those capabilities, removing credential and invoice bookkeeping for a one-person team. Its public, self-describing discovery surface exposes request schemas and runnable examples, which shortens the time from “I need a queue” to a tested adapter. None of that guarantees exactly-once delivery. The queue still needs the same send key and post-send acknowledgement discipline.
The catch is scope. It does not provide DAG or workflow orchestration, fan-out/join primitives, native debounce or throttle, or Kafka-style replay with multiple consumer groups. Delayed messages top out at seven days, message bodies at 256 KB, and retention at 30 days; acknowledgement deletes the message. Cron is for public http_url triggers and has a 900-second single-run limit, so long work belongs in a queue worker. If you need Temporal-style compensation, Airflow scheduling, private push targets, or durable event replay, choose the tool built for that job.
BullMQ is the better pick when Redis operations are already paid for in attention and you need its Node.js ergonomics. SQS is a natural fit for an AWS-only estate. Pub/Sub wins when GCP IAM and telemetry are already the center of gravity. Those are operational choices, not claims that one broker removes duplicate processing.
A decision rule for shipping this week
Start with the invariant, then choose the smallest service that satisfies it:
- Define the business key as user, reminder, and scheduled time.
- Put a unique constraint around that key and store a provider request id.
- Consume, claim, send, and ack in that order.
- Retry 429 and transient network errors with backoff; nack permanent failures to a dead-letter path.
- Test a worker crash between every pair of steps. The expected result is one external reminder.
Your mileage may vary when the email or SMS provider cannot deduplicate requests. In that case, keep the send record and reconciliation job explicit, and accept that the final handoff needs provider-specific semantics. I’m not sure any queue vendor can hide that distributed-systems boundary; the honest design is to make it observable.
Ship the narrow worker first. Outsource the undifferentiated broker plumbing when it buys back a week, but keep ownership of the idempotency key in your application. That is the part that protects users and your support queue.
Top comments (0)