DEV Community

oskarholm4968
oskarholm4968

Posted on

Node.js Healthtech Queue Setup: Create and Publish Managed Tasks, Not Durable Logs

Short answer: use a managed message queue for a nightly healthtech payment reconciliation when each provider record is an independently retryable task and the audit store, rather than the queue, is the system of evidence. Choose a durable log when replay, several independent consumer groups, or a retained event history is a first-class requirement. In both cases, assume at-least-once delivery and make the reconciliation result idempotent.

The useful unit of work is one provider record, not one enormous nightly script. The trigger creates a run, publishes bounded tasks, and leaves the worker to compare, record, and acknowledge each task. That separation gives the failure model somewhere precise to live.

What does the nightly reconciliation bill actually retain?

Start with the bill before choosing a broker. Consider an illustrative run containing 180,000 provider records. Its operational footprint has three different terms: the provider response set, the queue messages retained until acknowledgement, and the audit records retained for investigation. The queue payload is not automatically the dominant term. The durable response and audit evidence usually matter more because they explain why a balance was matched, disputed, retried, or sent to a dead-letter path.

That observation changes the design. A message should carry a reconciliation run identifier, a stable provider-record identifier, and only the data needed to fetch or validate the record. The worker should persist a result keyed by (provider, provider_record_id, reconciliation_run_id), together with a controlled response digest or snapshot, before acknowledging the message. If the same delivery arrives again, the unique key makes it an observation of existing work instead of another financial mutation.

Keep it boring.

This is where retention becomes a deliberate trade-off. I would stop treating the queue as a permanent archive and stop keeping unbounded provider payloads in it. The cost is that an investigation later needs a reliable pointer to the source response or a redacted audit snapshot. Deleting the queue copy is reasonable only when that evidence path is durable, access-controlled, and discoverable by the run identifier.

A standard queue provides at-least-once delivery. FIFO deduplication, where available, has a five-minute window; it does not cover a reconciliation that runs for hours. Queue messages can be delayed for at most seven days, retained for at most 30 days, and are deleted on acknowledgement. A message body is limited to 256 KB. Those limits are not footnotes: they define what belongs in a task and what belongs in an audit store.

Which delivery guarantee fits the work?

There are four credible shapes here, and they solve different problems.

Amazon SQS is a strong reference point for independently retryable work. Its visibility timeout lets a worker process a message while temporarily hiding it, after which an unfinished message can be delivered again. That is a good fit for record-level reconciliation, provided the worker commits its result before deletion and chooses a visibility period that matches the longest expected attempt. It is not exactly-once execution of an external payment action.

Google Cloud Tasks is better suited to dispatching HTTP work to a target with retry and scheduling controls. That can be attractive when the worker boundary is already an HTTP service, but the target remains responsible for idempotency and for making a provider timeout distinguishable from a rejected request. A retried HTTP delivery is still a retried delivery.

RabbitMQ offers more explicit topology: exchanges, routing keys, queues, acknowledgements, and dead-lettering are useful when routing is part of the domain. The price of that control is operational ownership of broker persistence, topology, capacity, upgrades, and unacknowledged-message behavior. It is a reasonable choice when the reconciliation system must route different classes of records to different consumers.

Kafka is the better fit when the retained log is itself valuable: several consumer groups need the same history, partitions are a deliberate scaling boundary, and replay is a product requirement. It is a poor default for a one-pass nightly work queue whose source of truth is the reconciliation result store. A replayable log preserves events; it does not make a provider-side effect idempotent.

For a small platform team that wants queue creation and publication behind one plain REST convention, a service such as Infrai can be a practical managed option because one key and one bill cover backend capabilities instead of forcing the team to reconcile a separate credential and invoice for every service. That reduces platform surface area, but it does not add DAG orchestration, a join primitive, Kafka-style replay, or multiple consumer-group history to this queue.

Option Strong fit Boundary that must stay visible
Amazon SQS Independent work items with managed redelivery Consumer idempotency remains necessary; visibility is not a financial guarantee
Google Cloud Tasks HTTP-targeted background dispatch The target still receives retries and must make side effects safe
RabbitMQ Explicit routing and dead-letter topology The team owns more broker operations and topology discipline
Kafka Durable history, replay, partitions, and many consumer groups More infrastructure and design than a one-pass reconciliation needs
Managed REST queue A compact platform surface for bounded tasks Payload, retention, replay, and orchestration limits remain material

The decision rule is therefore asymmetric: choose the managed queue for independent tasks and an external audit store; choose RabbitMQ for routing topology; choose Kafka when replay and multiple consumer groups are requirements rather than future possibilities.

How should a nightly flow survive a duplicate?

The nightly trigger should create or identify a reconciliation run, enumerate provider records, and publish one task per record. It should not perform the entire job inside a cron invocation. A cron task has a 900-second maximum, so work beyond that boundary follows cron trigger -> queue -> worker. A cron task also requires a public HTTP URL, and a push subscription target must be public HTTPS; an internal-only endpoint will not receive pushed work. Missed triggers during a pause are not backfilled, so the run identity and source window need to be explicit.

Name queues around ownership and lifecycle, such as healthtech-payments-reconcile, rather than a temporary implementation label. Include a schema version and run identifier in each message. Keep the body below 256 KB, use delay only within seven days, and set retention no longer than 30 days. Do not mistake acknowledgement for archival: acknowledgement removes the queue message, and this queue shape has no Kafka-style replay or multi-consumer-group history.

The worker order is more important than the queue brand:

  1. Parse and validate the message before calling the provider.
  2. Claim the idempotency key in the reconciliation result store.
  3. If the key is already complete, record the duplicate observation and acknowledge it.
  4. Call the provider with its own idempotency behavior where supported.
  5. Commit the comparison result and audit event in one durable transaction, or in a sequence with an explicit recovery state.
  6. Acknowledge only after that commit succeeds.

The claim needs a lease or equivalent concurrency rule. A worker can disappear after a provider accepts a request but before the worker records the response. The next delivery must inspect durable state and resolve that ambiguity; treating the retry as new work is how at-least-once delivery becomes a duplicate payment operation.

That is the boundary.

A dead-letter queue is a decision queue, not an archive. A malformed payload, a provider outage, and a code defect require different actions. Redrive only after the idempotency path is known to be safe, and retain the message, reason, run identifier, and attempt history in the audit system.

How should a Node.js setup create and publish message queue tasks?

Yes, provided the API contract is kept separate from the reconciliation contract. The following Go program performs the two verified queue writes without inventing request fields: it reads JSON bodies that have been validated against the platform's current request schema, then sends them to the create and publish routes. The example uses an explicit method, a bearer token from the environment, an idempotency key, bounded retries for HTTP 429, and the response body on every failure.

Set QUEUE_CREATE_JSON to the current create request and QUEUE_PUBLISH_JSON to the current publish request. The program is intentionally strict about those inputs; silently guessing field names at a payment boundary is worse than failing before publication.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func post(path, body, idempotencyKey string) error {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return fmt.Errorf("INFRAI_BASE_URL is required")
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewBufferString(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return readErr
        }

        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return fmt.Errorf("queue request failed: status=%d body=%s", res.StatusCode, string(data))
        }
        return nil
    }
    return fmt.Errorf("queue request exceeded retry limit")
}

func main() {
    createBody := os.Getenv("QUEUE_CREATE_JSON")
    publishBody := os.Getenv("QUEUE_PUBLISH_JSON")
    if createBody == "" || publishBody == "" || os.Getenv("INFRAI_API_KEY") == "" {
        panic("INFRAI_API_KEY, QUEUE_CREATE_JSON, and QUEUE_PUBLISH_JSON are required")
    }

    runID := os.Getenv("RECONCILIATION_RUN_ID")
    if runID == "" {
        panic("RECONCILIATION_RUN_ID is required")
    }
    if err := post("/queue/create", createBody, "queue-create-"+runID); err != nil {
        panic(err)
    }
    if err := post("/queue/publish", publishBody, "queue-publish-"+runID); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The retry key must identify the same logical write, not the attempt number. If publication returns an uncertain network error, rerunning with the same reconciliation run identifier must not create a second queue or apply a second publication. That protects the handoff; the worker still needs a per-record idempotency key because standard queue delivery is at least once.

Inngest and Trigger.dev are sensible alternatives when the primary abstraction is application-level background functions with retries and schedules. Temporal is a better match for durable, stateful workflows whose steps and timers must survive process failures. BullMQ is a practical Node.js-oriented choice when a team already operates Redis and wants local queue primitives. These tools can be more expressive than a plain queue, but that expressiveness is not free: it brings a workflow runtime, a Redis dependency, or a larger operational boundary. None is automatically a better fit for a bounded reconciliation handoff.

What should the runbook refuse to promise?

It should refuse to promise exactly-once external effects, arbitrary workflow orchestration, or infinite retention. This queue shape has no native debounce or throttle, no topic that fans one message to many consumers, and no join primitive for DAG-style workflows. Multiple queues can model fan-out, but that is an application decision, not a hidden queue feature. Long tasks belong behind a cron-triggered enqueue operation, not inside a cron request that is allowed to run for only 900 seconds.

It should also make the boundary around push delivery operationally obvious: public HTTPS is required, and an internal worker endpoint is not a valid push target. A consumer must record enough evidence to explain a duplicate, a delayed delivery, a failed provider call, and a dead-letter decision. Four kilobytes of retained run output is not a substitute for an audit record.

For this healthtech workload, the managed queue wins because the work is bounded, independently retryable, and valuable only after the reconciliation result is durably recorded. A durable log wins when retained history and independent replay are the requirement. Neither choice removes the central obligation: make every provider-side mutation safe to repeat, then acknowledge only what the audit system can prove.

Further reading

Top comments (0)