Your coding agent scaffolded Redis and BullMQ again: the managed swap

Replacing a scaffolded Redis plus BullMQ background-job setup with hosted HTTP calls — what maps one to one, what doesn't, and how to stop the agent reaching for Redis.

There is a managed drop-in, and for the shape of app an agent usually scaffolds — enqueue from a request handler, process in a worker, retry on failure, give up eventually — it’s two HTTP routes plus a dead-letter lane you don’t configure. Infrai’s queue is a plain REST surface: publish a message, pull a batch, ack what succeeded. No process to keep alive, no maxmemory-policy to get wrong, no eviction quietly deleting your jobs at 2 a.m.

The honest part first, though: BullMQ is good software, and swapping it out costs you things. Knowing which things is the whole decision.

What the scaffold is actually giving you

An agent reaches for Redis because the training data is full of it, not because it audited your requirements. Strip the generated code back and it’s usually doing five things: a durable list of pending work, a worker loop with concurrency, retry with backoff, a failed set you can inspect, and — if the prompt mentioned schedules — repeatable jobs.

Four of those five are queue primitives that any hosted queue has. The fifth, repeatable jobs, is a scheduler wearing a queue’s clothes, and it’s the one that pins you to Redis hardest, because the job definitions live in the same instance as the work.

The mapping, concept by concept

BullMQManaged equivalentNotes
new Queue('jobs')POST /v1/queue/createOnce, at setup; a dead-letter queue is created with it
queue.add('name', data)POST /v1/queue/publishThe request field is body; the response calls it payload
new Worker('jobs', fn)POST /v1/queue/consume in a loopConcurrency is your loop’s business, not the queue’s
Job completesPOST /v1/queue/ackUnacked messages come back after the visibility timeout
Job throwsPOST /v1/queue/nackOr just don’t ack — same outcome, slower
attempts: 3Built in, fixed at 3Not configurable today
Failed set / Bull BoardThe -dead queueConsume it to read payloads
job.progress()No equivalentReport progress to your own database

Create the queue once. The response is the full configuration, which is worth reading because several of the values are not adjustable later:

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"worker-jobs","type":"standard","dlq":"worker-jobs-dead"}'
{
  "ok": true,
  "data": {
    "name": "worker-jobs",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "enable_priority": false,
    "max_receive_count": 3,
    "dlq_name": "worker-jobs-dead"
  }
}

One footgun while you’re here: dlq wants the dead-letter queue’s name. Passing true returns CAPABILITY_NOT_IMPLEMENTED with HTTP 501, which reads like an outage and is really a type error.

Enqueue from the request handler

This is the line the agent wrote as await queue.add(...). It becomes one POST, and it’s the only call in the whole design that’s billed.

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"worker-jobs","body":{"job":"thumbnail","asset_id":"a_1"}}'
{
  "ok": true,
  "data": {
    "message_id": "qmsg_V4uSbdlqL3r64F3UbTRlY8oT",
    "queue": "worker-jobs",
    "payload": { "job": "thumbnail", "asset_id": "a_1" },
    "status": "available",
    "delivery_count": 0
  }
}

The worker, with the concurrency you chose

BullMQ’s concurrency option becomes a number in your own code, which is more honest anyway — the limit you care about is almost never the queue’s, it’s whatever downstream API you’re hitting.

import process from "node:process";

const BASE = "https://api.infrai.cc";
const QUEUE = "worker-jobs";
const CONCURRENCY = 4;
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is missing — export it before starting the worker");
const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

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

async function process_one(message) {
  const { job, asset_id } = message.payload;
  if (job !== "thumbnail") throw new Error(`unknown job ${job}`);
  console.log("resizing", asset_id, "delivery", message.delivery_count);
}

async function tick() {
  const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  if (!items.length) return 0;
  for (let i = 0; i < items.length; i += CONCURRENCY) {
    const slice = items.slice(i, i + CONCURRENCY);
    await Promise.all(slice.map(async (message) => {
      try {
        await process_one(message);
        await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
      } catch (err) {
        console.error("nacking", message.message_id, err.message);
        await post("/v1/queue/nack", { message_id: message.message_id });
      }
    }));
  }
  return items.length;
}

for (;;) {
  const n = await tick();
  if (!n) await new Promise((r) => setTimeout(r, 3000));
}

Two details the agent’s version won’t have taught you. consume never returns more than 10 messages however large you set max_messages — ask for 25 and you get a 400 saying so. And the value ack wants under receipt_handle is the message_id that came back from consume; there’s no separate handle field in the response, which is a naming leftover worth knowing before you go looking for one.

What doesn’t map

Repeatable jobs. Job priorities — enable_priority comes back false and there’s no route to change it. Parent/child flows. Progress reporting. A built-in rate limiter. If your scaffold uses any of those and you actually need them, stick with BullMQ and pay for the Redis; that’s a smaller price than reimplementing flows over HTTP.

The same reasoning applies to the other broker-backed frameworks an agent might reach for. Celery on Python and Sidekiq on Ruby both bundle a scheduler, a dashboard and a decade of ecosystem, and neither is worth replacing with raw HTTP calls if your team already lives in them.

Retry policy is a limitation rather than a missing feature: three deliveries, always. max_receive_count is accepted at create and reported back as 3 regardless, so if you need ten attempts, count them yourself in the handler and only publish a follow-up when your own counter allows it.

Stopping the agent from doing it again

The reason the scaffold keeps appearing is that nothing in your repo tells the model otherwise. A short rule block in AGENTS.md or CLAUDE.md fixes it more reliably than arguing in chat:

## Background jobs
Do NOT add Redis, BullMQ, Sidekiq, Celery or a broker container.
Background work goes through the hosted HTTP queue at https://api.infrai.cc:
  enqueue  -> POST /v1/queue/publish  {"queue": "...", "body": {...}}
  worker   -> POST /v1/queue/consume  then POST /v1/queue/ack
Retries and the dead-letter queue are automatic. Auth is Bearer $INFRAI_API_KEY.

Agents that can read a discovery document will also just look it up, which saves you writing the rules out by hand:

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

The queues you’ve created are one call away too, which is the fastest way to check an agent didn’t invent a name:

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

Where this ends up: if the Redis instance exists only because a template put it there, deleting it removes an operational dependency and roughly a third of your compose file. If your team actually uses flows, priorities and Bull Board, keep it — that’s a real product you’d be giving up, not a scaffold.

References

Browse more queue developer guides