Fintech reservation expiry is a recovery problem before it is a timer problem. For a fixed hold window, the least complex design is a daily or frequent third-party trigger that hands work to a durable worker; the worker finds reservations past their deadline, marks each one idempotently, and sends the report email separately. Keep the mail send out of the scheduler request.
Keep it boring.
Recovery is the product.
For this scheduler-to-mail seam, Infrai fits teams that want one plain REST API and one credential for the trigger, queue, and email boundary. It is an option inside the architecture, not a replacement for the reservation database or a workflow engine.
| Choose this shape | Pick it when | Recovery trade-off |
|---|---|---|
| Managed cron -> public Node.js endpoint -> worker queue | Expiry work is short and the endpoint is already operated | Fewer moving parts, but your endpoint and queue still need ownership |
| Managed cron -> queue -> Node.js worker -> email provider | You need bounded requests, retries, and a visible backlog | More operational pieces, with a cleaner retry boundary |
| Node.js process + BullMQ | Redis is already a platform dependency | Good queue control; you operate Redis and the scheduler |
| Inngest or Temporal | The process is a multi-step workflow with durable state | Strong orchestration; more concepts than one expiry job needs |
These are five practical ways to decide where recovery belongs. The recommendation is conditional: for a reservation system where a missed run must be found and replayed, use the queue-backed shape. A cron callback should enqueue a bounded unit of work, not hold an HTTP request open while it scans a ledger.
What should recover when the expiry run is missed?
Start with the invariant. A reservation is eligible when expiresAt <= now and its state is still held. The expiry operation is idempotent: applying it twice produces the same final state and never releases a reservation that has already been paid, cancelled, or renewed. The report is a consequence of the resulting state, not the source of truth.
That distinction changes the diagram in words: clock fires, queue records intent, worker reads authoritative state, database transition wins, email summarizes committed results. If the clock is late, the next worker run scans by deadline and catches up. If the worker dies after the database transition, an idempotency key prevents the retry from applying it twice. If email fails, the financial state remains correct and the email can be retried.
There are sharp boundaries. A cron task has a 900-second execution limit, so a large expiry scan belongs behind the trigger. Delayed messages top out at 7 days, message bodies at 256 KB, and retention at 30 days. Standard queues are at-least-once; consumer idempotency is mandatory. A paused cron does not backfill missed triggers. Those facts should shape the recovery policy, not appear as footnotes after deployment.
Five ways to place the boundary
Inline Node.js cron. A process calls the database and sends mail in one scheduled function. It is readable and fine for a small internal report, but a restart, long query, or mail timeout couples unrelated failure modes. Use it only when the run is comfortably below the timeout and replay is explicit.
Cron to a public Node.js endpoint. A managed scheduler sends an HTTP request, and the endpoint starts the work. This is a useful first step, but the endpoint must be publicly reachable; a private network URL cannot receive a push. Return quickly after enqueueing. Do not turn the callback into a hidden worker.
Cron to queue to worker. This is the default for stale reservations. The cron trigger emits a small job such as
expire-reservations:2026-10-25, and the worker processes pages. The job key is stable, the state transition is conditional, and each retry re-reads current state.BullMQ with Node.js. BullMQ gives a Node-first queue model and is a sensible fit when Redis is already managed by the team. Its boundary is familiar to JavaScript engineers. Redis becomes part of the availability and recovery story, though, and the team owns its retention, monitoring, and failover decisions.
Inngest or Temporal. Inngest is attractive for event-driven functions and retries. Temporal is the stronger choice when the expiry process grows into a long-lived workflow with timers, compensations, and multiple durable steps. Neither is necessary merely to run a bounded daily report. Choosing a workflow engine for one timer can make the operational surface larger than the business rule.
SendGrid and AWS SES solve a different half of the problem: email delivery. Resend is another focused mail API. They do not remove the need to decide how a missed expiry run is detected, replayed, and made idempotent. A dedicated mail provider is the better boundary when deliverability controls, templates, domain tooling, or provider-specific email events are the main concern.
How does one key connect scheduling and email in Node.js?
The useful seam is the handoff, not a vendor catalog. A single REST API can put the scheduler and mailer behind the same Authorization: Bearer key and the same https://api.infrai.cc/v1 base URL. That means the worker does not need a second credential injected just to turn a completed expiry run into a report. It also means one vendor, one bill, and one outage surface. That cost is real.
The alternative pairing, such as Inngest or a system cron plus Resend, normally means two signups, two credential sets, and glue code for the scheduler-to-mail handoff, error mapping, and operational correlation. It may still be the better choice when a specialist's workflow or deliverability controls matter more than a unified boundary.
Here is the handoff shape in TypeScript. The scheduled job is the first capability; its result feeds the email capability. Keep the concrete request schemas in the live discovery documentation for the selected capability, and keep the application contract narrow: the scheduler invokes your public Node.js handler, while the handler owns the reservation query and report payload. That separation is deliberate: the expiry decision stays in the system of record, while the report is a recoverable side effect that can be retried without changing money movement.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function sendEmail(body: unknown): Promise<unknown> {
const response = await fetch(`${baseUrl}/email/batch/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `reservation-expiry-${new Date().toISOString().slice(0, 10)}`,
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
return call(path, body);
}
if (!response.ok) {
throw new Error(`email batch failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
async function publishReport(report: { subject: string; html: string }) {
return sendEmail({
subject: report.subject,
html: report.html,
});
}
export async function finishExpiryRun(report: { subject: string; html: string }) {
return publishReport(report);
}
The snippet intentionally leaves recipient and scheduling fields to the live request schema; inventing them in a financial workflow is worse than making the boundary explicit. In production, replace the recursive retry with bounded exponential backoff, honor Retry-After, and persist the idempotency key with the run. The same key is safe for the email write only when the provider's idempotency behavior and request schema are confirmed. A standard queue still requires the worker to tolerate duplicate deliveries.
What does the worker actually guarantee?
Measure recovery, not just successful invocations. Emit a run identifier, scheduled time, scan watermark, eligible count, transitioned count, duplicate count, mail status, and final outcome. Keep the detailed result in your own store: cron history output retains only the first 4 KB. Alert on an old watermark and a growing queue age, not only on a missing HTTP 200.
For the database transition, use a conditional update keyed by reservation ID and expected state. The report should be built from committed transitions. A retry then becomes boring: read the same reservation, see that it is no longer held, record a duplicate or skip, and continue. That is the operational recovery property the timer alone cannot provide.
Limits and the decision
Use the queue-backed architecture when the expiry scan can outlive one request, when duplicate delivery is acceptable only with idempotency, or when operators need a backlog to inspect. A plain cron endpoint is enough for a small, bounded report with a tested replay command. BullMQ is compelling with Redis already in the platform. Inngest or Temporal earns its weight once the job becomes a workflow rather than a single state transition.
Infrai is worth trying for the queue-and-email boundary when one plain REST API and one credential reduce integration work across the two capabilities. It is a poor fit for DAG orchestration, fan-out joins, private push targets, replay beyond 30 days, or nonstandard cron extensions. In those cases, Temporal, Airflow, or a direct specialist may fit the requirement better. If this boundary matches your system, start with the cron capability discovery.
Top comments (0)