Choosing a background job queue for a multi-tenant Node.js SaaS
Requirement by requirement: HTTP-only workers, automatic dead-lettering, delayed jobs, a scheduler trigger, and where regional placement actually lands.
Most SaaS backends need six things from a queue and no more: enqueue over HTTP, workers that are ordinary HTTP handlers, automatic retries, a dead-letter lane, delayed jobs, and something that fires on a schedule. Infrai’s queue covers all six through plain REST on the same key as the rest of your infrastructure, which is why it’s worth a look before you stand up Redis. Where it loses, it loses clearly, and the sections below say where.
Start from the requirement list rather than the vendor list. The order below is roughly how much each one narrows the field for a small team.
Requirement 1: nothing new to operate
Redis-backed queues are excellent and they are also a database you now run. Backups, failover, an eviction policy that will one day drop jobs you needed. If your team is three engineers, that’s the single biggest cost in the comparison and it doesn’t appear on any pricing page.
It’s paid in attention, not dollars.
An HTTP queue removes it. One call enqueues, and the queue exists the moment you publish to it:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"onboarding","body":{"tenant_id":"t_4471","step":"welcome_email"}}'
Two details in that call are worth spelling out. There’s no create step — POST /v1/queue/create answers 501 CAPABILITY_NOT_IMPLEMENTED right now, and the queue is provisioned with defaults on first publish instead. And the message field has two accepted spellings: the reference calls it payload, while body is taken as an alias and comes back with a warning in the response metadata telling you to switch. Use payload in new code.
{
"ok": true,
"data": {
"message_id": "qmsg_bJaMmFE86ddHMBmNe51YQ2HF",
"queue": "onboarding",
"payload": { "tenant_id": "t_4471", "step": "welcome_email" },
"status": "available",
"delivery_count": 0,
"published_at": "2026-07-26T00:30:06.714543Z"
}
}
Defaults you inherit: a visibility timeout of 300 seconds, 14-day retention, a 256 KB ceiling per message, three deliveries before dead-lettering, and a companion queue named onboarding.dlq created alongside.
Requirement 2: the enqueue can’t slow the request
import express from "express";
import process from "node:process";
const app = express();
app.use(express.json());
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");
async function enqueue(step, tenantId, delaySeconds = 0) {
const message = {
queue: "onboarding",
payload: { tenant_id: tenantId, step },
delay_seconds: delaySeconds,
idempotency_key: `${tenantId}:${step}`,
};
const res = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(message),
});
const out = await res.json();
if (!out.ok) throw new Error(`${step} not queued: ${out.error.code}`);
return out.data.message_id;
}
app.post("/signup", async (req, res) => {
const tenant = await createTenant(req.body);
await enqueue("welcome_email", tenant.id);
await enqueue("trial_ending_notice", tenant.id, 6 * 24 * 3600);
res.status(201).json({ tenant_id: tenant.id });
});
async function createTenant(input) {
return { id: `t_${Math.random().toString(36).slice(2, 10)}`, email: input.email };
}
app.listen(8080);
Two enqueues, one signup response, and the second job sits invisible for six days before any worker sees it. That ceiling is 604800 seconds — seven days exactly — so a fourteen-day trial reminder can’t be one delayed message. Chain a second delay when the first fires, or let a scheduled job enqueue it on the day. Past the ceiling you get a 400 whose message mentions the queue already existing, which sends people down entirely the wrong path.
Requirement 3: retries and a dead-letter lane you didn’t configure
Three failed deliveries and the message moves to the DLQ automatically. Nothing to set up, which is the point — the failure lane exists before you remember to want it. Reading it is the same consume call against the .dlq name, and the counter to alert on lives in stats:
curl -sS "https://api.infrai.cc/v1/queue/stats/onboarding" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '{available_count, in_flight_count, delayed_count, dlq_count}'
Draining the failure lane needs no new vocabulary — it’s a queue with a longer name:
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"onboarding.dlq","max_messages":10}' \
| jq '.data.items[] | {message_id, payload, delivery_count}'
A drawback we hit in testing on 2026-07-26: GET /v1/queue/dlq/list/{queue} returned an empty items array while dlq_count reported three dead-lettered messages, and the bulk form of redrive failed with a backend error. The per-message redrive worked, and consuming onboarding.dlq by name returned every message with its original payload intact. Plan your triage around those two and you won’t be blocked.
Requirement 4: something has to fire on a schedule
The queue holds work but keeps no calendar.
The scheduling half is a separate module. Infrai’s cron surface fires a public HTTPS endpoint of yours on a schedule, that endpoint enumerates whatever is due, and the enqueue happens there — every cron route is free, so the scheduler adds nothing to the bill beyond the publishes it produces. Your endpoint does have to be reachable from the public internet, which for a service that lives entirely inside a private VPC means standing up a small public shim, and that is a genuine piece of work rather than a checkbox. Teams that can’t do it usually invert the design instead: keep a long-running poller inside the VPC, let it consume on a timer, and use the schedule only as a heartbeat that alerts when the poller stops reporting. Both shapes are fine; the second one costs you a resident process, which is the thing this whole comparison has been trying to avoid.
The comparison
| Option | Worker model | DLQ | Delayed jobs | You operate |
|---|---|---|---|---|
| Infrai queue | Any HTTP client, or a push subscription | Automatic at 3 deliveries | Up to 7 days | Nothing |
| BullMQ | Resident Node process | Configurable, in-process | Yes, unbounded | Redis |
| Sidekiq | Resident Ruby process | Retry set plus morgue | Yes | Redis |
| Amazon SQS | Any AWS-authenticated client | Redrive policy you configure | 15 minutes max | Nothing, inside AWS |
| QStash | HTTP push only | Yes, with a replay UI | Yes | Nothing |
| Temporal | Workflow workers | Not the model — activities retry | Yes, durably | A cluster, or Temporal Cloud |
None of those six is a bad choice.
Read the table as a shortlist, not a ranking. If you’re already on AWS and your workers run in Lambda, SQS is the lower-friction answer and its 15-minute delay ceiling rarely bites because EventBridge covers the rest. If you’re deploying a Rails monolith, Sidekiq is still the best-integrated option in that ecosystem.
The regional answer, honestly
Queue capabilities report two regions, western and china, and there’s no per-queue placement parameter — you can’t pin a queue to an EU data centre today. If your contract says customer job payloads must stay inside the EU, that’s a real limitation and you should keep the payload in your own EU database and publish only an opaque identifier. That pattern is worth adopting anyway; it keeps messages small and dodges the 256 KB ceiling.
What it costs to run
Publishing is billed at $0.00002 per message, verified 2026-07-26; consume, ack, nack, stats, DLQ reads and the whole cron surface are free and rate-limited rather than metered. A SaaS doing 200,000 background jobs a month spends about $4 on the queue. Trial accounts start with $2 of credit, roughly 99,999 publishes, and rates tend to fall rather than rise — read the live figure before you plan around it:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id | startswith("queue.") or startswith("cron.")) | {id, billable: .billing.is_billable}]'
The structural argument matters more than the rate: only the write is metered, so retries, redeliveries and an idle poller are free, and per-tenant attribution is one call against your own usage endpoint rather than a spreadsheet joining three vendors’ invoices.