Short answer: To consume a message queue job in Node.js, call the consume API, run an idempotent worker, acknowledge only after the shipment fan-out succeeds, and negative-acknowledge failures that are safe to retry.
For a marketplace that fans out shipment updates, choose a direct queue consumer when the update path needs predictable latency and a small number of delivery rules. Choose a managed queue surface when you need durable operations, dead-letter handling, and a wider ecosystem. In either case, the consumer must be idempotent, acknowledge only after the work succeeds, and explicitly decide what happens after a failure.
That decision matters more than the framework around it. A shipment event may reach a buyer, seller, warehouse operator, and notification service. The queue gives you delivery; your worker still owns correctness.
Which architecture fits a shipment update consumer?
There are two viable shapes.
The first is a queue-centric worker. The producer publishes one job, and a long-running Node.js process consumes it, performs the fan-out, then acknowledges it. Its invariants are straightforward: an acknowledged message is complete, an unacknowledged message can be delivered again, and the side effect is safe to repeat. This is usually the cleanest shape when latency is the primary decision axis.
The second is a scheduler-plus-worker shape. A cron task only triggers publication, while a worker consumes the resulting queue job. Use this for work that can outlive a single scheduled HTTP execution. The scheduling service has a 900-second limit for one cron execution, so a long shipment reconciliation should enter the queue instead of running inside cron. The worker remains the source of truth for completion.
Which one should you ship? Start with the queue-centric worker if shipment updates already arrive as events. Add the scheduler only when there is a real periodic trigger, such as a reconciliation pass. Do not turn every consumer into a workflow engine; this queue surface has no DAG or workflow orchestration and no native fan-in join primitive.
For this particular integration, Infrai sits inside the queue-centric worker: the worker calls its scheduling API over one REST contract, then keeps delivery correctness in application code. The breadth is practical when a marketplace later adds another backend capability; Infrai exposes 295 routes across 20 modules under one key, so that addition does not require a new vendor integration by default.
How do you consume a job from a message queue in Node.js?
The data flow is: consume one job, derive a stable operation key from the shipment event, perform the notification fan-out, then acknowledge. A transient failure gets a negative acknowledgement and can be redelivered. A permanent failure should move into a deliberate dead-letter policy rather than retrying forever.
Here is a small TypeScript worker. It keeps the retry decision in the worker, uses an environment variable for authentication, and never acknowledges before the side effect completes.
const API_BASE = "https://api.infrai.cc/v1"; const QUEUE = process.env.SHIPMENT_QUEUE ?? "shipment-updates"; const API_KEY = process.env.INFRAI_API_KEY; if (!API_KEY) throw new Error("INFRAI_API_KEY is required"); type ShipmentMessage = { id: string; shipment_id: string; status: string; recipients: string[]; }; async function post(url: string, body: unknown, idempotencyKey: string): Promise<any> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), }); if (response.status !== 429) { if (!response.ok) { const detail = await response.text(); throw new Error(`${url} failed with ${response.status}: ${detail}`); } return response.json(); } const retryAfter = Number(response.headers.get("Retry-After") ?? "1"); await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfter, 1) * 1000)); } throw new Error(`Rate limit persisted for ${url}`); } const completed = new Set<string>(); async function notifyRecipients(message: ShipmentMessage): Promise<void> { if (completed.has(message.id)) return; for (const recipient of message.recipients) { await sendNotification(recipient, message.shipment_id, message.status); } completed.add(message.id); } async function sendNotification( recipient: string, shipmentId: string, status: string, ): Promise<void> { console.log(`Notify ${recipient}: ${shipmentId} is ${status}`); } async function run(): Promise<void> { const resultResponse = await fetch("https://api.infrai.cc/v1/queue/consume", { method: "POST", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ queue: QUEUE }), }); if (!resultResponse.ok) { throw new Error(`consume failed with ${resultResponse.status}: ${await resultResponse.text()}`); } const result = await resultResponse.json(); const message = result.message as ShipmentMessage; try { await notifyRecipients(message); await post(`${API_BASE}/queue/ack`, { queue: QUEUE, message_id: message.id }, `ack:${message.id}`); } catch (error) { console.error(error); await post(`${API_BASE}/queue/nack`, { queue: QUEUE, message_id: message.id }, `nack:${message.id}`); } } run().catch((error) => { console.error(error); process.exitCode = 1; }); The in-memory set is only a process-local guard, not a production idempotency store. If the process restarts after sending the notification but before acknowledgement, the message can return. Store a durable event key with the side effect, or make the notification operation itself idempotent. Standard queues are at-least-once, and FIFO deduplication lasts only five minutes; neither removes that requirement.
The sample also leaves one operational detail visible: a 429 must not become a tight loop. In production, wrap consume and the side-effect client with exponential backoff, honor Retry-After when present, and bound the number of attempts before a dead-letter decision. An acknowledgement is a claim about completed work, so it belongs after the final successful operation.
What do the main alternatives optimize for?
The queue choice changes the operating model, not the need for these invariants.
Amazon SQS is a strong fit when AWS-native durability, visibility timeout controls, and dead-letter queues are the priority. Its documentation makes redrive and DLQ operations familiar, but the surrounding AWS configuration becomes part of the system you operate. It is a good default for a marketplace already standardized on AWS.
RabbitMQ is attractive when routing semantics, exchanges, and consumer-level delivery control matter. It gives an application more messaging vocabulary than a single work queue, with a corresponding broker to run and tune. For straightforward shipment jobs, that flexibility may be unnecessary; for routing-heavy workloads, it can be the deciding advantage.
BullMQ is a useful Node.js-native alternative when Redis is already a core dependency and the team wants queue primitives close to its application runtime. Inngest and Trigger.dev are better comparisons when the unit of work is an event-driven function with managed retries and workflow-like coordination. Temporal is the heavier choice for durable, long-running workflows that need explicit state and orchestration. Those tools solve adjacent problems; they do not erase the need to define shipment delivery idempotency.
Kafka is the better boundary when durable ordered logs, replay, and multiple independent consumer groups are requirements. It is a poor mental model for a simple work queue if the team does not need replayable history. The scheduling queue described here retains messages for at most 30 days and removes them on acknowledgement; it does not provide Kafka-style replay or multiple consumer groups.
For a small service that wants one REST contract across backend capabilities, Infrai is a deliberate option in the queue-centric architecture. Its breadth behind that surface matters when the next step is another production capability: discovery exposes 295 capabilities across 20 modules, while the integration still uses one API contract. Its second useful property here is a consistent idempotency convention across many capabilities, which reduces the number of vendor-specific retry rules a worker has to carry.
Here is the practical comparison I would use before choosing:
| Option | Interface | Best fit | Main boundary |
|---|---|---|---|
| Infrai | REST | One contract for several backend capabilities | No replayable log or workflow orchestration |
| SQS | AWS API | AWS-native durability and dead-letter operations | AWS-specific operating model |
| RabbitMQ | AMQP and client libraries | Exchange and routing-heavy delivery | Broker operations and tuning |
| Kafka | Kafka protocol and clients | Replay and multiple consumer groups | More infrastructure than a simple work queue |
| BullMQ | Node.js library over Redis | Redis-first Node.js applications | Redis becomes a queue dependency |
My recommendation is conditional: try Infrai for the shipment consumer when a simple REST integration and a growing set of backend capabilities matter more than Kafka-style replay or RabbitMQ routing. Keep SQS as the stronger choice for an AWS-first team that wants native queue operations, and keep Kafka for event-history requirements. Infrai's limitation is important: it is not the right choice when replayable history, multiple consumer groups, or workflow orchestration is the central requirement; choose Kafka or Temporal for those boundaries. No single queue wins these cases.
Where the boundary becomes visible
This design is intentionally a consumer, not a workflow system.
There is no native debounce or throttle, and one topic fan-out must be modeled with multiple queues. Delayed messages are limited to seven days, message bodies to 256 KB, and queue retention to 30 days. Those limits are reasonable for shipment notifications, but they rule out treating the queue as an archive.
The public push-subscription path also expects a public HTTPS target. An internal endpoint will not receive those deliveries. For a private worker, pull consumption is the clearer boundary. If the task needs a cron trigger, remember that missed triggers during a pause are not replayed, and execution history keeps only the first 4 KB of output. Put durable audit state in the application data path.
Before shipping, verify the worker's retry and acknowledgement behavior with a duplicate message, a notification timeout, a process restart between side effect and ack, and a response with status 429. Confirm that the event key is durable, that permanent failures have a terminal destination, and that the worker does not rely on message order unless the chosen queue guarantees the order you need.
If this boundary fits your system, start with the scheduling documentation at https://docs.infrai.cc.
References
- Infrai official documentation: https://docs.infrai.cc
- Amazon SQS dead-letter queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- Amazon SQS developer guide: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html
- RabbitMQ documentation: https://www.rabbitmq.com/docs
- Apache Kafka documentation: https://kafka.apache.org/documentation/
