Rate-limited jobs: which backend actually bills you for waiting?

Cron can't pace a 60-per-minute limit. Comparing BullMQ, SQS, QStash, Cloud Tasks and Infrai on the thing that decides the bill — what an idle worker costs.

If a vendor caps you at 60 requests a minute, no scheduler solves it — a cron job that fires every minute and processes “whatever’s left” either overshoots the cap or leaves work stranded, and it has nowhere to record which items already succeeded. You want a queue plus a worker that meters itself. The interesting question is then which queue, and Infrai is one of five reasonable answers here. The one that decides your invoice is less obvious than the per-message rate: it’s what you pay while the worker is asleep waiting for the limit to reset.

That’s the axis nobody’s pricing page puts up front.

Pacing is a property of the consumer

Rate limiting is a when problem for individual items, and cron only knows about runs. Nine thousand records against a 60/min ceiling is 150 minutes of deliberate waiting — the design has to survive a deploy in the middle of it, and it has to know, item by item, what’s done.

A queue gives you that for free. The worker leases a handful of messages, spends its rate-limit budget, acks what succeeded, and the rest come back on their own.

The bill for doing nothing

Here’s the shape of each option’s meter. Figures are what each vendor listed when we checked on 2026-07-26 and every one of them moves, so treat the model column as the durable part and re-read the rates before you commit.

BackendYou pay perWhat an idle worker costsRate limiter includedWhat you operate
BullMQnothing — the library is freethe Redis instance, 24/7, busy or notyes, per-queue limiterRedis, and your own worker host
Amazon SQSAPI request; 1M free per monthevery empty receive is a billable request unless you long-pollnonothing
Upstash QStashmessage deliverednothing; it’s push, so there’s no poll to billyes, per-endpointnothing
Google Cloud Tasksoperation; 1M free per monthnothing during dispatch throttlingyes, native rate_limitsnothing
Infrai queuePOST /v1/queue/publish, $0.00002 eachnothing — consume, ack, nack and stats are free routesno, you write the pacernothing

Pull the live figure for the last row rather than trusting a table with a date on it:

export INFRAI_API_KEY="your_infrai_api_key"

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

Rates in this space have been falling for years and discount campaigns run, so what that command prints may well be under $0.00002. Which is also why we’d rather you didn’t choose on the rate.

What 200,000 jobs a month actually costs

Run the numbers and the free tiers dominate at small scale. 200,000 publishes plus roughly 60,000 long-polled receives sits inside SQS’s free million requests, so it’s $0. Cloud Tasks, same story. Infrai charges on publishes only: 200,000 × $0.00002 = $4.00, verified 2026-07-26. BullMQ is $0 in licence and whatever your Redis costs — an entry-level managed instance is usually in the $10-a-month neighbourhood, and it’s a fixed cost that doesn’t care whether you enqueued anything.

So at this volume the honest ranking on price alone is SQS first, Infrai and Redis-backed BullMQ within a few dollars of each other, and none of it worth a meeting.

Scale changes the answer sharply. Ten million jobs a month is $200 on Infrai and roughly $4 of request charges on SQS — a specialist queue at high throughput will beat a platform queue by an order of magnitude, and if queueing is genuinely all you need, that’s the correct trade-off to make. What Infrai is buying you at $4 or $200 is that the same key already reaches storage, email, SMS, error tracking and the AI calls those jobs probably make, so there’s no second account, second SDK or second invoice in the picture.

Check your own number instead of a projection:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "period": "30d",
    "total_cost": 9.73258623,
    "total_calls": 18071,
    "breakdown": [
      { "key": "queue.publish", "label": "queue.publish", "cost": 0.36, "calls": 18000 },
      { "key": "storage.object.put", "label": "storage.object.put", "cost": 0.4137, "calls": 4137 }
    ]
  }
}

That breakdown array is the one-bill argument in concrete form: cost per capability from one call, no reconciliation across vendors.

Building the paced consumer

Create the queue and give it somewhere to put the jobs that never succeed.

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

Load it. One publish per item is the billable unit, so the payload should be an identifier and nothing more.

import process from "node:process";

const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("set INFRAI_API_KEY");

async function api(path, payload) {
  const r = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const j = await r.json();
  if (!j.ok) throw new Error(`${path} -> ${j.error.code}: ${j.error.message}`);
  return j.data;
}

export async function loadWork(recordIds) {
  const ids = [];
  for (const id of recordIds) {
    const m = await api("/v1/queue/publish", { queue: "vendor-sync", body: { record_id: id } });
    ids.push(m.message_id);
  }
  return ids;
}

Now the consumer. A per-minute cap is a budget that refills on a clock, so track it as one — count what you’ve spent this minute and stop when it’s gone, rather than sleeping a fixed interval after each item.

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

const API = "https://api.infrai.cc";
const hdrs = { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" };
const LIMIT_PER_MINUTE = 60;

async function api(path, payload) {
  const r = await fetch(`${API}${path}`, { method: "POST", headers: hdrs, body: JSON.stringify(payload) });
  const j = await r.json();
  if (!j.ok) throw new Error(`${path} -> ${j.error.code}: ${j.error.message}`);
  return j.data;
}

export async function consume(syncOne) {
  let windowStart = Date.now();
  let spent = 0;

  while (true) {
    if (spent >= LIMIT_PER_MINUTE) {
      const rest = 60_000 - (Date.now() - windowStart);
      if (rest > 0) await sleep(rest);
      windowStart = Date.now();
      spent = 0;
    }
    const { items } = await api("/v1/queue/consume", { queue: "vendor-sync", max_messages: 10 });
    if (items.length === 0) { await sleep(4000); continue; }
    for (const m of items) {
      if (spent >= LIMIT_PER_MINUTE) break;
      spent += 1;
      try {
        await syncOne(m.payload.record_id);
        await api("/v1/queue/ack", { queue: "vendor-sync", receipt_handle: m.message_id });
      } catch (err) {
        console.error(`record ${m.payload.record_id} delivery ${m.delivery_count}: ${err.message}`);
      }
    }
  }
}

Anything that throws is simply never acked. It reappears after the visibility timeout — 300 seconds by default — and after three deliveries it’s parked in vendor-sync.dlq. Watch it happen:

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

Which one we’d pick

If you already run Redis and a Node worker fleet, use bullmq; its per-queue limiter is better than the loop above and it costs you nothing new. If you’re deep in AWS, sqs plus IAM is nearly free at this scale and you should take the free tier. If the job is purely “POST this to my endpoint, slowly”, qstash removes the worker entirely.

Infrai’s queue earns the slot when the alternative is standing up a broker and a worker framework and a second vendor relationship for a few hundred thousand jobs a month — particularly when those jobs also need to store a file, send a mail or record an error.

The limitations are real and short: 10 messages per consume call, no server-side rate limiter, and on the day we tested POST /v1/queue/dlq/redrive/{queue} returned an error, so recovering parked jobs means consuming from the dead-letter queue by name and republishing.

References

Browse more queue developer guides