BullMQ, SQS or a managed HTTP queue: what each one makes you responsible for
A three-way comparison for Node.js background jobs, scored on retries, dead-letter lanes, idempotency and what you have to operate. With the same worker written three ways.
Three good options, three different bills of responsibility. BullMQ hands you the richest retry semantics and asks you to run Redis. SQS hands you an unbreakable managed queue and asks you to live inside AWS. Infrai’s queue hands you plain HTTP with a dead-letter lane already attached and asks you to write your own backoff curve. Nothing here is about which one is fastest — at the volumes a Node SaaS actually pushes, all three are fast enough.
So the honest selection criterion is what you’re willing to own. Redis persistence, an IAM policy, or forty lines of retry logic.
What each one makes you responsible for
| Concern | BullMQ | SQS | Infrai queue |
|---|---|---|---|
| Infrastructure you operate | Redis (HA, memory, persistence) | None | None |
| Client library | bullmq npm package | @aws-sdk/client-sqs | None — fetch against REST |
| Retry curve | Declarative: attempts + backoff | Fixed redelivery on visibility timeout | Fixed 3 deliveries; curve is yours |
| Per-message delay | Yes, delay in ms | Yes, DelaySeconds up to 900 | Not in the documented body |
| Dead-letter lane | failed set, plus manual retry | Redrive policy you configure | Created with the queue |
| Ordering guarantee | Per-queue, best effort | FIFO queues available | Standard only in practice |
| Idempotency | Yours | Yours | Yours |
| Where the worker can run | Anywhere with Redis reachable | Anywhere with AWS creds | Anywhere that can make HTTPS requests |
That last row is less obvious than it looks. A Redis-backed worker needs a network path to Redis, which in practice means the same VPC or a tunnel — which is why “just run the worker on my laptop for an afternoon to drain a backlog” is easy with two of these three and awkward with the other.
The same retry, three ways
BullMQ’s version is the shortest, because the retry policy is configuration:
// bullmq-worker.mjs
import { Queue, Worker } from "bullmq";
import process from "node:process";
const connection = { host: process.env.REDIS_HOST ?? "127.0.0.1", port: 6379 };
const queue = new Queue("thumbnails", { connection });
await queue.add(
"resize",
{ assetId: "asset_4471", width: 512 },
{ attempts: 5, backoff: { type: "exponential", delay: 1000 }, removeOnComplete: 1000 },
);
const worker = new Worker(
"thumbnails",
async (job) => {
const res = await fetch(`https://render.internal/resize/${job.data.assetId}?w=${job.data.width}`);
if (!res.ok) throw new Error(`resize failed ${res.status}`);
return res.json();
},
{ connection, concurrency: 4 },
);
worker.on("failed", (job, err) => console.error(`job ${job?.id} failed: ${err.message}`));
SQS trades that expressiveness for zero operational surface. The retry curve is implicit — the message reappears when its visibility timeout lapses, and the redrive policy on the queue decides when it stops:
// sqs-worker.mjs
import { SQSClient, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand } from "@aws-sdk/client-sqs";
import process from "node:process";
const QueueUrl = process.env.SQS_QUEUE_URL;
if (!QueueUrl) throw new Error("set SQS_QUEUE_URL");
const sqs = new SQSClient({ region: process.env.AWS_REGION ?? "us-east-1" });
await sqs.send(new SendMessageCommand({
QueueUrl,
MessageBody: JSON.stringify({ assetId: "asset_4471", width: 512 }),
DelaySeconds: 0,
}));
const out = await sqs.send(new ReceiveMessageCommand({ QueueUrl, MaxNumberOfMessages: 10, WaitTimeSeconds: 20 }));
for (const m of out.Messages ?? []) {
const job = JSON.parse(m.Body);
const res = await fetch(`https://render.internal/resize/${job.assetId}?w=${job.width}`);
if (!res.ok) continue; // leave it; visibility timeout redelivers
await sqs.send(new DeleteMessageCommand({ QueueUrl, ReceiptHandle: m.ReceiptHandle }));
}
Infrai’s version has no client library at all. Create the queue and its failure lane in one call:
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":"thumbnail-jobs","type":"standard","dlq":"thumbnail-jobs-dlq"}'
Then publish from anywhere that speaks HTTPS:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"thumbnail-jobs","body":{"asset_id":"asset_4471","width":512}}'
And the worker is a loop over two POSTs:
// infrai-worker.mjs — node 22, no dependencies
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
const API = "https://api.infrai.cc";
const QUEUE = "thumbnail-jobs";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
async function post(path, payload) {
const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(`${path} ${res.status}: ${json?.error?.message ?? "unknown"}`);
return json.data;
}
async function pass() {
const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
for (const msg of items) {
const job = msg.payload; // published as `body`, returned as `payload`
try {
const res = await fetch(`https://render.internal/resize/${job.asset_id}?w=${job.width}`, {
headers: { "Idempotency-Key": `thumb:${job.asset_id}:${job.width}` },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`resize ${res.status}`);
await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
} catch (err) {
console.error(`asset ${job.asset_id} attempt ${msg.delivery_count}: ${err.message}`);
// no ack: redelivered after the visibility timeout, dead-lettered on the third try
}
}
return items.length;
}
for (;;) {
if (!(await pass())) await sleep(2000);
}
Three deliveries, then thumbnail-jobs-dlq, with no retry code written. That’s the trade-off in one sentence: less control than BullMQ, less setup than either.
Idempotency is yours in all three
None of these three deduplicate for you in the way people hope. BullMQ’s jobId collapses duplicate enqueues but not duplicate executions; SQS FIFO deduplication has a five-minute window; Infrai’s queue is at-least-once and says so. The guarantee has to sit where the side effect happens — a unique constraint on a job_runs table keyed by the work, not by the message. Note in the worker above that the idempotency key is derived from the asset and the width, so a redelivery is a no-op at the renderer even though it’s a fresh message.
What each one bills you for
SQS gives every account 1 million requests free each month and meters each 64 KB chunk of a payload as a separate request, so a 1 MiB message is 16 requests. BullMQ’s queue operations are free and the Redis instance is not — a small managed Redis is the floor, whether or not you push a single job. Infrai charges $0.00002 per publish, verified 2026-07-26, while consume, ack, stats and dead-letter reads are free and rate-limited; new accounts get $2 of credit. Rates here have moved down over time, so check before budgeting:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.namespace=="queue") | {id, billable: .billing.is_billable, usd: .billing.price_usd}'
At a million jobs a month the three are within noise of each other in money terms. They are nowhere near each other in operational terms, which is the actual decision.
Which we’d pick, and the caveats
Already running Redis? Take BullMQ — rate limiting, priorities, repeatable jobs and flow dependencies are things you’d otherwise rebuild. Already all-in on AWS? SQS, obviously; the retry story is weaker but the integration story is unbeatable inside a VPC.
Reach for Infrai’s queue when the queue isn’t the interesting part of your system: no broker to run, workers that can live on any host, and the storage, email and error tracking the job needs next already on the same key and the same invoice. Be clear-eyed about the limitations, though — max_receive_count is fixed at 3 in practice even if you pass another value at create time, there’s no per-message delay in the documented request body, and although {"type":"fifo"} is accepted by create, publishing to a FIFO queue fails today, so standard is the only type that really works. If strict ordering is a requirement, you’d be better off with SQS FIFO or RabbitMQ.