What a managed cron plus queue stack really costs a side project
Free tiers compared for hosted schedulers and message queues, plus the arithmetic for a real side-project month and the single call that actually gets metered.
Yes, this combination can be close to free, and the reason is structural rather than promotional: scheduling and queue management are cheap to run, so most vendors give them away and charge for message volume. Infrai works the same way. The entire cron surface is free to call, as are queue create, consume, ack, stats and dead-letter reads — one call in the whole stack is metered, and it’s publishing a message.
So the question worth asking a vendor isn’t “is there a free tier” but “which call is the meter attached to, and how many of those does my design make?”
Free tiers and where they stop
Every hosted scheduler advertises free runs. The ones that bite you later are the ones where retries, dead-lettering or a second capability sit behind a paid plan, so a side project that outgrows the toy phase discovers the real price the same week it gets its first real users.
Amazon SQS gives a permanent allowance of a million requests a month and then charges per request — generous, though “request” counts your empty polls too, which is how idle workers run up a bill. Upstash QStash prices per message and is a good fit if all you want is HTTP delivery with retries. BullMQ is free software, but the managed Redis it needs is not, and the smallest useful instance is a fixed monthly line item whether or not a single job runs.
Self-hosting RabbitMQ on a spare VPS is the genuinely cheapest option on paper. It’s also the one where you’re the on-call engineer.
The one call that’s metered
Create a queue with a dead-letter lane. Nothing here is billed, and the response tells you the defaults you’re getting:
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"sideproject-tasks","type":"standard","dlq":"sideproject-tasks-dead"}'
{
"ok": true,
"data": {
"name": "sideproject-tasks",
"type": "standard",
"message_retention_days": 14,
"max_message_size_kb": 256,
"visibility_timeout_default": 300,
"max_receive_count": 3,
"dlq_name": "sideproject-tasks-dead"
}
}
Publishing is the meter. One call, one message, one charge:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"sideproject-tasks","body":{"kind":"digest","user_id":"u_412"}}'
Everything after that — pulling the message, acking it, checking the depth, reading the dead-letter queue — costs nothing, so a worker that polls an empty queue all day doesn’t generate an invoice. That’s the difference that matters at side-project scale, because an idle poller is exactly what a side project has.
A worker that runs on a free host
Node 22 has fetch built in, so this needs no dependencies and fits inside the free tier of any small container host.
import process from "node:process";
const BASE = "https://api.infrai.cc";
const QUEUE = "sideproject-tasks";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY (your_infrai_api_key from the console)");
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 handle(message) {
// Replace with the real work; throwing here leaves the message for redelivery.
console.log("processing", message.message_id, JSON.stringify(message.payload));
}
let idle = 0;
for (;;) {
const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
if (!items.length) {
idle = Math.min(idle + 1, 6);
await new Promise((r) => setTimeout(r, 1000 * 2 ** idle));
continue;
}
idle = 0;
for (const message of items) {
try {
await handle(message);
// The field is called receipt_handle, but the value comes back as message_id.
await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
} catch (err) {
console.error("failed", message.message_id, err.message);
}
}
}
The backoff matters less for cost than for politeness here, since consume is free either way. It matters for rate limits, which are the real constraint on the free routes.
To confirm the loop is keeping up, read the depth — also free, also unmetered, so you can wire it straight into a status page:
curl -sS "https://api.infrai.cc/v1/queue/stats/sideproject-tasks" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"queue": "sideproject-tasks",
"message_count": 3,
"available_count": 3,
"in_flight_count": 0,
"delayed_count": 0,
"dlq_count": 0
}
}
The arithmetic for a real month
Say your side project fires a scheduled trigger every five minutes, and each run fans out a handful of jobs — roughly 50,000 messages a month.
| Line item | Calls per month | Rate | Monthly |
|---|---|---|---|
| Scheduled trigger definitions and runs | 8,640 | free | $0 |
| Queue create, get, stats | a few dozen | free | $0 |
| Publish | 50,000 | $0.00002 per call | $1.00 |
| Consume + ack | ~200,000 | free | $0 |
| Dead-letter reads and redrive | as needed | free | $0 |
New accounts start with $2 free credit, so the first hundred thousand messages are covered before a card is involved. Rates were read on 2026-07-26 and they’ve trended downward, so confirm against the live catalogue rather than this table:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id | startswith("queue.") or startswith("cron.")) | {id, price: .billing.price_usd}]'
And the running total, whenever you want it:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
What you give up
Consume returns at most 10 messages per call — ask for 25 and you get a 400 telling you so, which is fair but undocumented in the parameter description. Messages are retained for 14 days. The delivery budget is fixed at three attempts, and max_receive_count is accepted at create without changing it, so treat three as a constant rather than a setting.
There’s no per-queue region control either. Queues are served from western and China regions, and the API doesn’t support pinning one to a single jurisdiction, so a hard residency requirement is a reason to look elsewhere.
The thing a comparison table can’t show is what happens on day 40, when your side project needs to email the user whose job just finished. On a point-solution stack that’s a new account, a new SDK and a second invoice. Here it’s the same key and the same bill — which, for a project you maintain in evenings, is worth more than a few cents of message volume.