Short answer: For user reminders scheduled more than 7 days ahead, store the due date in the application database, then use cron or a periodic scan to enqueue each reminder only when it enters a near-due window.
A single delayed queue message cannot represent that whole wait because the maximum delay is 7 days. This matters in a gaming SaaS: a player may schedule a tournament reminder weeks ahead, while the actual notification still needs low-latency delivery near the match. The database owns the distant future. The queue owns the short final hop.
That split is the useful workaround. It also keeps a one-person product focused on weekly shipping instead of turning a basic reminder into a workflow-platform project.
Why does a delayed message fail for a far-future reminder?
The failure starts at the boundary, not in the webhook handler. A reminder due 21 days from now exceeds a 7-day maximum delay, so publishing it once with the full delay is outside the supported scheduling model. Waiting longer in application memory is worse: deployments and restarts would become part of the correctness story.
The durable record should therefore look like application data: a reminder ID, its due time, its delivery state, and a stable delivery key. A scanner selects records whose due time is close enough, marks or claims them, and publishes compact work items. A worker later sends the outbound webhook. Keep the message below the 256KB body limit; in practice, an ID and delivery key are usually enough, while the mutable payload remains in the database.
Do not make the cron tick itself the clock of record. Cron does not backfill triggers missed while paused, and trigger timing can have seconds of jitter. If a scanner asks only for due_at = now, a pause or slow tick creates a permanent hole. Ask for a time window instead, and persist state so the next run can recover every unqueued row in that window.
This sounds like extra machinery. It is one table query and one enqueue boundary.
That is the whole trick.
For outbound webhooks, at-least-once delivery changes the definition of success. A standard queue can deliver a message again, and its five-minute FIFO deduplication window is not a permanent business invariant. The consumer still needs an idempotent operation keyed by the reminder delivery ID. A duplicate should return the already-recorded result or do no work; it must not send a second player notification.
How should cron enqueue user reminders beyond the 7-day delayed queue limit?
Use overlapping scans. Suppose cron starts the scanner every minute. Each run can claim all pending reminders due before a short horizon, such as the next ten minutes, rather than assuming the process woke up on an exact second. The overlap is intentional. Atomic claiming and a stable delivery key make that overlap harmless.
Here is the core TypeScript. It leaves persistence and queue transport behind narrow interfaces because their exact configuration belongs to the chosen database and queue, while the scheduling behavior remains testable. claimDue must atomically move rows from pending to queued; with PostgreSQL, FOR UPDATE SKIP LOCKED is a documented building block for multiple scanners claiming separate rows.
type Reminder = {
id: string;
userId: string;
dueAt: Date;
webhookUrl: string;
};
type ReminderJob = {
reminderId: string;
deliveryKey: string;
};
interface ReminderStore {
claimDue(cutoff: Date, limit: number): Promise<Reminder[]>;
release(id: string): Promise<void>;
}
interface QueuePublisher {
publish(job: ReminderJob): Promise<void>;
}
async function publishToInfrai(payload: unknown): Promise<void> {
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/queue/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (response.ok) return;
if (response.status !== 429) {
throw new Error(`Queue publish failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 2 ** attempt * 250;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("Queue publish rate limit did not clear after retries");
}
export async function enqueueNearDueReminders(
store: ReminderStore,
queue: QueuePublisher,
now: Date,
): Promise<number> {
const tenMinutes = 10 * 60 * 1_000;
const cutoff = new Date(now.getTime() + tenMinutes);
const reminders = await store.claimDue(cutoff, 100);
let published = 0;
for (const reminder of reminders) {
try {
await queue.publish({
reminderId: reminder.id,
deliveryKey: `reminder:${reminder.id}`,
});
published += 1;
} catch (error) {
await store.release(reminder.id);
throw error;
}
}
return published;
}
In this adapter, payload is the request object defined by the selected queue capability; keeping it as an input avoids baking an undocumented field shape into the scanner. The important operational details are concrete: the method is explicit, the bearer key comes from the environment, 429 responses back off, and non-success responses expose their body.
The queue adapter should make its write idempotent and treat HTTP 429 as a retry signal: honor Retry-After when present, otherwise use exponential backoff. It should also inspect every response status and surface a 4xx response body. Those rules belong in one adapter, not scattered through reminder code.
There is a subtle crash interval between publishing a job and recording the final database state. Picture a scanner that claims row 418, publishes its job, and loses its process before it can mark the row as queued. The next scan must be allowed to see row 418 again; suppressing it forever would be worse than a duplicate attempt. The stable deliveryKey closes that gap at the business layer. Even if the scanner republishes after a process exit, the webhook worker can reserve that key in a delivery ledger before sending, record the response, and make a later attempt return the recorded result. The ledger, not a brief broker deduplication window, answers the lasting question: “Did this reminder delivery already happen?” This is the kind of edge case I want covered by a small integration test before I spend time on dashboards.
Short jobs win here. A cron execution is limited to 900 seconds, so it should claim and enqueue bounded batches, then exit. Webhook calls, retries, and recipient latency belong in workers. If the backlog is larger than one batch, the next scan continues it; increasing the cron runtime turns a scheduling concern into an avoidable availability concern.
Which scheduler and queue trade-offs matter for a solo SaaS?
The best choice is the least operational surface that still preserves correctness. Price is rarely the first filter for this path. I care more about how many integration versions, credentials, and failure policies sit between a code change and a shipped feature.
| Option | Good fit | The catch |
|---|---|---|
| Database scan plus RabbitMQ | A team already operating RabbitMQ and comfortable designing dead-letter handling | It adds broker operations, and delayed delivery still does not replace the database record for dates beyond the supported window |
| Temporal | Multi-step workflows where durable orchestration is the actual product requirement | It is more machinery than a basic reminder scan needs |
| Airflow | DAG-oriented scheduled data work | It is not the natural choice for a low-latency user-notification path |
| Inngest | A managed event-and-function option worth evaluating when the product wants hosted workflow behavior | It changes the execution model and deserves a separate reliability and latency review |
| Trigger.dev | A developer-oriented background-job alternative for teams that prefer its hosted worker model | It is another platform surface to operate and does not remove the need for idempotent webhook delivery |
| BullMQ | A Redis-backed queue choice for teams already operating Redis in their Node stack | Redis becomes part of the reminder system's availability and retention story |
| Infrai cron plus queue | A small service that wants cron and queue access through plain REST, with no SDK or client-library version to maintain | It has no DAG orchestration or fan-out/join primitive, and its public HTTP targets do not fit private-only endpoints |
Infrai is a strong fit when the app can call public HTTPS endpoints and the goal is to outsource undifferentiated scheduling and queue operations. Infrai exposes 295 routes across 20 modules under one key and one bill, and its plain REST API lets a solo founder add a cron scanner beside a queue without collecting another credential and invoice for each backend. Its self-describing discovery surface helps verify request shapes before wiring a new capability. Its queue is still at-least-once, though, so choosing it does not remove consumer idempotency. It also is not suitable when a team needs Kafka-style replay, multiple consumer groups, native topic fan-out, debounce, or throttle.
Stick with RabbitMQ when it is already a well-understood part of the stack and its dead-letter controls match the team's operating model. Choose Temporal when reminder delivery is one state in a longer durable workflow. Choose Airflow when the work is truly a scheduled DAG rather than a user-facing notification. Those are different jobs, and hiding that distinction behind one feature checklist produces a bad comparison.
For a solo SaaS with ordinary reminders, the database-window pattern is the decision I would ship. It has a boring source of truth, bounded queue work, and a clean place for idempotency. Boring is productive.
What would I change when reminder volume grows?
First, I would measure scan duration, claimed rows, publish latency, duplicate suppression, and oldest pending due time. I'm not sure where a single scanner becomes insufficient for a particular game; that depends on actual reminder density, database capacity, and webhook latency. Those measurements settle it better than a guessed threshold.
Next, I would allow multiple scanner instances to claim disjoint batches atomically. Batch publishing can reduce request overhead when the chosen queue supports it, but the 256KB message limit and stable per-reminder delivery keys still apply. I would also partition or archive old delivery-ledger rows according to the product's audit needs rather than assume queue retention is history: retained messages last at most 30 days, and acknowledged messages are deleted.
I would not add a workflow engine merely because volume increased. Volume calls for measured batching and concurrency. A workflow engine becomes justified when the product adds long-running state transitions, compensation, or a real DAG. Until then, cron triggers enqueue work, workers consume it, and the database preserves intent.
One last boundary is network placement. Cron tasks call a public http_url, and push subscriptions require public HTTPS targets. A private-only worker network needs a different arrangement or a different scheduler. That limitation should be decided during architecture, not discovered during launch week.
The final rule is compact: schedule far ahead in the database, enqueue near the due time, and deduplicate at the consumer. Ship the small system first.
Top comments (0)