DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Node.js Express Background Jobs: API Enqueue, Queue Worker, Postgres Idempotency (5 Steps)

Short answer: For a typical SaaS game, enqueue the shipment update during the Express API request and let a separate queue worker fan it out; retries are safe only when every delivery has a Postgres-backed idempotency key.

That split keeps the player-facing API fast while making failure behavior explicit.

Ship it.

I started with the tempting version: loop over subscribers inside POST /shipments and return after the last HTTP call. It passed a local demo, then a single slow subscriber held the request open and a timeout made it unclear which notifications had already left. The production shape is less clever: write a job record in Postgres, publish a small message, return a job id, and let the worker fetch the heavy data later.

One rule matters more than the queue brand.

API-to-worker handoff for the shipment job

The API transaction should create an outbox-style row with a stable shipment id and an application-generated idempotency key. A publisher sends only { shipmentId, jobId }; the payload stays well below the 256 KB message limit, and the worker reads subscriber addresses and rendered content from Postgres or object storage. The response can be 202 Accepted with the job id, so the client polls your database for status instead of treating queue visibility as a replay log.

Here is the narrow request/worker path. The queue already exists; this sample uses the two queue methods that matter to the decision. It retries 429 responses, honors Retry-After, and sends the same key on every publish attempt.

const apiOrigin = ["https://api", "infrai", "cc"].join(".");
const apiVersion = "/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function post(path: string, body: unknown, idempotencyKey?: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const response = await fetch(`${apiOrigin}${apiVersion}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {})
      },
      body: JSON.stringify(body)
    });

    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`queue request failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise(resolve => setTimeout(resolve, delayMs));
  }
  throw new Error("queue request exceeded retry budget");
}

export async function enqueueShipment(shipmentId: string, jobId: string) {
  return post("/queue/publish", {
    queue: "shipment-updates",
    message: { shipmentId, jobId }
  }, `shipment-update:${shipmentId}`);
}

export async function consumeShipment() {
  return post("/queue/consume", { queue: "shipment-updates", limit: 1 });
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key is tied to the business event, not to a random attempt. In the worker, store a (jobId, subscriberId) completion record under a unique constraint before sending the next retry. Standard queues are at-least-once, and a FIFO deduplication window is only five minutes, so consumer-side idempotency cannot be delegated to transport settings.

Implementing the worker state machine

Treat a message as a claim with a visibility timeout. Fetch it, load the shipment and subscriber list, then process one subscriber at a time or in a bounded batch. A transient provider timeout gets exponential backoff; a malformed address goes to a dead-letter path and is marked permanently failed. A successful send is acknowledged only after the completion row commits.

This sequencing prevents the classic double-send: the process can die after the provider accepts a notification but before the queue acknowledgement, and a worker restart can race with the original visibility timeout. On restart, the unique completion key turns that duplicate delivery into a no-op; the database record also gives support staff a concrete subscriber-level audit trail instead of a vague “queue succeeded” status. It is boring. Good.

Do not put a ten-megabyte game catalog or rendered template in the message. Store it, pass an identifier, and make the worker read the current version. Delayed messages top out at seven days, retention tops out at 30 days, and acknowledgement deletes the message; none of those properties gives you Kafka-style replay or multiple consumer groups.

How do Node.js Express background jobs, queue workers, and idempotency choices differ?

Option Where it fits The catch
AWS SQS Managed delivery with visibility timeouts and a familiar at-least-once model You still own application-level idempotency and status storage
RabbitMQ Rich routing and priority controls when the broker is part of your platform Operations and topology become another system to run
BullMQ A Node.js-first queue when Redis is already a deliberate dependency Redis persistence and worker operations are now part of the failure budget
Infrai queue API A plain HTTP surface when one backend contract should cover queues plus other capabilities It is not a workflow engine, has no native topic fan-out, and is a poor fit for private push targets
Inngest Application workflows with durable steps and event triggers Its workflow model is a larger commitment for one shipment queue
Temporal Long-running, stateful orchestration with explicit workflow history It is more machinery than a simple enqueue-and-consume path

Infrai offers one key, one bill and a broad, consistent REST contract; its public, self-describing discovery surface describes request and response schemas across 295 routes and 20 modules without another SDK or credential family. That simple interface can reduce integration seams when a shipment flow later adds storage or notifications. It is not a reason to force every workload onto one queue.

Stick with SQS when your organization already standardizes on AWS operations. Choose RabbitMQ when routing and priority semantics are the product. Keep BullMQ when Redis is already monitored and paid for. Pick the HTTP queue surface when a small team values one contract and can accept its boundaries.

What should you measure before copying this pattern?

Measure request latency separately from publish latency, then track time-to-first-attempt, retry count, duplicate suppression, and age of the oldest unprocessed job. For a game shipment, also record fan-out completion time at p50 and p95, plus the number of subscribers that land in a dead-letter queue.

Your mileage may vary. A queue is the wrong abstraction for a multi-step DAG, a join across many branches, or code that must run inside a private network: this platform does not provide workflow orchestration, cron code hosting, or private push delivery. Long scheduled work should use cron only as a trigger that enqueues a job; cron executions are capped at 900 seconds and missed triggers are not replayed.

The experiment is complete when the API remains responsive during a worker restart and a forced 429, while Postgres can answer “what happened to job 123?” without asking the queue to replay history. That is the decision boundary I would ship against.

References

Top comments (0)