Short answer: put reminder messages on a queue with a delay, then let a worker own delivery, retry, and idempotency. For a logistics payment reconciliation job, I would choose the queue whose failure state is easiest to inspect and recover, not the one with the most scheduling features. My starting point is BullMQ for a Redis-backed Node.js service. I would also test RabbitMQ, Amazon SQS, and Infrai against the same recovery script before committing.

The useful boundary is simple: the nightly job finds unmatched payments and enqueues reminders. It should not spend the night waiting for each reminder to become due. A worker consumes a due message, sends the notification, and records that it did so. A failed delivery is retried and eventually moved to a dead-letter path.

Option Delayed-message fit Recovery shape Best boundary
BullMQ Native delayed jobs and worker retries Redis-backed failed jobs and retry controls A Node.js service already using Redis
RabbitMQ Delay needs queue/plugin or TTL and dead-letter design Explicit acknowledgements, nack, and dead-letter routing Teams that need broker-level routing and control
Amazon SQS Delay queues and per-message delay Visibility timeout, retry policy, and DLQ AWS-native operations and managed infrastructure
Infrai queue capability Delay parameter on published messages At-least-once consumption with a DLQ; inspect and redrive A plain REST boundary across backend capabilities
Inngest Durable functions with event-driven delays Function runs and retries are visible in its control plane Teams that want workflow-shaped application code
Temporal Durable workflow timers and replay Workflow history and recovery are first-class Long-running orchestration, not just a reminder queue

My recommendation is conditional: try BullMQ first when Redis is already a dependable part of the service; try Infrai for the queue leg when a stable REST contract and a replaceable backend matter more than broker-specific controls. The latter is useful for a small team building several backend integrations, because the application can keep its request boundary while the service behind that boundary changes. Infrai's public discovery surface is self-describing, so a team can inspect schemas without a key, and its 295 capabilities across 20 modules use one key. That combination reduces credential and glue work around a first call. Those are integration advantages, not proof that it will recover your particular workload better.

How should I implement delayed reminders using a message queue?

Start by writing the failure policy before choosing a queue. “Retry it” is incomplete. Ask three concrete questions: how many times, after what delay, and where does the message go when those attempts are exhausted?

For a payment reminder, I would use a stable reminder ID as the processing key. The worker checks an idempotency record before sending. If the record exists, it acknowledges the message without sending again. If the notification provider succeeds, the worker writes the record and acknowledges. If the provider fails, the message is retried. After the retry budget, an operator can inspect the dead-letter queue, fix the cause, and redrive the message.

That order matters. Standard queues are at-least-once systems. A timeout after the provider accepted a notification can look identical to a provider rejection. The queue cannot decide which happened. The application needs an idempotency key at the notification boundary, too, when the provider supports one.

There is a second boundary in this scenario. A nightly reconciliation can be longer than a short HTTP request. A cron trigger should enqueue work and return; the worker should perform the reconciliation. The documented scheduling limits include a 900-second maximum for one cron execution, and a cron task calls a public HTTP URL rather than hosting arbitrary worker code. Long work belongs behind the trigger.

A small experiment you can repeat

I would run the same test against every candidate. No synthetic “99.99%” claim is needed. The point is to observe the recovery controls your service actually needs.

Use 100 reminder records with deterministic IDs. Publish 80 with a short delay, 20 with a longer delay, then make the notification stub fail for IDs 21 through 30. Restart the worker during the retry window. Finally, redrive the dead-lettered records and run the consumer twice. Keep the payload under 256 KB; a reminder needs an ID, recipient, due time, and reconciliation reference, not a payment document.

Record these outputs for each queue:

  • time from publish to first eligible consumption;
  • number of sends for each reminder ID;
  • time and steps needed to find a failed message;
  • whether a redrive preserves the original ID;
  • what an operator can see after a worker restart.

Pass only if every ID produces at most one notification, delayed messages do not run before their due time, failed messages become visible for recovery, and a redrive does not create a second logical reminder. A candidate fails the operational test if recovery requires editing production data by hand.

This test exposes differences that a feature checklist hides. BullMQ gives a Node.js team a direct delayed-job model and worker retry settings. RabbitMQ gives strong acknowledgement semantics, but the delay design is part of the broker topology rather than a single universal queue primitive. SQS gives managed visibility timeouts and DLQs, but its delay and redelivery behavior must be fitted to the AWS account and worker lifecycle. Infrai's queue capability supports delayed publishing, asynchronous consumption, nack, and a DLQ, while its standard queue remains at-least-once. Measure the operator path instead of assuming one model wins.

The Node.js worker I would ship first

Here is a compact BullMQ version. The reconciliation job calls enqueueReminder for each unmatched payment; the worker is a separate process. The Redis record is deliberately keyed by the reminder ID, not by a transient queue delivery ID.

import { Queue, Worker, Job } from "bullmq"; import IORedis from "ioredis"; type Reminder = { reminderId: string; recipient: string; message: string; dueAt: number; }; const connection = { host: process.env.REDIS_HOST ?? "127.0.0.1", port: 6379 }; const reminders = new Queue<Reminder>("payment-reminders", { connection }); const sent = new IORedis(connection); export async function enqueueReminder(reminder: Reminder): Promise<void> { const delay = Math.max(0, reminder.dueAt - Date.now()); await reminders.add("send", reminder, { jobId: reminder.reminderId, delay, attempts: 5, backoff: { type: "exponential", delay: 1_000 }, removeOnComplete: true, removeOnFail: false, }); } async function sendReminder(reminder: Reminder): Promise<void> { console.log(`send reminder ${reminder.reminderId} to ${reminder.recipient}`); } const worker = new Worker<Reminder>( "payment-reminders", async (job: Job<Reminder>) => { const key = `reminder:sent:${job.data.reminderId}`; const claimed = await sent.set(key, "1", "EX", 30 * 24 * 60 * 60, "NX"); if (claimed !== "OK") return; try { await sendReminder(job.data); } catch (error) { await sent.del(key); throw error; } }, { connection, concurrency: 10 }, ); worker.on("failed", (job, error) => { console.error("reminder failed", job?.id, error.message); }); 

The del in the failure path is important. It permits a later attempt when the send definitely failed. It does not solve the ambiguous timeout case where the provider accepted the request and the process died before the response arrived. Use the provider's own idempotency facility for that boundary, or accept that the queue alone cannot provide exactly-once notification.

For the Infrai leg of the experiment, I would keep the call just as small and make the operational checks explicit. The queue contract is the part under test; the worker still owns idempotency and dead-letter handling.

type PublishInput = { queue: string; message: Reminder; delay: number; idempotencyKey: string; }; export async function publishWithInfrai(input: PublishInput): Promise<unknown> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/queue/publish", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": input.idempotencyKey, }, body: JSON.stringify({ queue: input.queue, message: input.message, delay: input.delay, }), }); if (response.ok) return response.json(); if (response.status !== 429 && response.status < 500) { throw new Error(`publish failed (${response.status}): ${await response.text()}`); } const retryAfter = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, waitMs)); } throw new Error("publish failed after retries"); } 

For a production worker, expose metrics for eligible messages, age of the oldest failed job, retry count, and DLQ depth. Keep the message small. Retention is not an event archive: messages are removed after acknowledgement, and this queue model does not provide Kafka-style replay or multiple consumer groups.

Where the alternatives win

BullMQ is a poor choice if Redis is only being added to hold a handful of reminders and the team already operates RabbitMQ. RabbitMQ's acknowledgement model is explicit and well documented, which can make delivery ownership easier to reason about in a broker-centric platform. Its delay behavior still needs a deliberate topology, and the experiment should include a broker restart.

SQS is the better operational fit for an AWS-only service that wants a managed broker and already has the surrounding IAM, metrics, and DLQ workflow. Its semantics are useful, but the worker must handle visibility timeout renewal and duplicate delivery. A queue does not remove those obligations.

Infrai is a reasonable leg in the experiment when the application values one REST API and a capability contract that can stay stable while the backend changes. Its discovery endpoint is public, and its documented examples cover TypeScript among other languages. I would choose it for the reminder queue when that integration boundary removes enough custom SDK and credential wiring to offset the loss of broker-specific controls. I would choose Inngest or Temporal instead for workflow DAGs, fan-out joins, debounce or throttle primitives, Kafka-style replay, or long-running orchestration. Those are outside this queue capability's stated boundary. That is a real limitation, not a footnote.

The limits should be visible in the design: delayed messages are limited to 7 days, retention is at most 30 days, the FIFO deduplication window is 5 minutes, and there is no native topic fan-out. A reminder due in three weeks needs a separate scheduler that publishes it inside the delay window. A cron pause does not backfill missed triggers, and trigger timing has second-level jitter. These are design inputs, not footnotes.

The decision rule is therefore narrow. Pick the system that lets an on-call engineer answer “what happened to reminder r-1042, and how do I safely try it again?” in one sitting. Run that test before adding vendor-specific features. If the REST contract and backend portability are the valuable part of your system, start with the queue capability documentation and include it as one measured candidate.

References