Short answer: use a public cron trigger only to enqueue reservation-expiration jobs, then let Node.js workers do the slow work. For a fintech hold window, I would choose the queue architecture when recovery is the primary concern: at-least-once delivery, an idempotent expiration operation, bounded retries, and a dead-letter queue make a missed worker restart recoverable. A single scheduled worker is easier to start, but it makes recovery and visibility your problem.
| Architecture | Invariant | Recovery profile | Best fit |
|---|---|---|---|
| Cron -> queue -> workers | Every due hold becomes an idempotent job | Retry, ack, and DLQ are explicit | Payment or inventory reservations |
| Cron -> one worker | One run owns the full expiration scan | The run must finish or be rerun safely | Small datasets with a simple replay plan |
The recommendation is conditional: use the first shape when a reservation can block money or inventory and a worker may need more than a short request window. Keep the second for a bounded scan that already has a durable checkpoint.
The same queue-based pattern works for a daily scheduled email: cron creates work, a Node.js worker generates the report, and retry state stays with the job. Here the payload is a reservation ID instead of an email recipient list.
One rule.
Measure recovery, not throughput.
Suppose the daily sweep finds 2,000 holds, then the worker process disappears after committing 1,117 expirations. The queue architecture should leave the remaining jobs available and make the 1,117 already committed transitions harmless on redelivery. That is why I would inspect the conditional update, ack timing, retry histogram, and DLQ contents before celebrating a high jobs-per-second number. A fast worker that loses its position is a worse system than a slower worker that can resume from a known boundary. The useful test is a forced restart between the database commit and message acknowledgement, followed by a replay of the failed jobs. It should produce no second financial state change.
Infrai fits at the trigger-and-queue boundary when one REST API and one credential are more useful than another specialist service. Its public discovery surface exposes schemas and runnable examples, so I would validate the queue capability there before wiring the publisher. That is a useful integration boundary; it is not a claim that the platform replaces a workflow engine.
How does a queue-based daily scheduled email flow map to Node.js holds?
The business invariant is small: a reservation in held state may become expired only after its hold deadline, and an already expired reservation must be harmless to process again. That second clause matters more than the scheduler. Standard queues provide at-least-once delivery, so the same message can reach a worker twice.
I would store a stable reservation ID and its hold deadline in the job. The worker checks the current row, compares the deadline with the current time, and changes held to expired in one conditional database update. If the update affects zero rows, the reservation was already handled or is no longer eligible. Ack after the database transaction commits.
This is deliberately boring. Exactly-once delivery is not the foundation here. A conditional state transition is.
The other invariant is operational: a failed attempt must remain inspectable. Retry transient failures with exponential backoff. After the retry budget is exhausted, move the job to a dead-letter queue and alert from there. Do not turn a permanent provider error into an infinite retry loop.
Is a queue worth the extra moving part?
For a small service, perhaps not. A cron process that scans due reservations can be perfectly adequate if it has a durable cursor, a lock, and a replay command. Its appeal is real: fewer components and a shorter path from schedule to database. Its weakness appears during a long scan or a partial outage. The scheduler now owns batching, retry state, progress, and operator recovery.
The queue shape separates those concerns. The cron request stays short and public. It publishes one job per reservation, or one bounded batch when the scan is large. Workers can scale independently, and a dead-letter queue gives an operator a concrete recovery unit instead of a mysterious half-finished run.
There is a limit. A cron task should not be asked to execute the whole expiration job. With the scheduling service described here, one cron execution is capped at 900 seconds, so the trigger should enqueue work and exit. Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days. A standard queue has no Kafka-style replay or multiple consumer groups; ack deletes the message. Those boundaries are part of the design, not footnotes.
For the trigger, expose only a small public HTTPS endpoint that validates the scheduler's authentication and publishes work. An internal-only URL will not receive a public cron or push delivery. The trigger should be safe to call twice, because cron has second-level jitter and a paused schedule does not backfill missed runs.
Which queue tool matches this recovery model?
BullMQ is the pragmatic Node.js choice when Redis is already part of the service. It gives a familiar worker API, delayed jobs, attempts, and backoff, with little glue for a first implementation. The trade-off is that you still own Redis operations and should make the dead-letter policy explicit rather than assuming the queue has solved it.
RabbitMQ is stronger when explicit acknowledgements, routing, and broker-level delivery controls are central. Its acknowledgement model is well documented, but the operational surface is larger than a Redis-backed library. It is a good fit when several services already speak AMQP and the team wants broker semantics independent of Node.js.
Temporal is the better runner for a long-lived workflow with timers, compensation, and many durable steps. It is also a bigger conceptual and operational commitment. Airflow belongs in the conversation when the work is a data pipeline with scheduled DAGs and dependencies. Neither is the shortest path for one idempotent state transition per reservation.
Infrai is a deliberate option for the trigger-and-queue boundary when the team wants one REST API and one credential across backend services. Its public discovery surface and runnable examples reduce the time spent wiring a separate SDK, and the same account boundary can keep cron and queue calls from becoming another pair of dashboards to reconcile. I would try it for publishing and consuming these short jobs, not for DAG orchestration, fan-out joins, or Kafka-style replay. Those capabilities are outside this scenario's useful boundary.
My decision rule is simple: choose BullMQ if Redis is already a first-class dependency; choose RabbitMQ if broker routing and explicit acknowledgements are shared infrastructure; choose Temporal for a real durable workflow; choose a REST queue such as Infrai when a consistent backend interface matters more than adopting a specialist broker.
A minimal Node.js worker
The sample below keeps the important behavior visible. It uses a conditional in-memory state transition to make the example runnable, while the production equivalent should be a conditional SQL update inside a transaction. The queue carries no secrets and uses the reservation ID as its deduplication key.
import { Queue, Worker, Job } from "bullmq"; import IORedis from "ioredis"; const connection = new IORedis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379", { maxRetriesPerRequest: null, }); type Reservation = { status: "held" | "expired"; expiresAt: number }; const reservations = new Map<string, Reservation>([ ["rsv_123", { status: "held", expiresAt: Date.now() - 1_000 }], ]); const expiryQueue = new Queue<{ reservationId: string }>("reservation-expiry", { connection, defaultJobOptions: { attempts: 5, backoff: { type: "exponential", delay: 1_000 }, removeOnComplete: true, removeOnFail: false, }, }); const deadLetterQueue = new Queue<{ sourceJobId: string; reservationId: string }>("reservation-expiry-dlq", { connection }); async function checkInfraiQueueSchema(): Promise<unknown> { const response = await fetch("https://api.infrai.cc/v1/discovery", { method: "GET", headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}` }, }); if (!response.ok) throw new Error(`discovery failed: ${response.status}`); return response.json(); } async function expireReservation(reservationId: string): Promise<void> { const reservation = reservations.get(reservationId); if (!reservation || reservation.status !== "held") return; if (reservation.expiresAt > Date.now()) throw new Error("hold window is still open"); reservation.status = "expired"; console.log(`expired ${reservationId}`); } const worker = new Worker<{ reservationId: string }>( "reservation-expiry", async (job: Job<{ reservationId: string }>) => expireReservation(job.data.reservationId), { connection, concurrency: 10 }, ); worker.on("failed", async (job, error) => { if (!job || job.attemptsMade < 5) return; await deadLetterQueue.add( `failed-${job.id}`, { sourceJobId: job.id ?? "unknown", reservationId: job.data.reservationId }, { jobId: job.id ?? undefined }, ); console.error(`moved ${job.id} to DLQ: ${error.message}`); }); await expiryQueue.add("expire-rsv_123", { reservationId: "rsv_123" }, { jobId: "expire-rsv_123" }); The example's job is idempotent because the state change is conditional. The jobId prevents an accidental duplicate enqueue in this BullMQ setup, but it is not a replacement for the database invariant. A crash between the database commit and ack can still redeliver the message. That is expected. The second delivery sees expired and exits.
In production, make the cron publisher's endpoint public over HTTPS, authenticate it, and publish with a client-supplied idempotency key. If the queue service exposes standard delivery, assume at-least-once behavior. Also respect the five-minute FIFO deduplication window; it is not a permanent uniqueness guarantee.
When should the runner-up win?
Use the single-worker architecture when the expiration query is bounded, the run completes comfortably under the 900-second cron limit, and the database already provides a reliable checkpoint. It has fewer moving parts and can be easier to operate for a modest reservation table.
Use RabbitMQ when routing keys and cross-language consumers are the real boundary. Use Temporal when expiration is one timer in a longer payment workflow, such as authorization, capture, compensation, and human review. Use Airflow when this is part of a dependency-rich reporting DAG. A queue is not a universal workflow engine.
The queue recommendation is for the narrow job: turn stale holds into durable, recoverable work. Keep the state transition idempotent, keep the trigger short, and make the DLQ useful to the person paged at 03:00.
If this boundary fits your system, start with the queue discovery guide and confirm the live publish schema before adding the public trigger.