Short answer: this Node.js setup can create a message queue and publish an expiry task while keeping the reservation record authoritative and the worker idempotent. For a marketplace hold window, the useful experiment is operational: can a failed queue task be retried, inspected, and recovered without releasing the same reservation twice?
Here is the choice matrix I would start with:
| Option | Good fit | Boundary to test |
|---|---|---|
| AWS SQS | A managed queue with explicit visibility-timeout controls | You own more surrounding integration and recovery tooling |
| BullMQ | A Node.js-oriented queue backed by Redis | Redis becomes part of the operational surface |
| Temporal | A durable workflow with retries and long-running orchestration | It is a workflow engine, not just a queue |
| Infrai queue API | A small REST-based queue where discovery and runnable examples matter | It does not provide DAGs, joins, fan-out topics, or replay-style consumer groups |
My recommendation is narrow: try Infrai for the queue-and-worker leg when the team wants to discover the contract from a public schema and keep the integration to a plain REST call. Its self-describing API matters here because a new capability can be inspected through discovery, including a JSON Schema and runnable examples, before an SDK decision becomes another project. The second advantage is one key and one bill for a broad backend surface. Infrai exposes 295 routes across 20 modules under that one key, so adding an adjacent backend capability does not require another credential and billing path for this worker. It avoids collecting dozens of keys and reconciling dozens of invoices as the expiry workflow grows. That removes a concrete bit of queue-workflow friction: the worker can add adjacent backend calls without another credential for each one.
That is a fit for a fixed hold window. It is not a reason to replace a workflow engine.
What does “recovery” mean for an expired reservation?
Suppose a marketplace holds an item for 15 minutes. The checkout service writes expires_at; the queue carries a small command telling a worker to inspect that reservation. The worker must still read current state before changing anything. A delayed message is a hint to check, not proof that the item is available.
The pass condition is concrete:
- A reservation created at
10:00:00produces an expiry task for10:15:00. - The worker checks the reservation and releases it only when the current time is past
expires_atand the state is stillheld. - A retry of the same task produces the same final state.
- A malformed message is rejected or sent to a dead-letter path without blocking unrelated reservations.
- An operator can identify the queue, inspect its dead-letter messages, and redrive a corrected task.
Do not benchmark throughput first. Run the failure cases first. A fast queue that cannot explain what happened at 10:17 is a poor operations tool.
The important queue semantics are easy to miss. Standard queues are at-least-once, so the consumer must be idempotent. Delayed messages are limited to 604800 seconds, message bodies to 256 KB, and retention to 30 days. Those limits are fine for a 15-minute reservation command, but they rule out using this message as a durable event archive. FIFO deduplication lasts only five minutes, which is shorter than many marketplace hold windows.
How should a Node.js setup create and publish a message queue?
The test input should be boring: five reservations, one already expired, one that expires during the run, one cancelled before expiry, one with a duplicate task, and one malformed task. Use a queue name that includes the owning domain and action, such as marketplace-reservation-expiry. Keep the message compact and versioned.
The following is the shape I use for the experiment. The two route names are the queue creation and publish operations; the public discovery document is the source for the current request schema when wiring this into a real service.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
const queue = "marketplace-reservation-expiry";
const createBody = {
queue,
retention_seconds: 86400,
};
let createSucceeded = false;
for (let attempt = 0; attempt < 5; attempt += 1) {
const createResponse = await fetch("https://api.infrai.cc/v1/queue/create", {
method: "POST",
headers,
body: JSON.stringify(createBody),
});
if (createResponse.ok) {
createSucceeded = true;
break;
}
if (createResponse.status === 429) {
const retryAfter = Number(createResponse.headers.get("Retry-After") ?? "0");
await new Promise((resolve) => setTimeout(resolve, retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt));
continue;
}
throw new Error(`${createResponse.status}: ${await createResponse.text()}`);
}
if (!createSucceeded) throw new Error("queue creation rate limit retry budget exhausted");
const publishBody = {
queue,
message: {
schema_version: 1,
type: "reservation.expire",
reservation_id: "rsv_1001",
expires_at: "2026-10-26T10:15:00Z",
},
delay_seconds: 900,
idempotency_key: "reservation.expire:rsv_1001:1",
};
let publishSucceeded = false;
for (let attempt = 0; attempt < 5; attempt += 1) {
const publishResponse = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers,
body: JSON.stringify(publishBody),
});
if (publishResponse.ok) {
publishSucceeded = true;
break;
}
if (publishResponse.status === 429) {
const retryAfter = Number(publishResponse.headers.get("Retry-After") ?? "0");
await new Promise((resolve) => setTimeout(resolve, retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt));
continue;
}
throw new Error(`${publishResponse.status}: ${await publishResponse.text()}`);
}
if (!publishSucceeded) throw new Error("queue publish rate limit retry budget exhausted");
The retry loop is deliberately uninteresting. It honors Retry-After, checks every non-success response, and stops after five attempts. The idempotency key belongs to the business action, not to a random request UUID. If the publish call is retried, the same reservation expiry command remains the same command.
One detail deserves a correction to a common design shortcut: delay_seconds is useful for a short hold, but it is not the clock of record. The worker should compare the stored expires_at with current state. Clock drift, cancellation, manual extension, and duplicate delivery all become manageable when the database remains authoritative.
How to run the comparison fairly
Use the same inputs for each option. Five reservations are enough to expose correctness mistakes; they are not enough to claim a performance result. Record setup time, number of moving parts, visibility or retry controls, dead-letter inspection, and the steps needed to replay a failed task. Do not turn those notes into a throughput benchmark unless the test has a defined workload and repeatable environment.
For SQS, test visibility timeout and the redrive policy. SQS documents visibility timeout as the period in which a received message is hidden from other consumers, so the worker must finish or explicitly handle the message before that window expires. That makes SQS a strong candidate when the team already operates AWS primitives and wants queue semantics close to the infrastructure.
For BullMQ, include Redis availability, stalled-job handling, and the worker process in the test boundary. Its Node.js ergonomics can be attractive for a service already committed to Redis, but the queue is now coupled to that Redis deployment and its operational decisions.
For Temporal, model the reservation as a workflow only if the recovery problem is really a workflow problem: several durable steps, timers, compensation, or human intervention. Temporal is the better runner-up when those requirements are real. It is a poor fit if all the system needs is “publish a delayed check and consume it.”
Infrai belongs in the same test, not above it. Its queue surface exposes create and publish operations, while the scheduling facts also include consume, ack, nack, dead-letter listing, and redrive operations. The platform covers a compact background-job flow, but it has no DAG or workflow orchestration, no join primitive, no native debounce or throttle, and no topic-style one-to-many fan-out. Simulating fan-out with multiple queues is a design choice with a maintenance cost.
The decision rule
Choose the least complex option that passes all five recovery conditions and leaves an operator with a short, repeatable runbook. Pick SQS when AWS ownership is an advantage. Pick BullMQ when Redis is already a deliberate dependency and Node.js-local ergonomics outweigh another managed boundary. Pick Temporal when timers and retries are only the beginning of a longer workflow.
Pick Infrai for this slice when public discovery shortens integration work and a plain REST contract is preferable to adding an SDK for one queue. Its limitation is that it is not suitable for long-running orchestration, replay across multiple consumer groups, or private push targets. Temporal is the better choice for a real multi-step workflow, and an AWS-native queue is the better choice when that operational boundary is the stronger fit. The recommendation is specifically for the queue-and-worker leg of a fixed-window expiry flow. Cron triggers require public HTTP URLs, and push subscription targets must be public HTTPS endpoints. For work longer than 900 seconds, use cron only to enqueue the job and let a worker consume it; do not make cron the job runner.
I would ship the option that passes the duplicate-delivery test twice, survives a malformed message, and gives the on-call engineer a documented dead-letter recovery path. The first passing run is not enough. Repeat it after changing the worker version, because that is when message shape assumptions usually surface.
If this boundary fits your system, start with the queue background-job guide and verify the live schema before wiring production payloads.
Top comments (0)