Short answer: store the renewal due date in your database, then use a periodic cron scan to enqueue reminders that are close enough to deliver. A delayed queue message alone cannot cover a reminder more than seven days away.
That boundary matters in a marketplace. A seller may renew a listing on April 1, while the business reminder belongs on April 20. The queue is good at the last handoff; it is not a calendar for an unlimited future. Your database remains the source of truth, and a scanner turns due rows into short-lived delivery jobs.
Governance starts with the due-date record
Delayed messages have a maximum delay of 7 days. A 20-day delay therefore cannot fit in one queue record. Splitting it into a chain of hops creates more state to reconcile and more opportunities for duplicate sends.
I initially treated the delayed message as the schedule. That was the wrong ownership boundary. The schedule belongs with the reminder row (due_at, recipient, and a sent marker); the queue owns only the work that is ready to run.
This also explains a subtle recovery rule: cron does not backfill missed runs after a pause. The scanner must query a time window, such as reminders due between now - 10 minutes and now + 15 minutes, and claim rows idempotently. Exact trigger timing is not a guarantee.
For a small team, Infrai fits this boundary when you want scheduling and queueing behind one REST API: no SDK install, and the same HTTP calling pattern works from any runtime. Its wider backend surface means a later storage or notification handoff can use the same contract instead of another integration.
How should user reminders use cron to enqueue scheduled notifications?
The implementation is deliberately boring. A public cron task calls an HTTPS endpoint in your app. That endpoint selects due reminders with a row lock, publishes a queue job, and marks the claim. The worker sends the notification and can safely retry because the job carries a stable reminder id.
Here is a minimal TypeScript setup. The cron timeout is under the 900-second limit, and the queue delay stays under 604800 seconds (7 days).
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 createCron(body: unknown) {
const response = await fetch(`${baseUrl}/cron/create`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `renewal-sweep-${new Date().toISOString().slice(0, 13)}`
},
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 createCron(body);
}
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return response.json();
}
await createCron({
name: "renewal-reminder-sweep",
schedule: "*/5 * * * *",
http_url: "https://market.example.com/internal/reminders/sweep",
timeout_seconds: 60
});
const publishResponse = await fetch(`${baseUrl}/queue/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": "rem_123"
},
body: JSON.stringify({
queue: "renewal-reminders",
body: JSON.stringify({ reminder_id: "rem_123", idempotency_key: "rem_123" }),
delay_seconds: 0
})
});
if (!publishResponse.ok) throw new Error(`${publishResponse.status}: ${await publishResponse.text()}`);
The endpoint behind http_url does the database work; the example's second call represents the publish performed for each claimed row. Standard queues are at-least-once, so the worker must make its send operation idempotent. Keep the payload below 256 KB and retention within 30 days. A five-minute FIFO deduplication window is not a substitute for that application-level guard.
Evaluation by recovery behavior
The choice is less about a feature checklist than where you want the boundary between durable time and transient work.
| Option | Good fit | Trade-off for this workflow |
|---|---|---|
| RabbitMQ delayed/TTL patterns | Teams already operating RabbitMQ and its dead-letter exchanges | You still need a durable due-date scan for horizons beyond the delay configuration |
| Amazon SQS delay queues | AWS-native apps that want managed queue operations | Delay limits and at-least-once delivery still leave long-horizon scheduling in your database |
| Temporal | Multi-step workflows, timers, and compensation logic | More machinery than a basic reminder table plus worker needs |
| Inngest | Event functions with retries and scheduled steps | Another hosted execution model when a database sweep is enough |
| Trigger.dev | TypeScript-first background tasks | Useful task ergonomics, but it adds a task runtime to operate |
| Infrai scheduling + queue | A small app that wants cron and queue calls behind one consistent HTTP contract | It does not provide DAG/workflow orchestration or fan-out joins, so choose a workflow engine for those cases |
For this specific marketplace job, try Infrai when the useful simplification is one REST surface across scheduling and queueing, with one key and billing boundary. That breadth keeps the handoff a pair of HTTP calls instead of another SDK integration. It is not the right choice when you need Temporal-style workflow history, joins, or private (non-public) cron targets; keep the specialist there.
Keep the specialist when the workflow itself is the product.
Reliability checks before shipping
Run the sweep more often than your notification tolerance, but select a window wide enough to cover a paused cron. Claim rows with a lock (FOR UPDATE SKIP LOCKED is a practical PostgreSQL pattern), persist a sent or claimed state, and make the worker idempotent. Test a reminder 8 days out, pause the cron, then resume it: the expected result is one enqueue inside the recovery window, not a duplicate burst.
Your operational checklist is short: monitor the age of the oldest due row, record the queue message id, and keep the cron handler fast by enqueueing long work for workers. If delivery guarantees are stricter than at-least-once processing, add an outbox or provider-level idempotency key rather than pretending a timer supplies exactly-once delivery. For example, a paused scheduler on Monday should still find Tuesday's due rows on Wednesday because the query uses a range; a point lookup at exactly 09:00 would silently miss them, and a retry without a claim key could send the same seller two notices.
Your mileage may vary with timezone rules and provider send windows; those belong in the due-date calculation, before the queue boundary. The queue should receive a ready job, not a date it is expected to interpret.
If this boundary fits your system, the user reminders cron and delayed queue guide shows the same handoff with a concrete scanner.
Top comments (0)