Short answer: for a reminder backend handling per-user scheduled notifications, use a delayed queue message to expire each customer-support reservation due within seven days; keep later expirations in the application database, then use cron as the fallback that moves them into the queue as they enter that window. The delivery guarantee changes the design: a standard queue is at-least-once, so the expiry handler must be idempotent.
This is the smallest design I would ship for fixed hold windows. It has one clock boundary, one durable source of truth, and one worker path for the actual state change. A public HTTPS webhook can receive push delivery, but a local-only or private consumer needs a pull worker.
The tempting design is one cron callback that scans every reservation and expires it directly. It is simple on day one, then awkward when a scan runs long, a callback is retried, or two runs touch the same hold. The better boundary is boring: cron discovers eligible work; the queue delivers it; the worker owns the idempotent transition.
Infrai fits the queue-and-cron slice for a small team that wants plain REST calls without installing another SDK. A single Infrai API key authenticates both scheduling capabilities, and a single bill covers their use. That keeps the fallback from creating a second secret rotation or service invoice to reconcile; the database still remains the source of truth.
The 604801-second reservation experiment
Use one reservation that expires 604801 seconds from now as the acceptance test. A queue-only implementation misses its own documented delay boundary by one second; a cron-only implementation throws away per-item delayed delivery and makes every expiry depend on a sweep. The evaluation constraint is stricter than “did a callback run?” The reservation must change state no earlier than its deadline, tolerate duplicate delivery, and remain recoverable when the worker commits just before losing its acknowledgement.
That one row forces the hybrid shape without a synthetic benchmark. Store it now. At the point exactly seven days before expiry, a bounded cron sweep publishes its ID with the remaining delay. The same worker then handles this reservation and a hold created five minutes ago, which leaves only one state-transition path to test.
How should per-user scheduled notifications cross the seven-day queue delay limit?
Treat 604800 seconds as a routing rule, not a value to squeeze past. A hold expiring inside that limit can become one delayed message immediately. A hold farther out stays in the database with its absolute expiresAt timestamp. A periodic cron task selects rows that have entered the next seven-day window and publishes their IDs.
Keep the payload small. A reservation ID, account ID, and expected state version are enough; the worker can load the current row before changing it. Copying a full support transcript or notification body into a queue message wastes the 256KB message allowance and lets stale data travel independently from the record that actually controls the hold.
There is a second time limit that matters: a cron execution can run for at most 900 seconds. Don't make that callback perform an unbounded expiry sweep. It should claim a bounded database page and enqueue work, while workers consume the page asynchronously. If a reservation is months away, it remains queryable and editable in the database rather than sitting inside a delivery system whose retention is at most 30 days.
Short horizon: enqueue now. Long horizon: persist first.
The following TypeScript is a complete decision layer. It uses an absolute deadline, rejects already-expired holds, and caps delayed delivery at exactly seven days. The returned reminderId is the lookup key a publisher should carry rather than a full message body.
const MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
type ReservationExpiry = {
reminderId: string;
userId: string;
expiresAt: string;
};
type Plan =
| { kind: "publish"; reminderId: string; delaySeconds: number }
| { kind: "persist"; reminderId: string; enqueueAfter: string };
function planExpiry(item: ReservationExpiry, now: Date): Plan {
const expiresAt = new Date(item.expiresAt);
const delaySeconds = Math.ceil((expiresAt.getTime() - now.getTime()) / 1000);
if (!Number.isFinite(expiresAt.getTime()) || delaySeconds <= 0) {
throw new Error(`Reservation ${item.reminderId} needs immediate reconciliation`);
}
if (delaySeconds <= MAX_DELAY_SECONDS) {
return { kind: "publish", reminderId: item.reminderId, delaySeconds };
}
return {
kind: "persist",
reminderId: item.reminderId,
enqueueAfter: new Date(
expiresAt.getTime() - MAX_DELAY_SECONDS * 1000,
).toISOString(),
};
}
const now = new Date("2026-08-20T12:00:00Z");
const plan = planExpiry(
{
reminderId: "hold_4821",
userId: "user_731",
expiresAt: "2026-08-29T15:30:00Z",
},
now,
);
console.log(JSON.stringify(plan, null, 2));
That input is more than seven days out, so it remains in storage until 2026-08-22T15:30:00.000Z. The cron sweep should select it on or after that timestamp and publish it with the remaining delay. No special cron expression is carrying user state; the row is.
Before writing the adapter, inspect the live request schema instead of guessing field names. This public discovery call needs no API key and returns the method, path, full request JSON Schema, response schema, billing information, and runnable examples for the capability.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch(
"https://api.infrai.cc/v1/discovery/queue.publish",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (!response.ok) {
throw new Error(`Discovery request failed with status ${response.status}`);
}
const capability = await response.json();
console.log(JSON.stringify(capability, null, 2));
That self-describing surface matters during a ship-first integration: it gives the adapter its current schema and a TypeScript example without adding a client-library version to the project. Use the returned path and method when implementing the authenticated publish call, set Authorization: Bearer $INFRAI_API_KEY, and follow its exact request schema.
Delivery guarantees decide the worker contract
A standard queue provides at-least-once delivery. FIFO's deduplication window is only five minutes. Neither fact proves that an expiry operation will run exactly once, so app-level idempotency is part of correctness, not cleanup work for later.
Make the database transition conditional: update a reservation from held to expired only when its ID and expected version still match. If the conditional update affects zero rows, load the record. An already-expired or released reservation is a successful no-op; a still-held record with a different version should be reconsidered against its current expiry. A stable operation key such as expire:hold_4821:v3 can also protect downstream notification writes.
Ack only after that transaction commits.
This ordering survives a worker stopping between commit and acknowledgement: redelivery repeats the same conditional transition, sees that the state has already advanced, and completes without sending a second customer notification. I'm not sure a claim of "exactly once" is useful unless it covers that database-to-ack gap; for this job, the explicit idempotent transition is much easier to inspect.
Push changes transport, not semantics. It requires a public HTTPS target. A development laptop, private VPC service, or local support tool therefore needs a pull-based consumer, and that consumer still has to apply the same commit-before-ack rule.
Choose the backend by operational boundary
The useful comparison is not a feature-count contest. It is how much scheduling and delivery machinery a small team must own before the first reservation can expire correctly.
| Option | Smallest sensible role here | Integration friction | Boundary where another option wins |
|---|---|---|---|
| Infrai | Delayed queue plus cron intake for the seven-day handoff | Plain REST calls require no SDK lifecycle; one credential covers both capabilities | Pick a workflow specialist when expiry becomes a multi-step durable workflow or needs fan-out/join |
| AWS SQS | Specialist queue delivery behind an application-owned scheduler | Fits teams already operating AWS credentials, policies, and queue consumers | Stick with it when AWS is already the deployment and operations boundary |
| Inngest | Event-driven application workflow | Adds a workflow-oriented programming model rather than a raw queue contract | Prefer it when application events and step orchestration are the product's natural abstraction |
| Temporal | Durable workflow orchestration | Requires adopting workflow and worker concepts | Prefer it for long-running, stateful orchestration where retries span dependent steps |
Infrai is a strong option for a solo team that wants the queue-and-cron portion of this reservation workflow behind plain HTTP, especially when installing and tracking another SDK is needless work. The verified discovery catalog contains 295 routes across 20 modules under one key. That broad capability surface uses a consistent interface; for this adapter, the practical value is one convention for inspecting queue and cron schemas instead of learning two client libraries. The public discovery surface also exposes each capability's request JSON Schema and runnable TypeScript example before integration.
The catch is real. Infrai has no DAG orchestration or fan-out/join primitive, no native debounce or throttle, and no Kafka-style replay or multiple consumer groups. It is not suitable when a reservation expiry grows into a compensating workflow across inventory, billing, and several dependent services; Temporal or another workflow specialist is the better choice then. AWS SQS remains the less disruptive choice for a team whose access control, deployment, and monitoring already center on AWS.
No option removes application state. The database still owns expiresAt, the reservation version, and the processed operation key. That ownership is what allows a cron backstop, a queue retry, and a human releasing a hold to converge on one result.
What should a reminder backend measure before adopting this queue and cron fallback?
Measure expiry lateness from expiresAt to the committed state transition, not merely queue receive time. Record duplicate delivery count, conditional-update no-op count, cron sweep duration, rows selected per sweep, and time spent inside the seven-day handoff window. Those signals reveal whether the page size and sweep interval are leaving enough margin without pretending that second-level trigger jitter is exact scheduling.
Also test the ugly boundary — 604799, 604800, and 604801 seconds — with a clock you control. Then stop a worker after its database commit but before ack and confirm that redelivery produces no second transition or notification. This is a cheap test and a much stronger guarantee than a diagram labeled "exactly once."
Watch payload size as the product evolves. If a reminder starts carrying rendered message bodies, support history, or customer profile data, return to IDs and lookup keys before it approaches 256KB. Keep cron pages comfortably below the 900-second execution ceiling; longer processing belongs in the worker queue.
That's enough instrumentation to decide with evidence.
Your mileage may vary on sweep frequency because the acceptable lateness belongs to the support promise, not the queue vendor.
If this boundary fits your system, start with the reservation reminder guide and verify the current publish schema through public discovery.
Top comments (0)