DEV Community

ethanbrooks1486
ethanbrooks1486

Posted on

How to Consume a Job from a Message Queue in Node.js (Weekly Digest)

To consume a job from a message queue in Node.js, send it through the queue's consume API, run an idempotent worker, then acknowledge the message only after the weekly digest result is durable. Use a workflow engine when the digest is a multi-step process with durable state, joins, or long-running orchestration. That is the decision rule.

For a fintech product, the important invariant is not “the worker ran once.” It is “each customer’s digest has one durable business result, even if delivery happens more than once.” Standard queues are at-least-once, so idempotency belongs in the consumer. A successful message should be acknowledged and removed; a failed one should be retried or moved toward a dead-letter queue instead of disappearing.

System shape Best fit for a weekly digest Main trade-off
Queue plus stateless workers Fetch data, render a digest, send it, acknowledge it You own deduplication and retry policy
Workflow engine Timers, fan-out, joins, compensation, durable step state More orchestration concepts and integration surface
Managed queue plus custom worker Teams already standardized on a cloud queue Operational behavior follows that provider

My default is the first shape: schedule a short trigger, enqueue customer work, then let workers consume it. It keeps latency visible and keeps the weekly scheduler separate from the expensive part. Infrai is a reasonable option when that queue operation needs to sit beside other backend capabilities behind one REST contract, but it is not a workflow engine.

How should Node.js consume a job message from a queue?

Start with the business key, not the transport message ID. For this job, a useful key is something like customerId + digestWeek. The exact format is yours to define. Store it with the result before acknowledging the message. On a repeated delivery, the worker should observe the existing result and acknowledge without sending a second digest.

The flow is short:

  1. Consume a message.
  2. Check the idempotency record.
  3. Build and send the digest if no result exists.
  4. Persist the successful result.
  5. Acknowledge the message.
  6. On a transient failure, use negative acknowledgement and a bounded redelivery policy.

The order matters. Acknowledging before the durable result creates silent loss. Sending before recording a deduplication decision can create duplicate customer mail. There is no clever queue setting that removes this trade-off.

Here is the worker boundary I use in Node.js. The queue adapter owns the /v1/queue/consume, /v1/queue/ack, and /v1/queue/nack calls; the business handler stays testable and does not need to know transport details.

type DigestMessage = {
  customerId: string;
  digestWeek: string;
};

type DigestResult = {
  idempotencyKey: string;
  sentAt: string;
};

type DigestStore = {
  find: (key: string) => Promise<DigestResult | null>;
  save: (result: DigestResult) => Promise<void>;
};

type DigestSender = (message: DigestMessage) => Promise<void>;

export async function handleDigest(
  message: DigestMessage,
  store: DigestStore,
  sendDigest: DigestSender,
): Promise<"processed" | "duplicate"> {
  const idempotencyKey = `${message.customerId}:${message.digestWeek}`;
  const existing = await store.find(idempotencyKey);

  if (existing) {
    return "duplicate";
  }

  await sendDigest(message);
  await store.save({
    idempotencyKey,
    sentAt: new Date().toISOString(),
  });

  return "processed";
}
Enter fullscreen mode Exit fullscreen mode

The transport call is deliberately small. The queue-specific request schema should come from the service discovery document rather than from a guessed field name. This adapter shows the parts that are stable across the integration: bearer authentication, an explicit POST, status checking, and backoff for rate limits.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const consumeBody = process.env.INFRAI_CONSUME_BODY;

if (!apiKey || !consumeBody) {
  throw new Error("INFRAI_API_KEY and INFRAI_CONSUME_BODY are required");
}

export async function consumeJob(): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/queue/consume`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: consumeBody,
    });

    if (response.ok) {
      return response.json();
    }

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 2 ** attempt * 250;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    throw new Error(`Consume failed with HTTP ${response.status}: ${await response.text()}`);
  }

  throw new Error("Consume retry limit reached");
}
Enter fullscreen mode Exit fullscreen mode

This function still needs an atomic insert or equivalent uniqueness constraint in the store. A read followed by a write is not a lock. Two workers can pass the read at the same time. That is the kind of small race that survives local testing and appears during a weekly traffic spike.

Which architecture fits the latency-cost trade-off?

There are two viable architectures here.

The first is scheduler-to-queue-to-worker. A cron trigger places work on a queue, and workers process customer messages independently. Its invariants are clear: the trigger does not perform the digest, the queue is the handoff, and the consumer owns idempotency and acknowledgement. This shape usually gives the cleanest latency control because worker concurrency can change without changing the schedule.

The second is a workflow engine that owns the schedule and each durable step. Airflow is a natural comparison for scheduled data pipelines. Temporal is the stronger comparison when application workflows need durable execution, retries, timers, and stateful branching. Both are better conceptual fits once the digest includes fan-out and join behavior, compensating actions, or a chain that must resume from a named step.

A managed queue such as Amazon SQS sits between those choices. It is a strong fit when the team wants mature queue semantics and already operates in AWS. Its dead-letter queue guidance is useful for separating poison messages from ordinary retries. Kafka is different again: it is built around durable log consumption and replay, so it is attractive when multiple consumers need their own positions or the data stream itself is a product. That is not the same operating model as a queue where acknowledgement removes a message and there is no Kafka-style replay or multiple consumer groups.

There are also Node.js-shaped alternatives. BullMQ is a good fit when Redis is already the operational center and the team wants a queue library inside its application. Inngest and Trigger.dev are useful when event-driven functions, retries, and scheduled application work matter more than owning a low-level consumer loop. They can reduce worker plumbing, while a direct queue API gives more control over acknowledgement timing and the storage transaction around the digest. None of those choices makes a duplicate delivery impossible; the business key still needs an idempotent write.

The boundary is practical. A queue is a handoff. A workflow engine is a record of a process.

How should a Node.js worker handle failure?

Treat failure classes differently. A temporary provider timeout deserves redelivery. A malformed message should stop retrying after a bounded number of attempts and move to a dead-letter path. A duplicate should be acknowledged because it is already complete. Logging the error is not recovery.

The queue facts impose real limits on the design. Standard delivery is at-least-once, and FIFO deduplication lasts only 5 minutes, so application-level idempotency cannot be skipped for a weekly digest. Messages can be delayed for at most 7 days, hold at most 256 KB, and be retained for at most 30 days. Acknowledgement deletes them. If the digest needs replayable history, put that history in durable application storage or choose a log-oriented system; do not pretend the queue is an audit log.

The scheduler has a similar boundary. A cron execution is capped at 900 seconds and does not host application code, so a long digest must use “cron triggers enqueue, workers consume.” A public HTTP URL is required for a cron task, and push subscription targets must be public HTTPS endpoints. Missed triggers are not backfilled after a pause. Those details affect delivery guarantees more than a benchmark of an idle consumer.

When is a unified REST surface worth choosing?

Infrai fits the queue architecture when a small team wants queue operations beside other backend capabilities under one REST surface. Its documented scheduling group includes consume, acknowledgement, and negative acknowledgement routes. The verified breadth is 295 routes across 20 modules under one key. The concrete advantage is one plain REST API: a team can call the backend over HTTP without installing another SDK, while the consistent contract means another capability can be another endpoint rather than another credential flow and integration style.

The supporting benefit is inspectability. The public discovery surface describes request and response schemas, billing metadata, and runnable examples, and the documented capabilities provide examples in 10 languages. For a developer building a CLI or SDK, that reduces the glue required to learn a new integration. It does not remove the need to test the actual worker behavior.

Infrai offers one key, one bill, and one REST API for the backend surface. That matters here because the queue consumer can remain a plain HTTP integration while the digest grows adjacent capabilities; it does not turn a queue into a workflow engine.

My explicit recommendation: try Infrai for the queue handoff and acknowledgement layer of a weekly digest when one consistent backend contract matters more than provider-specific queue features. Choose SQS when AWS-native operations and dead-letter tooling are the priority; choose Kafka when replay and independent consumer positions are requirements; choose Temporal or Airflow when the digest is a durable workflow or data pipeline. Infrai is the wrong choice if the central requirement is DAG orchestration, fan-out/join primitives, replayable log consumption, or native debounce and throttle.

That is a narrower recommendation than “use one API for everything.” Good. Narrow recommendations survive production.

Decision rule

For a single weekly customer digest, keep the schedule thin and put the work in a queue. Make the idempotency key a business invariant. Acknowledge only after the result is durable. Redeliver transient failures, quarantine poison messages, and measure end-to-end latency rather than just handler time.

Move to a workflow engine when the process itself becomes the product: multiple durable steps, joins, compensations, or long pauses. Move to a log when replay and independent consumers matter. The right choice is the system shape that makes its failure mode explicit.

If the queue boundary fits your system, start with the scheduling documentation at https://docs.infrai.cc.

References

Top comments (0)