DEV Community

felixhoffmann556
felixhoffmann556

Posted on

How to Consume a Healthtech Job from a Node.js Message Queue — Recovery

Short answer: consume the job in a Node.js worker, make its business key idempotent, acknowledge only after delivery succeeds, and redeliver transient failures.

A renewal reminder has a business deadline, not merely a timestamp. If the clinic administrator is offline when that deadline arrives, the system needs a durable job and a delivery path that can recover later. My default design is a queue consumer with an idempotency key, explicit acknowledgement, and bounded redelivery. The consumer decides when the job is complete; realtime delivery is only the last hop.

Before: the scheduler fires a socket event and hopes someone is connected. After: a deadline-triggered job sits in a queue, the worker records a stable processing key, and the delivery attempt can retry without creating a second reminder.

Keep it boring.

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

Treat the reminder as a state transition: scheduled becomes ready, processing, and then delivered or retryable. A live socket is useful for a connected browser, but it is not a durable inbox. The queue is the durable boundary.

For a standard queue, assume at-least-once delivery. A worker can see the same message again. It must make the business operation idempotent before acknowledging the message. The key should come from the renewal record and deadline, not from the delivery attempt.

Acknowledge only after the reminder has been accepted by the downstream delivery step. On a transient error, negatively acknowledge with redelivery. On a permanent validation failure, use a dead-letter path rather than retrying forever.

A small TypeScript worker with recovery decisions

This worker uses consume, acknowledge, and negative acknowledgement. It keeps the payload small, uses a stable key, and honors Retry-After when rate-limited. Delayed messages can be at most 604800 seconds, retention can be at most 30 days, and a standard queue still requires consumer-side idempotency.

import crypto from "node:crypto";

const baseUrl = process.env.BACKEND_BASE_URL;
if (!baseUrl) throw new Error("BACKEND_BASE_URL is required");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const queue = "health-renewal-reminders";
const seen = new Set<string>();

async function request(path: string, body: unknown): Promise<any> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(body)
    });

    if (response.status !== 429) {
      const text = await response.text();
      if (!response.ok) throw new Error(`${path} ${response.status}: ${text}`);
      return text ? JSON.parse(text) : undefined;
    }

    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) =>
      setTimeout(resolve, Math.max(1, retryAfter) * 1000 * (attempt + 1))
    );
  }
  throw new Error(`${path} stayed rate-limited after retries`);
}

async function deliverReminder(job: {
  renewalId: string;
  deadline: string;
  recipientId: string;
  text: string;
}): Promise<void> {
  const idempotencyKey = crypto
    .createHash("sha256")
    .update(`${job.renewalId}:${job.deadline}`)
    .digest("hex");

  if (seen.has(idempotencyKey)) return;

  await request("/v1/realtime/publish", {
    channel: `renewal:${job.recipientId}`,
    event: "renewal.reminder",
    data: { text: job.text, renewalId: job.renewalId },
    idempotency_key: idempotencyKey
  });
  seen.add(idempotencyKey);
}

async function worker(): Promise<void> {
  const result = await request("/v1/queue/consume", {
    queue,
    visibility_timeout_seconds: 60
  });

  for (const message of result.messages ?? []) {
    try {
      await deliverReminder(message.body);
      await request("/v1/queue/ack", {
        queue,
        receipt: message.receipt
      });
    } catch (error) {
      console.error("renewal reminder failed", error);
      // Leave the message unacknowledged so the queue can redeliver it.
      return;
    }
  }
}

void worker().catch((error) => {
  console.error("worker stopped", error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

The consumed message supplies renewal data to the delivery function, which publishes through the same base URL and bearer key. That single surface is the useful part of a broad backend platform such as Infrai: queue and realtime capabilities share one contract, so the delivery step does not introduce another client library or credential set. It does not remove the need for a durable idempotency record.

The Set is inadequate for multiple workers or a restart. Replace it with a unique database constraint on the derived key. If the process dies after publishing but before acknowledgement, the next delivery is harmless. If it dies before publishing, the message remains eligible for redelivery.

The long paragraph is intentional because the failure window deserves more attention than the happy path. Suppose the worker receives renewal 8841 at the deadline, publishes the event, and loses its network connection before the acknowledgement reaches the queue. A second worker can now receive an equivalent delivery. Without a durable claim keyed by renewal ID and deadline, the administrator sees two reminders. With that claim, the second attempt can detect that the side effect already exists and acknowledge the duplicate. The queue did its job: it preserved work across a process failure. The application still had to define what “already delivered” means.

Which queue approach fits the recovery boundary?

Option Strength Boundary to accept
BullMQ Node.js-focused workers with Redis-backed jobs and retry controls. You own Redis operations and worker connection behavior.
Amazon SQS Managed queues, visibility timeouts, and a dead-letter pattern. You assemble the realtime channel and manage AWS identity and policy.
Temporal Durable workflow execution with retries and long-running state. Its workflow surface is larger than a single queue consumer.
REST queue plus realtime One key and one HTTP contract can cover the handoff. You still own workers, idempotency, and permanent-failure policy.

The Pusher plus SQS alternative means two signups, two credential sets, and glue code to correlate a queue receipt with a socket channel, plus separate observability and failure policies. That can be right when each service is already a team standard. A combined surface reduces integration seams, but it also means one vendor to trust, one bill, and one outage surface.

Do not choose this pattern for workflow orchestration. The scheduling capability has no DAG or workflow-join primitive and no native debounce or throttle. A cron task is limited to 900 seconds and calls a public http_url; for longer work, let cron trigger queue production and let workers consume jobs. A delayed message tops out at seven days. Retention tops out at 30 days, and acknowledgement removes the message, so this is not Kafka-style replay with multiple consumer groups.

How do I decide between redelivery and a dead-letter path?

A timeout, rate limit, or temporary downstream outage deserves redelivery with backoff. A malformed renewal identifier, an expired business record, or a payload over 256 KB will not improve with repetition. Send those cases to a dead-letter workflow and alert an operator. Bound attempts, or one poison message can crowd out useful reminders.

A successful ack is a claim about side effects, not about receiving bytes. If publish returned an error, do not acknowledge. If publish succeeded and the process crashes later, idempotency makes repeated delivery safe.

A connected client can receive a realtime event, but an offline client needs a durable record and a later read path owned by the application. For a healthtech renewal, store reminder status beside the renewal record, and let the next client session reconcile it.

Start by enqueueing a compact job. Keep sensitive detail out of the message when an identifier will do. Use a deterministic idempotency key. Add structured logs containing renewal ID, queue name, receipt, attempt, and request ID. Alert on age, redelivery count, and dead-letter volume, not only process uptime.

Then test the awkward sequence: publish succeeds, acknowledgement fails. The expected result is one user-visible reminder after redelivery, not two. Test the inverse: the worker crashes before publish. The message should remain available. Finally, test a seven-day delay and a 30-day retention policy against the deadline; a longer deadline needs an application-level scheduler or a re-enqueue strategy.

Limitation: this is not suitable for DAG orchestration, Kafka-style replay, or a deadline longer than seven days. Choose Temporal for a workflow engine, SQS when AWS operations and dead-letter tooling are already standard, or BullMQ when Redis is the team boundary. For this healthtech reminder, use the queue for recovery, realtime for connected delivery, and make the worker idempotent before trusting either one. Infrai's practical advantage is one key and one bill for the queue-to-realtime handoff, plus one plain REST API without another SDK to install; the worker can use the same HTTP shape from any runtime.

Sources

Top comments (0)