Queue or cron for failed webhook retries? Two inequalities decide it
A decision rule instead of a debate: compare your retry granularity to the sweep interval, and your failure volume to one request's timeout. Then the Node.js worker.
Both work. The question is which one stops working first for your traffic, and that’s answerable with two comparisons rather than an opinion. If the retry spacing you need is finer than the shortest schedule you can run, cron can’t express it. If the failures accumulating between sweeps take longer to replay than one HTTP request is allowed to live, cron can’t carry it. Fail either test and you want a queue — Infrai’s is HTTP-only, so the “worker” stays a plain Node process with no inbound port.
Pass both tests and cron plus a database table is genuinely the simpler system, and we’d tell you to build that instead.
The two inequalities
Retry granularity < sweep interval? Webhook consumers expect a fast first retry — many senders go 10 s, 30 s, 2 min, 10 min. A once-a-minute cron can approximate the tail of that curve but not its head, and most managed schedulers won’t go below a minute anyway.
Failures per sweep × replay time > request timeout? This is the one that bites in an incident. If a subscriber is down for an hour and you accumulate 6,000 failed deliveries, a sweep that replays them serially at 200 ms each needs 20 minutes. Your scheduled HTTP job will be cut off long before that — 300 seconds is the common default — and the next tick starts the same doomed scan from the top.
A worked example. Say 40,000 webhooks a day, a 2% failure rate, and a subscriber outage lasting 45 minutes: that’s roughly 500 stuck deliveries when the sweep next runs, 100 seconds of serial replay, and a retry ladder starting at 10 seconds. Second test passes, first test fails. Queue.
What “just use cron” actually costs in code
The cron version isn’t one line, it’s a table plus the concurrency control around it. Here’s the honest minimum:
CREATE TABLE webhook_retries (
id BIGSERIAL PRIMARY KEY,
subscription TEXT NOT NULL,
target_url TEXT NOT NULL,
body JSONB NOT NULL,
attempts INT NOT NULL DEFAULT 0,
next_attempt TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_by TEXT,
locked_until TIMESTAMPTZ,
last_error TEXT,
dead BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX ON webhook_retries (next_attempt) WHERE NOT dead;
Then a SELECT ... FOR UPDATE SKIP LOCKED claim query, a lease so two sweeps can’t grab the same row, an expiry so a crashed sweep releases its rows, a dead transition when attempts run out, and a way to look at the dead rows. That’s the dead-letter queue, the visibility timeout and the receive count — reimplemented, in your schema, with your bugs.
The queue version
One call gets the lane and the failure lane:
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":"hook-redelivery","type":"standard","dlq":"hook-redelivery-dlq"}'
When a live delivery fails, the dispatcher publishes it instead of writing a row:
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"hook-redelivery","body":{"subscription":"sub_71","target_url":"https://customer.example.com/hooks","event":{"id":"evt_5510","type":"invoice.paid"},"attempt":1,"not_before":"2026-07-26T09:15:00Z"}}'
The worker is an outbound-only HTTP loop. It can run on a laptop, a spare container, or next to your API — nothing has to route traffic to it, which is the practical reason a pull consumer beats a push subscription when your worker lives behind a firewall.
// redelivery-worker.mjs — node 22, no dependencies
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
const API = "https://api.infrai.cc";
const QUEUE = "hook-redelivery";
const LADDER_SECONDS = [10, 30, 120, 600, 3600]; // 5 attempts, then dead-letter
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
async function post(path, payload) {
const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(`${path} ${res.status}: ${json?.error?.message ?? "unknown"}`);
return json.data;
}
async function deliver(job) {
const res = await fetch(job.target_url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Event-Id": job.event.id },
body: JSON.stringify(job.event),
signal: AbortSignal.timeout(10_000),
});
if (res.ok) return "delivered";
if (res.status === 410 || res.status === 404) return "gone"; // stop retrying
return "retry";
}
async function pass() {
const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
for (const msg of items) {
const job = msg.payload; // published under `body`, read back as `payload`
if (job.not_before && Date.parse(job.not_before) > Date.now()) continue;
let outcome = "retry";
try {
outcome = await deliver(job);
} catch (err) {
console.warn(`sub ${job.subscription}: ${err.message}`);
}
if (outcome === "retry" && job.attempt < LADDER_SECONDS.length) {
const waitSeconds = LADDER_SECONDS[job.attempt];
const jittered = Math.round(waitSeconds * (0.5 + Math.random() / 2));
await post("/v1/queue/publish", {
queue: QUEUE,
body: { ...job, attempt: job.attempt + 1, not_before: new Date(Date.now() + jittered * 1000).toISOString() },
});
} else if (outcome === "retry") {
console.error(`sub ${job.subscription} event ${job.event.id}: ladder exhausted`);
}
await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
}
return items.length;
}
for (;;) {
if (!(await pass())) await sleep(2000);
}
Notice there’s no scheduler anywhere in that file. The spacing comes from not_before on the message; the retry count comes from attempt; and messages the worker never acks — because it crashed, not because it chose to — come back on their own after the visibility timeout and dead-letter after three deliveries. Three mechanisms, none of which you wrote.
Reading and replaying the dead lane
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"hook-redelivery-dlq","max_messages":10}'
curl -sS -X POST "https://api.infrai.cc/v1/queue/dlq/redrive/hook-redelivery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message_id":"qmsg_QBxmkCtGd7WqVqemXO8e5l5A"}'
Two caveats we hit on 2026-07-26. Redrive works per message and answers {"redriven": 1}, but calling the same route with an empty body to move everything at once fails with an internal error, so drain deliberately rather than in bulk. And GET /v1/queue/dlq/list/{queue} came back with an empty items array while dlq_count was 1 — consuming <queue>-dlq by name, as above, is the reliable read. An unknown identifier gives QUEUE_MESSAGE_NOT_FOUND.
Check the shape of the backlog before you replay any of it:
curl -sS "https://api.infrai.cc/v1/queue/stats/hook-redelivery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Side by side
| Cron + retry table | Queue + pull worker | |
|---|---|---|
| Finest retry spacing | Your schedule’s floor, usually 1 minute | Seconds, held in the message |
| Concurrency safety | You write the lease and the skip-locked claim | Visibility timeout, built in |
| Dead-letter handling | A dead column and a query you’ll write later | A queue created alongside the main one |
| Behaviour during a long outage | Each tick rescans the same growing backlog | Backlog drains continuously |
| Inbound network surface | None | None |
| New infrastructure | Your existing database | None |
What each one costs
The cron version’s marginal cost is database rows, which is close enough to zero that it’s not a deciding factor. The queue version bills per publish — $0.00002 each, verified 2026-07-26 — while consume, ack, stats and dead-letter reads are free and rate-limited. At 40,000 webhooks a day with a 2% failure rate and five attempts, that’s about 4,000 retry publishes daily, or roughly $2.40 a month. New accounts get $2 of credit to start. These rates have trended down, so read the live figure:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id=="queue.publish") | .billing'
When cron really is the right answer
Low volume with a loose deadline — a few hundred failures a day, retried within the hour — is the case where a table and a nightly-ish sweep wins on total complexity, and you shouldn’t add a broker for it. If Redis is already running, BullMQ gives you the delay and the ladder as configuration and is a shorter path than either option here. If ordering per subscriber is a hard requirement, look at RabbitMQ or SQS FIFO instead; Infrai’s queue is standard-type in practice and doesn’t support strict ordering today.