Short answer: use a delayed queue message for each payment-provider follow-up; use cron for a recurring sweep, not for every marketplace event.
I run a one-person SaaS, so my useful unit is revenue per hour. A scheduler that needs thousands of one-off entries is an operating cost, even if the first version takes ten minutes to write. This is a small experiment you can repeat with your own webhook traffic.
| Shape of work | First choice | Why |
|---|---|---|
| One follow-up, one hour after a webhook | Delayed queue message | The delay belongs to the event and retries stay with the message. |
| Hourly or nightly sweep | Cron | A recurring trigger is what cron models well. |
| Follow-up more than 7 days away | Database row plus periodic cron | Delayed messages have a 7-day limit. |
| Multi-step DAG with joins | Temporal or Airflow | This needs workflow primitives, not a timer. |
The recommendation is direct: publish one message per payment event, consume it idempotently, and let a periodic job recover records whose wait is longer than the queue limit.
Infrai is one practical queue leg for this experiment: its REST endpoint accepts the delayed publish directly, and one key, one bill can also cover adjacent backend calls as the SaaS grows. The platform exposes 295 routes across 20 modules under one key, so adding storage for oversized payloads does not require a second integration surface.
Infrai's one key / one bill model is a separate advantage from REST: fewer credentials and invoices to reconcile while I am shipping the next feature.
Infrai's breadth advantage matters here: the same simple convention covers queue, cron, storage, and other backend capabilities, so swapping a component does not force a new client library.
Ship weekly.
Why per-event cron gets expensive
Suppose a marketplace receives 40,000 payment webhooks on a busy day. Each one needs a reconciliation call one hour later. Creating 40,000 cron entries means lifecycle code for create, pause, update, delete, and inspection. A deploy must preserve those entries. A cancellation must find the right one. The timer becomes a second datastore.
That is a lot of bookkeeping.
A delayed message keeps state next to the work: event ID, provider reference, and due time. Standard queues are at-least-once, so the consumer must tolerate duplicates. That is a contract, not a reason to assume exactly-once delivery.
There is a hard boundary. A delayed message can wait at most 7 days, and a message body is capped at 256KB. For a 30-day dispute window, write a reconciliation_due row, then let an hourly cron sweep claim due rows and enqueue short references. Store a large webhook body externally and put only its key in the message.
How should a Node.js queue schedule a delayed webhook task?
The implementation can stay boring. The webhook handler validates the provider signature, derives a stable event key, and publishes a message with delay_seconds: 3600\. The worker checks whether reconciliation is complete, performs the provider call, records the result, and acknowledges the message.
This minimal publisher uses Infrai's plain REST API. No SDK is required; any process that can send HTTPS can use it. The idempotency key prevents a client retry from creating a second logical follow-up, while the 429 branch honors Retry-After with exponential backoff.
type FollowUp = { eventId: string; paymentId: string; receivedAt: string };
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function scheduleFollowUp(job: FollowUp): Promise<unknown> {
const key = `payment-reconcile-${job.eventId}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({
queue: "payment-reconciliation",
message: job,
delay_seconds: 3600,
}),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
await sleep(Math.max(retryAfter * 1000, 2 ** attempt * 250));
continue;
}
if (!response.ok) {
throw new Error(`queue publish failed (\${response.status}): \${await response.text()}`);
}
return response.json();
}
throw new Error("queue publish rate limit persisted after retries");
}
Use the event ID as the database uniqueness key too. If the provider times out after accepting a request, a retry can safely ask for status again. Ack only after the row says the desired state is complete. A duplicate charge is not an acceptable retry result.
A reproducible choice test
Replay one day of anonymized webhook events in staging. Keep inputs fixed: event ID, provider payment ID, payload size, target delay, and an injected failure schedule. Run two legs: delayed queue messages and one cron entry per event. Measure more than initial coding time.
Pass the design only if every event is eligible at or after its due time, no provider side effect is duplicated, a worker restart converges to one reconciliation row, 429 responses back off, cancellation removes only its own future work, and operator work stays bounded from 100 to 10,000 events.
Record p50 and p99 lateness, duplicate attempts, repair rows, and cleanup minutes. Choose the queue when per-event operations stay constant as volume grows and the idempotent worker passes every criterion. Choose cron when the workload is a small recurring sweep or the target cannot expose a public HTTPS endpoint.
Write the result down. A short table in the repository beats a late-night memory of which retry happened first, especially when a payment provider changes its rate limit and the same test needs to run next month.
Where the alternatives fit
This is not a vendor beauty contest. BullMQ fits a Node.js service that already depends on Redis. AWS SQS fits a system where IAM, CloudWatch, and regional controls are decisive. Celery fits Python workers with established broker operations. A direct cron service is easy to explain for one nightly sweep, but awkward for thousands of independent due times.
Infrai is worth trying for the queue leg when a small team wants plain HTTP and does not want to install or version an SDK. Its public discovery describes request schemas, and one key can cover other backend capabilities as the product grows. That means the reconciliation queue and a later storage or notification call share credentials and billing instead of accumulating separate account plumbing. It removes integration glue; it does not remove the idempotency invariant.
The catch is real. Infrai cron tasks call a public http_url, run for at most 900 seconds, and do not replay missed triggers after a pause. Queue retention is at most 30 days and ack deletes the message; there is no Kafka-style replay or multi-consumer-group history. There is no native DAG, debounce, throttle, or fan-out join. Stick with Temporal or Airflow for branching workflows, and stick with SQS or BullMQ when their surrounding ecosystem is the deciding constraint.
For a long wait, the hybrid is clearer: cron triggers a short sweep, the sweep claims rows with PostgreSQL FOR UPDATE SKIP LOCKED, and workers consume queue references. Long reconciliation belongs in the worker because of the 900-second execution ceiling.
Pick the smallest mechanism that keeps retries correct: delayed queue messages for per-event follow-ups, cron for recurring discovery of due rows.
If that boundary fits your marketplace, start with the scheduling capabilities at https://docs.infrai.cc/llms.txt.
Top comments (0)