Background jobs in Node: create a queue, run a worker, ack and nack

A working background job queue over plain HTTP — queue creation, a polling worker, what ack and nack really return, poison-message handling and idempotency.

A background job queue needs four verbs and one rule. The verbs are create, publish, consume and ack; the rule is that a job you don’t ack comes back. Infrai exposes exactly that over HTTP — no broker to run, no client library, four POST calls and a loop — and this walks through a Node 22 worker built on it, including the two behaviours that surprised us when we tested the surface on 2026-07-26.

If you’ve used RabbitMQ, the mental model transfers almost unchanged; there’s a translation table further down.

Create the queue, or don’t

A publish to a name that doesn’t exist creates the queue with defaults, which is handy for a prototype and wrong for anything you care about, because you never get to name the dead-letter queue. Create it explicitly instead:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"background-jobs","type":"standard","dlq":"background-jobs-dead"}'

One trap: dlq wants a queue name, not a flag. Passing "dlq": true came back as a 501 in our testing, while a string works. The response also warns you that it read your dlq field as dead_letter_queue internally — both spellings are accepted, and the same courtesy applies to body on publish and receipt_handle on ack.

Ask for the queue back and you can see every default you didn’t set:

{
  "ok": true,
  "data": {
    "name": "background-jobs",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "delivery_delay_seconds": 0,
    "enable_priority": false,
    "max_receive_count": 3,
    "dlq_name": "background-jobs-dead"
  }
}

Retention is two weeks. A message caps out at 256 KB, so jobs referencing a stored object beat jobs carrying one.

Publish a job

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"background-jobs","body":{"job":"thumbnail","asset_id":"as_7781","width":640}}'

Publish is the only metered call in the whole namespace. Everything else — create, consume, ack, nack, stats, purge, DLQ reads — is free and rate-limited.

The worker

Consume hands you up to ten messages and makes them invisible to other consumers for the visibility timeout. Do the work, then decide each message’s fate individually.

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
const QUEUE = "background-jobs";

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

class Unprocessable extends Error {}

async function runJob(job) {
  if (job.job !== "thumbnail") throw new Unprocessable(`unknown job type ${job.job}`);
  if (!Number.isInteger(job.width)) throw new Unprocessable("width must be an integer");
  console.log(`resizing ${job.asset_id} to ${job.width}px`);
}

let running = true;
process.on("SIGTERM", () => { running = false; });

while (running) {
  const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  if (items.length === 0) { await sleep(1500); continue; }

  for (const msg of items) {
    try {
      await runJob(msg.payload);
      const { acked } = await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
      if (!acked) console.warn(`ack refused for ${msg.message_id} — lease had already expired`);
    } catch (err) {
      if (err instanceof Unprocessable) {
        console.error(`dropping ${msg.message_id}: ${err.message}`);
        await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
      } else {
        console.warn(`retrying ${msg.message_id} (delivery ${msg.delivery_count}): ${err.message}`);
        await call("/v1/queue/nack", { queue: QUEUE, message_id: msg.message_id });
      }
    }
  }
}

Two details in there earn their keep. First, Unprocessable separates “this job is garbage” from “this job failed” — a malformed payload gets acked and logged, because redelivering it three times just fills the dead-letter queue with noise. Second, the ack response carries an acked boolean and you should read it: acking a handle whose lease already lapsed returns HTTP 200 with {"acked": false} rather than an error, so a worker that ignores the body will believe it finished work that’s about to be redelivered.

That’s the single sharpest edge on this API.

ack and nack take different keys

queue.ack is documented with receipt_handle, queue.nack wants message_id, and the value you pass to both is the message_id you got back from consume. The server normalises the first for you and says so in a warning.

curl -sS -X POST "https://api.infrai.cc/v1/queue/ack" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"background-jobs","receipt_handle":"qmsg_PdBdedHTNrlbrJpiwXlbFxde"}'
curl -sS -X POST "https://api.infrai.cc/v1/queue/nack" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"background-jobs","message_id":"qmsg_PdBdedHTNrlbrJpiwXlbFxde"}'

An id that isn’t in flight answers {"nacked": false} — again a 200, again something to check rather than assume. If the id is unknown entirely you’ll see QUEUE_MESSAGE_NOT_FOUND.

RabbitMQ vocabulary, translated

RabbitMQHereNotes
assertQueuePOST /v1/queue/createIdempotent; a second call with different settings won’t silently reconfigure
sendToQueuePOST /v1/queue/publishJSON payload, 256 KB ceiling
consume with noAck: falsePOST /v1/queue/consumePull, not push — you poll, there’s no long-poll
channel.ackPOST /v1/queue/ackReturns {acked: bool}; read it
channel.nack(msg, false, true)POST /v1/queue/nackRequeues immediately
Dead letter exchangedlq on createA plain queue you can consume by name
Prefetch countmax_messages on consumeCapped at 10 per call

The gap that matters: RabbitMQ’s publisher confirms give you a broker-side guarantee before the publisher moves on, and there’s no equivalent handshake here beyond the HTTP 200 on publish. For most job queues that’s fine. If you need broker-level confirm semantics, stick with RabbitMQ.

Poison messages and idempotency

After three deliveries an unacked message moves to background-jobs-dead by itself. We measured the attempts landing roughly six seconds apart, and the max_receive_count field on the queue didn’t change that count when we altered it — treat three as fixed for now.

Because delivery is at-least-once, every handler needs to tolerate seeing the same job twice. The cheapest version is a unique key on a table keyed by something in the payload — asset_id plus width for the thumbnail job — checked before the side effect. BullMQ documents the same discipline in its idempotent jobs pattern, and the reasoning is identical whichever queue you’re on.

Confirm it’s draining

curl -sS "https://api.infrai.cc/v1/queue/stats/background-jobs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

You get message_count, available_count, in_flight_count, delayed_count, dlq_count and oldest_message_age_seconds. A healthy worker keeps available_count near zero; oldest_message_age_seconds is the number to alert on.

Publish costs $0.00002 per message, verified 2026-07-26, and a new account carries $2 of free credit — call it 99,999 jobs before you pay anything. Read the current figure rather than trusting this line:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id == "queue.publish") | .billing'

Rates drift downward and discount windows happen, so what you see may be lower.

When to pick something else

RabbitMQ or BullMQ give you priorities, delayed jobs with real backoff strategies, a management UI and publisher confirms, in exchange for running Redis or a broker. If your job graph has fan-in, per-job priorities or long chains of dependent steps, that’s the better trade. This surface is deliberately small: no priority (the flag exists but stays false), no long-polling, ten messages per consume, three attempts fixed.

The argument for the HTTP version isn’t that it does more. It’s that the same key already reaches the email you send when the job finishes, the object store the thumbnail lands in, and the usage query that attributes both to a tenant — so the next step in the job never needs a new vendor.

References

Browse more queue developer guides