Retrying failed jobs: a Postgres jobs table and cron, or a queue?
A cost and effort comparison for small SaaS backends: what SELECT FOR UPDATE SKIP LOCKED really gives you, what you end up writing yourself, and when a hosted queue is cheaper.
If you already run Postgres and something that fires every minute, a jobs table is the cheapest retry system available to you, and at a few hundred jobs a day you should just build it. It stops being cheapest the moment you start writing the parts nobody plans for — exponential backoff, leases so two workers don’t grab the same row, a place for the job that fails 40 times, and some way to see any of it. Infrai’s queue ships those four behaviours, metered only on publish.
So the real question isn’t queue versus cron. It’s how much of a queue you’re willing to write.
The do-it-yourself version, done properly
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
kind text NOT NULL,
payload jsonb NOT NULL,
attempts int NOT NULL DEFAULT 0,
run_after timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz,
failed_at timestamptz,
last_error text
);
CREATE INDEX jobs_ready ON jobs (run_after) WHERE failed_at IS NULL;
The claim query is the part worth copying. SKIP LOCKED lets several workers pull disjoint rows without blocking each other, which is the single feature that makes a database-backed queue viable at all:
UPDATE jobs SET locked_until = now() + interval '5 minutes', attempts = attempts + 1
WHERE id IN (
SELECT id FROM jobs
WHERE failed_at IS NULL AND run_after <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY run_after
FOR UPDATE SKIP LOCKED
LIMIT 20
)
RETURNING id, kind, payload, attempts;
And the sweep that a cron entry calls every minute, with the statement above saved next to it as claim.sql:
import { readFileSync } from "node:fs";
import process from "node:process";
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const CLAIM_SQL = readFileSync(new URL("./claim.sql", import.meta.url), "utf8");
const BACKOFF_S = [30, 120, 600, 3600, 21600]; // 30s, 2m, 10m, 1h, 6h
export async function sweep(handlers) {
const { rows } = await pool.query(CLAIM_SQL);
for (const job of rows) {
try {
await handlers[job.kind](job.payload);
await pool.query("DELETE FROM jobs WHERE id = $1", [job.id]);
} catch (err) {
const wait = BACKOFF_S[Math.min(job.attempts - 1, BACKOFF_S.length - 1)];
const dead = job.attempts >= BACKOFF_S.length;
await pool.query(
"UPDATE jobs SET run_after = now() + ($1 * interval '1 second'), locked_until = NULL, last_error = $2, failed_at = CASE WHEN $3 THEN now() ELSE NULL END WHERE id = $4",
[wait, err.message.slice(0, 500), dead, job.id],
);
console.warn(`job ${job.id} (${job.kind}) attempt ${job.attempts} failed: ${err.message}`);
}
}
return rows.length;
}
That’s a working retry system in about 60 lines, and it costs nothing you aren’t already paying. Be fair to it.
Now be fair about what it doesn’t do. Recovery latency is your cron interval, so a job that fails at 12:00:05 with a 30-second backoff still waits until 12:01. The locked_until lease is only honoured by workers that remember to check it. failed_at IS NOT NULL is a dead-letter queue only in the sense that a spreadsheet is a database — nothing lists it, nothing alerts on it, and re-running those rows is a query you’ll write by hand at 2am. Deleting completed jobs churns the table, so eventually you add a cleanup job for your job table.
The hosted version
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":"tasks","type":"standard","dlq":"tasks-dlq"}'
Leases, attempt counting and the dead-letter hand-off come with the queue. The worker keeps only the part that’s yours:
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not configured");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
async function api(path, payload) {
const res = await fetch(`https://api.infrai.cc${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const out = await res.json();
if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
return out.data;
}
export async function run(handlers) {
for (;;) {
const { items } = await api("/v1/queue/consume", { queue: "tasks", max_messages: 10 });
if (!items.length) { await sleep(5000); continue; }
for (const msg of items) {
try {
await handlers[msg.payload.kind](msg.payload);
await api("/v1/queue/ack", { queue: "tasks", receipt_handle: msg.message_id });
} catch (err) {
console.warn(`delivery ${msg.delivery_count} of ${msg.message_id} failed: ${err.message}`);
}
}
}
}
No ack means the message returns when its lease expires, and the third delivery routes it to tasks-dlq without you writing a line. Publishing to a name you never created won’t error, by the way — the API quietly makes a standard queue for you — so a typo in queue is silent, not a QUEUE_NOT_FOUND.
The money, at three volumes
| Approach | Fixed monthly floor | Marginal cost per job | What you still write |
|---|---|---|---|
jobs table plus cron | none, if Postgres is already there | zero, plus table bloat | backoff, leases, DLQ, alerting, cleanup |
| BullMQ | a Redis instance, billed whether idle or not | zero | deployment and Redis operations |
| SQS | none; a free request tier exists | per request | AWS wiring, IAM, a poller |
| Infrai queue | none | one publish per job, per retry-by-republish | your handler |
Publishing is the only metered call at $0.00002 per message — verified 2026-07-26 — and create, consume, ack and dead-letter reads are free within rate limits. Concretely: 1,000 jobs a day is $0.60 a month. 50,000 a day is $30. A million a month is $20. Redeliveries are free, so a bad afternoon where every job fails three times costs the same as a good one.
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That shows balance_usd, daily_avg_spend and runway_days, plus an affordable_uses_hint block that turns your balance into a per-capability call count. New accounts start with $2 of credit, which is roughly 99,999 publishes. Rates on this platform move down over time and campaigns run, so what you read there is likely to be lower than what’s printed here.
The crossover isn’t really about cents. At 50,000 jobs a day, $30 a month against a week of engineering time and a permanent operational surface is not a close call — but at 500 jobs a day the table wins, because zero is hard to beat and the failure modes are ones you can already see in psql.
What we’d actually recommend
Fewer than roughly 1,000 jobs a day, no ordering needs, one worker process: keep the table. You’re not going to hit the cases that make it painful, and everything lives in a database you’re already backing up.
Above that — or the first time you need a retry to survive a deploy, or a second worker, or an on-call alert when the failure count climbs — move it. BullMQ is the strongest alternative if Redis is already in the stack and you want job classes, rate limiters and a UI in-process; the trade-off is running Redis. SQS is right when everything else is AWS. QStash suits you if the jobs are outbound HTTP calls and nothing else.
Infrai’s queue fits the case where you’d rather not add a broker at all, and where the job’s next step — sending the notification, storing the artefact, recording the error — is already reachable with the same key and lands on the same invoice. Its limitations are real: 10 messages per consume call, no scheduling primitives inside the queue, and no dashboard of named job classes. If you want retry policy expressed as decorators beside your business logic, stick with a job framework.