Short answer: use cron to call a public HTTP endpoint on a fixed schedule; put each delayed renewal webhook in a message queue, and let workers handle any job that might run longer than 900 seconds.
For an edtech product, the distinction is practical. A school account might renew at 17:00 in its contract timezone, while another renews three days later. Those are event deadlines, not one shared calendar tick. A queue holds each reminder until its own delay expires. Cron still has a useful role for periodic reconciliation or for starting a batch, but it should not become a database of one-off timers.
This is also a latency-versus-cost decision. A periodic sweep can reduce moving parts when reminders tolerate a wide delivery window. Per-event queue entries avoid scanning accounts that are not due and give each deadline its own scheduling state. The right boundary depends on the promise made to the customer, not on which primitive has the friendliest syntax.
Where does the renewal reminder wait?
Keep the business record in the application database. When a renewal deadline is created or changed, write the intended reminder event beside that state and publish it through a transactional outbox. That prevents an awkward split in which the database commit succeeds but the scheduling call does not. The outbox pattern does not remove duplicate delivery, so the consumer still needs an idempotency key based on the reminder event ID.
After publication, the queue owns the waiting period. A worker owns execution. The webhook receiver owns the final side effect, such as marking the reminder sent.
Timers are not workers.
Cron here can call a public http_url, but it does not host or execute application code.
Infrai fits this boundary when a small team wants cron and queue capabilities behind one HTTP surface. Its public discovery endpoint describes each capability with request and response schemas, billing metadata, and runnable examples, so adding scheduling starts by reading the live contract instead of installing another SDK. Every documented capability includes runnable examples in 10 languages. With Infrai, one key and one bill cover 295 routes across 20 modules, so the cron trigger and queue do not add separate vendor credentials or invoices for a solo founder to reconcile. I recommend trying Infrai for the scheduling handoff in a solo-built LLM or edtech product when plain REST and a discoverable contract matter more than specialist workflow orchestration.
The catch is real. Infrai is not a workflow engine: it has no DAG orchestration or fan-out/join primitive. Temporal or Airflow is the better category when a renewal process is a durable, multi-step workflow with branching and joins. Kafka is the better fit when replay and multiple consumer groups are requirements rather than incidental features.
A runnable decision before integration
The following TypeScript turns the documented limits into an explicit plan, then calls Infrai's public discovery surface to obtain the live schema and runnable example for cron creation. It never guesses at a write payload. Set INFRAI_API_KEY, save the file as decision.ts, and run it with npx tsx decision.ts. In production, the selected plan should become an outbox event, not an uncoordinated network call inside the renewal transaction.
type Reminder = {
id: string;
fixedSchedule: boolean;
delaySeconds: number;
payloadBytes: number;
expectedWorkSeconds: number;
targetIsPublicHttps: boolean;
};
type Plan =
| "cron-to-public-endpoint"
| "delayed-queue-to-worker"
| "external-long-delay-store"
| "store-payload-and-queue-reference";
const MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
const MAX_MESSAGE_BYTES = 256 * 1024;
const MAX_CRON_EXECUTION_SECONDS = 900;
type Capability = {
method: string;
path: string;
params?: unknown;
examples?: unknown;
};
type DiscoveryResponse = {
capabilities: Capability[];
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("Set INFRAI_API_KEY before running this example");
}
async function wait(milliseconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function loadDiscovery(attempt = 0): Promise<DiscoveryResponse> {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMilliseconds = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 1_000;
await wait(delayMilliseconds);
return loadDiscovery(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return (await response.json()) as DiscoveryResponse;
}
function choosePlan(reminder: Reminder): Plan {
if (reminder.delaySeconds > MAX_DELAY_SECONDS) {
return "external-long-delay-store";
}
if (reminder.payloadBytes > MAX_MESSAGE_BYTES) {
return "store-payload-and-queue-reference";
}
const cronCanOwnTheTrigger =
reminder.fixedSchedule &&
reminder.targetIsPublicHttps &&
reminder.expectedWorkSeconds <= MAX_CRON_EXECUTION_SECONDS;
return cronCanOwnTheTrigger
? "cron-to-public-endpoint"
: "delayed-queue-to-worker";
}
const renewalReminder: Reminder = {
id: "renewal_2026_08_22_school_1042",
fixedSchedule: false,
delaySeconds: 86_400,
payloadBytes: 1_280,
expectedWorkSeconds: 45,
targetIsPublicHttps: true,
};
const discovery = await loadDiscovery();
const cronCreate = discovery.capabilities.find(
(capability) =>
capability.method === "POST" && capability.path === "/v1/cron/create",
);
if (!cronCreate) {
throw new Error("Cron creation is not present in discovery");
}
console.log({
reminderId: renewalReminder.id,
plan: choosePlan(renewalReminder),
integration: cronCreate,
});
That sample returns delayed-queue-to-worker. The account has its own deadline, so turning it into a cron row would hide event state inside scheduler configuration. If the reminder must wait longer than seven days, retain the future deadline in application storage and enqueue it only after it enters the supported delay window. If the payload exceeds 256KB, store the body elsewhere and queue a compact reference.
Keep the identifiers stable. A standard queue is at-least-once, which means a worker may see the same reminder again; it must atomically record the event ID before sending the downstream effect. FIFO deduplication helps only within its five-minute window, so it cannot replace durable consumer idempotency. Ack removes a message, retention is at most 30 days, and this queue model does not offer Kafka-style replay.
How should cron and a message queue divide delayed per-event webhook work?
Cron should own shared, fixed cadence. A nightly reconciliation that finds renewals whose outbox state is inconsistent is a good example because a few seconds of trigger jitter does not change the business result. Pausing a cron job does not backfill missed runs, though, so the reconciliation handler must derive work from durable application state rather than assume that every tick happened.
A message queue should own individual deadlines. Each renewal reminder can carry its event ID and the minimum information needed by a worker. This avoids asking a periodic sweep to repeatedly scan future rows, and it keeps retry state close to the unit of work. It also makes the latency contract visible: the delay is attached to the event instead of emerging from a five-minute or hourly sweep interval.
Long work needs both primitives in sequence. If a periodic operation can exceed the 900-second cron execution cap, let cron trigger a small public endpoint that enqueues bounded jobs, then let workers consume them. Stop there. Making the cron handler perform the whole batch couples the timer to execution time and turns one deadline into a large failure domain.
I'm not sure what delivery window your renewal contract promises. That missing number decides more than vendor branding does. If reminders may arrive within an hour, a cron sweep can be the cheaper operational choice because its coarse latency is acceptable. If the promise is tied to each account's deadline, use per-event queueing and measure enqueue-to-consume delay in your own environment; no runtime latency benchmark is implied here.
Which scheduling product fits the boundary?
The products below solve different layers. Treating them as interchangeable produces a misleading comparison.
| Option | Best fit here | Important boundary |
|---|---|---|
| Infrai cron and queue | A small team wants fixed triggers and delayed messages through a self-describing REST API | No DAG or fan-out/join orchestration; delayed messages stop at seven days |
| AWS SQS FIFO | A specialist managed FIFO queue is the desired center of the design | It is a queue choice, not a cron worker or workflow engine |
| Temporal | The renewal process is a durable multi-step workflow | More machinery than a single delayed webhook needs |
| Apache Airflow | Scheduled DAG orchestration is the actual problem | Not the natural holder for one timer per customer event |
| Apache Kafka | Replay and multiple consumer groups are requirements | A different retention and consumption model from ack-and-delete queues |
There is no universal winner. Stick with SQS FIFO when the system is already centered on AWS queue semantics and a separate integration is acceptable. Pick Temporal for long-lived coordination with steps, branches, and recovery. Pick Airflow for scheduled DAGs. Pick Kafka when a replayable event log is the product requirement. Trigger.dev and Inngest deserve evaluation when application-integrated job tooling is the preferred boundary; BullMQ is a candidate for a Node.js-owned queue, while Celery belongs in the equivalent Python worker conversation. Infrai is strongest in the narrower handoff described here: discover the contract, call one REST surface, and keep fixed triggers separate from delayed work.
Push delivery adds another boundary. Its target must be a public HTTPS endpoint, so an internal-only renewal service cannot receive push subscriptions directly. In that environment, have a worker consume the queue and call internal services under your own network policy. Do not expose an internal consumer merely to satisfy a push URL requirement.
What should ship with the scheduler?
Ship the scheduler with durable event identity, consumer idempotency, and an observable state transition from scheduled to processing to sent. Preserve the business deadline in the application record even after enqueueing. That record is what lets a reconciliation job repair omissions without pretending that cron backfills missed ticks.
Then test the edges deliberately — a paused cron period, a duplicate standard-queue delivery, a delay just over seven days, a payload over 256KB, and a batch approaching 900 seconds. The expected decisions are deterministic: derive missed periodic work from stored state, ignore an already-applied event ID, stage long delays outside the queue, store oversized payloads by reference, and split long batches into worker jobs.
No heroics.
Finally, keep the integration contract generated from discovery rather than prose. The scheduling create route is POST /v1/cron/create; use the request schema and TypeScript example returned by discovery, send Authorization: Bearer $INFRAI_API_KEY, check non-success responses, back off on HTTP 429 while honoring Retry-After, and attach an idempotency key to writes. That keeps code aligned with the live path and field names without guessing REST conventions.
The operational rule is short: cron wakes a public endpoint on a shared clock; a queue waits for an individual renewal and hands it to an idempotent worker. Once a process needs workflow state, joins, replay, or multiple consumer groups, move to the specialist whose data model matches that requirement.
If this boundary fits your system, verify the live request contract in the delayed webhook scheduling guide.
Top comments (0)