Per-event delayed tasks: why a cron row per order doesn't scale

Scheduling work that fires once, at a time the event chooses: push versus polling delivery, the 300-second visibility clock, and the 256 KB message ceiling on Infrai.

A cron expression describes a rule that repeats — every night at 03:00, every fifteen minutes. A delayed webhook task is the opposite shape: it fires once, at a moment the event picked, and then it should stop existing. Modelling the second thing as the first means creating a schedule per order and then owning a garbage-collection problem forever. Infrai’s queue holds per-event work as messages, and the recurring side stays where it belongs, in one cron rule that never grows.

Three sub-questions decide the design, and none of them is “cron or queue” in the abstract.

Does the time come from the clock or from the event?

If the answer is “the event”, you want a message. A queue message carries its own payload, its own attempt count and its own terminal resting place; you create the queue once and the number of scheduled things is just the number of messages in it. Ten thousand pending follow-ups is a healthy queue depth. Ten thousand cron rules is an incident waiting to happen — you’ll be reconciling which ones are stale, which fired twice, and which quietly stopped.

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"order-followups","body":{"order_id":"ord_5512","step":"shipping_reminder","not_before":"2026-07-27T14:32:00Z"}}'
{
  "ok": true,
  "data": {
    "message_id": "qmsg_Hvh5etCjENhBRFcYSErye9Rc",
    "queue": "order-followups",
    "payload": { "order_id": "ord_5512", "step": "shipping_reminder", "not_before": "2026-07-27T14:32:00Z" },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T00:46:40.000023Z"
  }
}

Carrying not_before in the payload and letting the worker decide is the portable version of this pattern — it works the same whether the message waits in the queue or your worker re-queues it after a look at the clock.

Push or poll, and what a public HTTPS endpoint actually costs

Two delivery models exist here and they have genuinely different requirements. Polling means your worker calls POST /v1/queue/consume on a loop; nothing about your infrastructure has to be reachable from the internet, which matters if the worker lives on a private subnet or a developer laptop. Push means the platform calls you:

curl -sS -X POST "https://api.infrai.cc/v1/queue/push_subscribe/order-followups" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/queue/order-followups"}'
{
  "ok": true,
  "data": {
    "subscription_id": "sub_3oy1ax9ftzvFi1rQXPuoSbRN",
    "queue": "order-followups",
    "concurrency": 10,
    "max_retries": 3,
    "active": true
  }
}

The subscription runs at a concurrency of 10 with three delivery attempts, and it needs a TLS endpoint the platform can resolve. Omit the url and you get a 400 that says so. If you can’t expose a public HTTPS endpoint — a compliance boundary, a VPC with no ingress, a dev machine — stick with polling; it costs you nothing and asks nothing of your network.

RequirementCron ruleQueue, polledQueue, push subscription
Time chosen per eventNo — one rule per patternYesYes
Needs public HTTPS ingressYes, for the targetNoYes
Handles a job that runs 20 minutesDepends on your host’s timeoutYes, with the visibility timeout raisedNo — the HTTP call has to return
Retry accountingYou build itdelivery_count + dead-letter queuemax_retries on the subscription
Natural fitNightly sweepsPer-event work, any durationPer-event work that finishes fast

Long-running jobs and the 300-second clock

Here’s the number that surprises people. A consumed message is invisible for the queue’s visibility timeout, which defaults to 300 seconds. If your handler takes longer than that and hasn’t acked, the message becomes available again and a second worker picks it up — you now have two workers transcoding the same video, and neither of them is wrong.

Raise the ceiling before you need it:

curl -sS -X PATCH "https://api.infrai.cc/v1/queue/update/order-followups" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"visibility_timeout_default":900}'

And instrument the handler so you find out before your customers do:

import process from "node:process";

const BASE = "https://api.infrai.cc";
const QUEUE = "order-followups";
const VISIBILITY_MS = 900_000;
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const H = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

const res = await fetch(`${BASE}/v1/queue/consume`, {
  method: "POST",
  headers: H,
  body: JSON.stringify({ queue: QUEUE, max_messages: 5 }),
});
const out = await res.json();
if (!out.ok) throw new Error(`consume: ${out.error.code} ${out.error.message}`);

for (const msg of out.data.items) {
  const job = msg.payload;
  if (job.not_before && Date.parse(job.not_before) > Date.now()) continue;   // let it reappear later

  const started = Date.now();
  try {
    await runStep(job);
  } catch (err) {
    console.error(`step ${job.step} failed on delivery ${msg.delivery_count}: ${err.message}`);
    continue;
  }
  const elapsed = Date.now() - started;
  if (elapsed > VISIBILITY_MS * 0.8) console.warn(`job ${job.order_id} used ${elapsed}ms of a ${VISIBILITY_MS}ms lease`);

  await fetch(`${BASE}/v1/queue/ack`, {
    method: "POST",
    headers: H,
    body: JSON.stringify({ queue: QUEUE, receipt_handle: msg.message_id }),
  });
}

async function runStep(job) {
  console.log(`running ${job.step} for ${job.order_id}`);
}

There’s no call to extend a lease mid-flight, so a job whose duration you can’t predict — a video encode, a partner import — should ack quickly and track its own progress in your database rather than holding the message hostage. That’s a real limitation, and it’s the reason Temporal exists for workflows measured in hours.

The 256 KB ceiling, and a misleading error

Messages cap at 256 KB. Go over it and the API returns a 400 whose text reads queue '...' already exists, which has nothing to do with the actual problem — we hit it at 265,000 bytes while a 261,000-byte message went through. Put the blob in object storage and pass the key in the message, which is the right design at any size.

curl -sS "https://api.infrai.cc/v1/queue/stats/order-followups" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

What it costs, and when to buy something else

Publish is the only metered call in this loop, at $0.00002 per message (verified 2026-07-26); consume, ack, stats and push subscriptions are free but rate-limited. New accounts get $2 free. Prices here trend downward, so check rather than trust:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id == "queue.publish") | .billing'

QStash is a good answer if a scheduled HTTP call is genuinely all you need — it’s built precisely for “POST this URL later” and has nothing else to configure. Amazon SQS is the right call when you’re already deep in AWS and want the delay semantics baked into the queue itself. The reason to keep this on Infrai is that the follow-up work — the email the job sends, the object it writes, the error it reports, the per-tenant cost line — is on the same key and the same invoice instead of four more accounts.

References

Browse more queue developer guides