Cheapest queue for rate-limited webhook fan-out: US and EU options compared
SQS, CloudAMQP, QStash, Cloud Tasks and Infrai priced against the same 2M-delivery month — plus which of them can actually pace deliveries at 5 per second.
If you’re sending webhooks to partners who rate-limit you, the queue’s per-message price is rarely what decides the bill — retry accounting and idle cost are. Infrai’s queue meters publishes only, and a redelivery is never a new publish; QStash charges for every delivery attempt; Cloud Tasks bills each push attempt as an operation; CloudAMQP charges for the broker by the month regardless of traffic. Same workload, four completely different invoices.
Here’s the comparison, then the code that does the pacing, then the honest verdict about where Infrai is and isn’t the cheap answer.
Four billing shapes
| Product | Free allowance | What you pay for | Native rate limiting | Delay | DLQ | Region choice |
|---|---|---|---|---|---|---|
| Infrai queue | $2 credit ≈ 99,999 publishes | publishes only, $0.00002 each; consume/ack/DLQ free | no — write a token bucket | queue attribute, not per message | yes, max_receive_count 3 | western / china |
| Amazon SQS | 1M requests/month | every request, including empty receives; 64 KB = 1 request | no — write a token bucket | DelaySeconds up to 15 min | yes, redrive policy | any AWS region |
| CloudAMQP (RabbitMQ) | Little Lemur: 1M msgs/month, 20 connections | the plan, monthly — Tough Tiger is $19/mo for 10M msgs | prefetch per consumer | plugin | yes, via policy | many, incl. EU |
| Upstash QStash | 1,000 messages/day | every delivery attempt, $1 per 100K | max parallelism | yes, per message | yes | global edge |
| Google Cloud Tasks | 1M operations/month | each API call and each push attempt, $0.40 per million | yes, dispatches per second | yes, per task | yes | any GCP region |
Verified against each vendor’s published pricing on 2026-07-26; Infrai’s own figure comes from the live catalogue, which you can read yourself:
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 | startswith("queue.")) | {id, billable: .billing.is_billable, usd: .billing.price_usd}]'
Per-call rates on this surface have moved downward over time and discount runs happen, so treat the number above as a ceiling rather than a promise.
One row deserves a second look. Cloud Tasks is the only product here with a rate limit you configure instead of implement — maxDispatchesPerSecond on the queue. If pacing is the entire problem you’re solving and you’re already on GCP, that’s the shortest path and you’d be better off taking it.
The retry column is where the money hides
A partner endpoint that 500s for an hour is the normal case, not the disaster case. Under QStash’s model each attempt is a billed message, so three attempts on 100,000 deliveries costs three times the base. Under Cloud Tasks each dispatch is an operation, with the same multiplier. On Infrai’s queue a redelivery isn’t a publish, so the retry is free — the only thing that costs is putting the message in.
That inverts the usual ranking at high failure rates.
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish_batch" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"hooks-out","messages":[{"payload":{"url":"https://partner.example/hooks","event_id":"evt_1"}},{"payload":{"url":"https://partner.example/hooks","event_id":"evt_2"}}]}'
Batch publishing is metered per call, at $0.001, so it’s cheaper than individual publishes only above 50 messages per request — below that, the single publish route wins. That’s an easy detail to get backwards, and it’s checkable in one line:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "queue.publish_batch") | .billing'
Rates here drift down rather than up, so confirm before you plan around either figure.
Pacing deliveries with a token bucket
None of the per-message products stop you exceeding a partner’s limit; you do that in the consumer. A bucket that refills at the partner’s documented rate, sitting in front of a batch of at most 10 messages, is all it takes:
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
const BASE = "https://api.infrai.cc";
const QUEUE = "hooks-out";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
class TokenBucket {
constructor(ratePerSecond, burst = ratePerSecond) {
this.rate = ratePerSecond;
this.capacity = burst;
this.tokens = burst;
this.last = Date.now();
}
async take() {
for (;;) {
const now = Date.now();
this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.rate);
this.last = now;
if (this.tokens >= 1) { this.tokens -= 1; return; }
await sleep(Math.ceil(((1 - this.tokens) / this.rate) * 1000));
}
}
}
async function call(path, payload) {
const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(`${path}: ${json?.error?.message ?? res.status}`);
return json.data;
}
const bucket = new TokenBucket(5, 5); // partner allows 5 rps
async function deliver(message) {
await bucket.take();
const res = await fetch(message.payload.url, {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": message.payload.event_id },
body: JSON.stringify(message.payload),
signal: AbortSignal.timeout(10000),
});
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after") ?? 30);
console.warn(`429 from partner; pausing ${wait}s and leaving ${message.message_id} unacked`);
await sleep(wait * 1000);
return false;
}
return res.ok || (res.status >= 400 && res.status < 500);
}
export async function run() {
for (;;) {
const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
if (!items.length) { await sleep(2000); continue; }
for (const message of items) {
const settled = await deliver(message).catch((err) => { console.error(err.message); return false; });
if (settled) await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
}
}
}
await run();
Five per second, a burst of five, and a 429 that pauses the whole worker rather than hammering through. Messages that don’t get acked come back when the lease expires and dead-letter after three deliveries, so a partner that stays down doesn’t block the queue for everyone else.
Watch the backlog while it drains:
curl -sS "https://api.infrai.cc/v1/queue/stats/hooks-out" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"queue": "hooks-out",
"message_count": 0,
"available_count": 0,
"in_flight_count": 0,
"delayed_count": 0,
"dlq_count": 0,
"oldest_message_age_seconds": 0
}
}
Two million deliveries in a month
Take 2,000,000 webhook events, a 5% failure rate, and a partner limit of 5 rps — about 4.6 deliveries per second sustained, so one worker is enough.
The arithmetic lands roughly here: Infrai bills 2M publishes at $0.00002, so $40, with the 100,000 retries free. QStash bills 2.1M attempts at $1 per 100K, about $21. Cloud Tasks bills 2M creations plus 2.1M dispatches, minus the free million, at $0.40 per million — under $2. CloudAMQP’s Tough Tiger plan covers 10M messages for $19 a month flat, so traffic doesn’t move the number at all. SQS gives you a million free requests and then bills per request, and your polling receives count too — an idle poller costs money there in a way it doesn’t here.
So no, Infrai’s queue is not the cheapest line item at this volume, and pretending otherwise would be useless to you. It’s the cheapest way to start — the $2 of new-account credit covers 99,999 publishes — and it wins on a different axis: the same key and the same invoice also cover the object storage the payload came from, the email you send when a partner’s endpoint dies, the error tracking that captured the 500, and per-tenant cost attribution as a query rather than a spreadsheet:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns a per-capability breakdown with cost and calls per route for the period — the reconciliation that costs a day a month when your queue, your mailer and your object store are three vendors.
Which one to pick
Take CloudAMQP if traffic is heavy and predictable and you want a flat bill in a named EU region; the entry-level shared plan in the table absorbs ten million messages and beats per-message pricing above roughly a million a month, and RabbitMQ’s prefetch is a genuinely good pacing tool. Take Cloud Tasks if you’re on GCP and want the rate limit declared rather than coded. Take SQS if the workers are in AWS and IAM is how you want to express access. Take QStash if you want push delivery with per-message retry and delay settings and nothing to run.
Take Infrai’s queue when the webhook fan-out is one job among many in a small SaaS, you want at-least-once HTTP semantics with a dead-letter lane, and you’d rather add a queue to an account you already have than open a fifth vendor relationship.
The limitations to weigh: regions are reported as western and china, so if your DPA names Frankfurt specifically this isn’t the right tool; max_messages caps at 10 per consume; max_receive_count is fixed at 3; there’s no per-message delay in the documented publish body; and the dead-letter listing route returned an empty array in our testing, so read a DLQ by consuming it under its own name. A message acked twice reports QUEUE_MESSAGE_NOT_FOUND, which is harmless but worth logging.