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

Two calls get the failure lane and the working lane that feeds it:

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-dead","type":"standard"}'

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","dead_letter_queue":"hook-redelivery-dead","max_retries":3}'

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","payload":{"subscription":"sub_71","target_url":"https://customer.example.com/hooks","event":{"id":"evt_5510","type":"invoice.paid"},"attempt":1},"delay_seconds":10}'

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;                       // whatever the publisher put in `payload`

    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,
        payload: { ...job, attempt: job.attempt + 1 },
        delay_seconds: jittered,
      });
    } else if (outcome === "retry") {
      console.error(`sub ${job.subscription} event ${job.event.id}: ladder exhausted`);
    }
    await post("/v1/queue/ack", { queue: QUEUE, message_id: 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 delay_seconds on the republish — the message is simply invisible until its moment, capped at 604800 seconds, which is seven days and far past the point where a webhook is worth retrying. The retry count rides along in 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 once max_retries is spent. Three mechanisms, none of which you wrote.

The catch is at-least-once delivery: a message you processed but failed to ack comes back, so deliver() has to be safe to run twice. Webhook consumers should be idempotent anyway — that’s what the event id in X-Event-Id is for — but it’s a limitation you inherit rather than one you can configure away.

Reading and replaying the dead lane

curl -sS "https://api.infrai.cc/v1/queue/dlq/list/hook-redelivery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

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"}'

The list route takes the parent queue’s name and its item count matches dlq_count from stats, so triage is a read rather than a destructive consume — you can look at the payloads without taking a lease on them. Redrive with a message_id moves that one message and answers {"redriven": 1}; omit the id and the whole dead lane goes back in one call, which is what you want after the subscriber’s certificate is finally fixed. An id that isn’t in the dead lane gives QUEUE_MESSAGE_NOT_FOUND rather than a false success.

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 tableQueue + pull worker
Finest retry spacingYour schedule’s floor, usually 1 minuteSeconds, held in the message
Concurrency safetyYou write the lease and the skip-locked claimVisibility timeout, built in
Dead-letter handlingA dead column and a query you’ll write laterA queue created alongside the main one
Behaviour during a long outageEach tick rescans the same growing backlogBacklog drains continuously
Inbound network surfaceNoneNone
New infrastructureYour existing databaseNone

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-27 — while consume, ack, stats, create and dead-letter reads are free and rate-limited. One publish per retry attempt is the whole meter, so multiply your own failure count by your own ladder length rather than trusting a worked total. New accounts get $2 of credit to start, and 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 — buy it if you also want the dashboard and the job classes that come with it.

If per-subscriber ordering is a hard requirement, create the queue with "type":"fifo" and give every publish a message_group_id and a deduplication_id; the group is the ordering unit, so one slow subscriber doesn’t stall the others. SQS is the better pick when the retry worker has to live inside an existing AWS IAM boundary.

The retry log lives on the same key

The question a support ticket actually asks is “did we ever deliver evt_5510?”, and that answer doesn’t come from the queue. One POST /v1/logs/ingest per attempt — subscription, event id, status code, attempt number — makes GET /v1/logs/search the answer, and both run on the same key as the publish you just made. Same for POST /v1/errors/capture when the ladder is exhausted: no second vendor, no second key to rotate, and the retry pipeline and its audit trail bill through one account rather than two.

References

Browse more queue developer guides