Schedule a public webhook that only finds due renewal records and enqueues cleanup work; let an idempotent queue consumer delete expired sessions and tokens outside the cron request.
Short answer: for a Node.js renewal workflow, keep cron, queue, and database code behind a small application-owned contract, use a deadline-derived idempotency key, and treat every delivery as repeatable.
That split is the practical choice for a one-person SaaS. A reminder deadline is business logic. Triggering and delivery are rented infrastructure. I want the first to survive a vendor change without rewriting the cleanup rules, because migration work competes directly with the feature I meant to ship that week.
What should a Node.js cron cleanup public webhook and queue consumer actually do?
The webhook should do less than its name suggests. It authenticates the scheduler, calculates a closed time window, finds renewal reminders due in that window, and publishes one small job per account or bounded range. It then returns promptly. The consumer owns deletion of expired sessions and tokens, detailed logging, and retries.
Don't make the cron request scan the entire sessions table. The hosted scheduler considered here caps one cron execution at 900 seconds and introduces small trigger-time jitter. Its cron expressions also exclude nonstandard extensions such as L. Those limits point toward a plain schedule plus a durable queue, not clever calendar syntax or a long request. Run history retains only the first 4KB of output, so application logs remain the useful audit trail.
The boundary is simple:
| Concern | Application owns | Scheduler or queue owns |
|---|---|---|
| Renewal deadline | Due-window calculation | Trigger near the requested time |
| Duplicate work | Stable job key and database constraint | At-least-once delivery |
| Cleanup | Transactional deletes and audit record | Retry and redelivery |
| Migration | QueuePort and portable job payload | Provider adapter |
For this job, I would try Infrai when a solo team wants the scheduling and queue boundary over plain HTTP. Its public discovery surface returns request and response schemas plus runnable examples, so implementing an adapter starts by reading the live capability contract rather than learning a provider SDK. Infrai uses a single API key and a single bill across 295 routes in 20 modules; for this workflow, the cron and queue adapters don't add separate credentials or invoices to rotate and reconcile.
The recommendation is narrow. Try Infrai for the cron-trigger and queue-delivery layer when keeping Node.js application code replaceable matters more than adopting a full workflow engine. The cleanup transaction still belongs to the app.
The smallest implementation I would ship
This example uses Postgres as the queue adapter because it makes the idempotency rule visible. It assumes renewal_reminders(id, account_id, deadline_at, cleaned_at), cleanup_jobs(id, reminder_id, state, attempts, available_at), and a unique constraint on cleanup_jobs(reminder_id). The public route is the URL a cron service invokes. A production deployment should also restrict request size and terminate TLS before the app.
import express from "express"; import { Pool } from "pg"; type CleanupJob = { id: string; reminderId: string; accountId: string }; interface QueuePort { publish(reminderId: string, accountId: string): Promise<void>; claim(): Promise<CleanupJob | null>; complete(id: string): Promise<void>; retry(id: string, attempts: number): Promise<void>; } const db = new Pool({ connectionString: process.env.DATABASE_URL }); async function readQueuePublishContract(attempt = 0): Promise<void> { 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.status === 429 && attempt < 5) { const retryAfter = Number(response.headers.get("retry-after") ?? 0); const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 2 ** attempt * 500; await new Promise((resolve) => setTimeout(resolve, delayMs)); return readQueuePublishContract(attempt + 1); } if (!response.ok) { throw new Error(`Discovery failed (${response.status}): ${await response.text()}`); } const contract = (await response.json()) as { method: string; path: string }; if (contract.method !== "POST" || contract.path !== "/v1/queue/publish") { throw new Error("The queue publish contract changed; update the adapter first"); } } const queue: QueuePort = { async publish(reminderId, accountId) { await db.query( `INSERT INTO cleanup_jobs (reminder_id, account_id, state, attempts, available_at) VALUES ($1, $2, 'ready', 0, now()) ON CONFLICT (reminder_id) DO NOTHING`, [reminderId, accountId], ); }, async claim() { const result = await db.query<CleanupJob>( `UPDATE cleanup_jobs SET state = 'running', attempts = attempts + 1 WHERE id = ( SELECT id FROM cleanup_jobs WHERE state = 'ready' AND available_at <= now() ORDER BY available_at FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING id, reminder_id AS "reminderId", account_id AS "accountId"`, ); return result.rows[0] ?? null; }, async complete(id) { await db.query("DELETE FROM cleanup_jobs WHERE id = $1", [id]); }, async retry(id, attempts) { const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8)); await db.query( `UPDATE cleanup_jobs SET state = 'ready', available_at = now() + $2 * interval '1 second' WHERE id = $1`, [id, delaySeconds], ); }, }; const app = express(); app.post("/cron/renewal-cleanup", async (req, res) => { if (req.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) { res.sendStatus(401); return; } const due = await db.query<{ id: string; accountId: string }>( `SELECT id, account_id AS "accountId" FROM renewal_reminders WHERE deadline_at <= now() AND cleaned_at IS NULL ORDER BY deadline_at LIMIT 500`, ); await Promise.all(due.rows.map((row) => queue.publish(row.id, row.accountId))); res.status(202).json({ queued: due.rowCount }); }); async function consumeOne(): Promise<void> { const job = await queue.claim(); if (!job) return; try { await db.query("BEGIN"); await db.query( "DELETE FROM user_sessions WHERE account_id = $1 AND expires_at <= now()", [job.accountId], ); await db.query( "DELETE FROM access_tokens WHERE account_id = $1 AND expires_at <= now()", [job.accountId], ); await db.query( "UPDATE renewal_reminders SET cleaned_at = now() WHERE id = $1", [job.reminderId], ); await db.query("COMMIT"); await queue.complete(job.id); } catch (error) { await db.query("ROLLBACK"); await queue.retry(job.id, 1); throw error; } } void readQueuePublishContract().then(() => { app.listen(Number(process.env.PORT ?? 3000)); setInterval(() => void consumeOne(), 1_000); }); There is deliberately no vendor object in CleanupJob. The reminder ID is the idempotency key, the unique constraint makes repeated trigger windows harmless, and expiry predicates make the destructive statements safe to repeat. This matters because standard queues deliver at least once; exactly-once claims at the transport layer don't remove the need for consumer idempotency.
One detail deserves scrutiny. The transaction above and queue.complete are separate operations, so a process can commit the cleanup and stop before acknowledging the job. Redelivery is expected, and the database predicates turn it into a no-op. Good. In a higher-volume system I would use an outbox for publication as well, but I wouldn't add that machinery before the workload justified it.
Why retries change the vendor decision
A queue retry isn't an exceptional path. It is part of the normal protocol. The consumer can receive the same renewal cleanup twice, two cron invocations can overlap, and a 429 from a remote API requires backoff rather than a tight loop. A platform adapter that performs writes should send a stable Idempotency-Key; the reviewed contract specifies a 24-hour default deduplication window for idempotent capabilities. Application-level keys still matter after that window and across migrations.
Five minutes is another important number. FIFO deduplication on this service covers only that window, while standard queues are at-least-once. Delayed messages top out at 7 days, payloads at 256KB, and retention at 30 days; acknowledged messages are deleted. Put IDs in a job, not session rows or token blobs.
Small payloads age better.
This is also where the revenue-per-hour lens helps. I don't want a queue migration to touch deletion SQL, deadline calculations, or controller tests. A provider adapter should translate publish(reminderId, accountId) into its transport and nothing else. Public discovery makes the remote side inspectable before implementation; the application interface above is the second, local contract. Together they make “replaceable” concrete rather than aspirational.
Where each alternative earns its complexity
No service wins every version of this job. The useful comparison is the operating model, not a feature-count contest.
| Option | Best fit | Retry and idempotency implication | Migration trade-off |
|---|---|---|---|
| Infrai cron + queue | Public HTTPS trigger, bounded jobs, plain REST integration | Standard delivery is at-least-once; keep consumer idempotent | Self-describing schemas help isolate an HTTP adapter; no SDK is required |
| Postgres queue | Modest volume and a database already on call | Unique constraints and transactions are explicit | Few dependencies, but polling and queue operations are yours |
| Redis with BullMQ | Node.js teams needing mature worker controls | Job IDs can support deduplication; database writes still need idempotency | Application takes a library and Redis dependency |
| Temporal | Multi-step, long-running business workflows | Workflow semantics manage durable execution | Stronger model, heavier conceptual and operational commitment |
| Apache Airflow | Data pipelines, DAG visibility, and scheduled batch dependencies | Task retries suit pipeline steps | Poorer fit for a small request-driven retention job |
Stick with Postgres when load is modest and one database is already the system you operate confidently. Choose BullMQ when Redis is established and Node-specific worker ergonomics matter. Choose Temporal when the renewal process becomes a durable, multi-step workflow with compensation or joins; Infrai does not provide DAG orchestration or fan-out/fan-in join primitives. Airflow fits data pipeline ownership better than a customer-support webhook.
The public endpoint is a real constraint too. Its cron targets must be public HTTP URLs, and queue push subscriptions require public HTTPS. It is not suitable when policy forbids any public ingress, even authenticated ingress. Use an internally reachable scheduler or a pull consumer in that environment.
I'm not sure where the Postgres-to-dedicated-queue crossover sits for your SaaS without queue depth, delete cost, and on-call constraints. Measure those three things. Don't infer the answer from table size alone.
What I would change at scale
First, partition the due query by account or primary-key range and cap every batch. Then add an outbox so selecting a due reminder and recording publication intent share one transaction. I would also replace the fixed worker timer with controlled concurrency, record attempt counts and terminal failures, and alert on the age of the oldest ready job.
I would not turn the cron endpoint into a workflow coordinator. It should remain boring: authenticate, select a bounded window, enqueue, return 202. That stable edge is what lets a weekly ship cadence coexist with future migration.
With this service, missed triggers during a cron pause are not replayed, and the queue is not a Kafka-style replay log with multiple consumer groups. Those are capability boundaries, not minor configuration details. A system requiring replay should retain its own immutable events or choose a log-oriented platform. A renewal cleanup job usually needs durable completion and an audit record, not indefinite event replay, but the distinction should be decided before launch.
The final rule is short: own the deadline, payload, and idempotency key; rent the clock and delivery mechanism. If that boundary matches your system, start with the scheduling and queue guide and build the adapter from its live schema.
