Reminder scheduling for SaaS: cron, queue, or a notifications API
Four jobs any reminder system has to do, and which of cron, a message queue or a dedicated notifications platform does each one. With the Node fan-out on Infrai's queue.
Most teams asking for a “reminder scheduling API” want one product to do four separate jobs, and no single product does all four well. Timing, fan-out, per-channel delivery, and failure handling are different problems with different answers. The shape we’d recommend for a typical SaaS is a due-time table for timing, a message queue for fan-out and retries, and whatever email or SMS provider you already pay for at the end — and on Infrai those middle and last pieces sit behind one key.
A dedicated notifications platform earns its money elsewhere: preference centres, digests, in-app inboxes, per-user quiet hours. Buy one when you need those. Not before.
The four jobs, and who does them
| Timing | Fan-out to N users | Channel delivery | Retry + failure lane | |
|---|---|---|---|---|
| Cron script alone | yes | in-process, all-or-nothing | your provider SDK | you write it |
| Cron + message queue | yes | one message per user | your provider SDK | built in, DLQ after 3 tries |
| Notifications platform (SuprSend, Courier) | yes | yes | yes, multi-channel | yes, plus preference logic |
| BullMQ or Sidekiq in-process | yes, native delays | yes | your provider SDK | built in, needs Redis |
Read the second row as the default. The third is an upgrade you buy when the product asks for it, usually the first time somebody in support has to explain why a customer got four emails about the same overdue invoice in a morning, and the honest answer is that nothing in the system knows what else that customer was sent. Twilio’s own appointment-reminder tutorial is the first row and it’s honest about that — a single Node process, a scheduler library, and one send per appointment. It’s fine at a hundred reminders a day and it’s the thing that breaks at ten thousand.
Where cron alone stops
A cron job that queries due reminders and sends them inline has one failure mode that never gets better: partial completion. The run dies at recipient 700 of 3,000 and nothing records which 700 succeeded, so re-running double-sends or under-sends depending on which mistake you prefer.
A bigger cron box fixes nothing.
What fixes it is narrowing the scheduler’s responsibility to “find what’s due and publish it”, which takes a few milliseconds per recipient and is safe to retry from the top. Every real decision then happens per message, where a failure affects exactly one user.
Fan-out is the queue’s actual job
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"saas-reminders","body":{"user_id":"u_7781","channel":"sms","template":"invoice_due","locale":"en-GB"}}'
{
"ok": true,
"data": {
"message_id": "qmsg_754EVKiZ71fwLMWPHmGMPse6",
"queue": "saas-reminders",
"payload": { "user_id": "u_7781", "channel": "sms", "template": "invoice_due", "locale": "en-GB" },
"status": "available",
"delivery_count": 0,
"published_at": "2026-07-26T00:56:10.127904Z"
}
}
The queue is created by that first publish, along with a saas-reminders.dlq companion, so there’s no provisioning step to automate. Publishing accepts an optional delay_seconds up to 604800 if you want the message itself to carry the timing, and an idempotency_key that returns the same message_id instead of a second copy when your scheduler retries. The batch variant, queue.publish_batch, moves many at once — but it does not apply that idempotency check per entry, so a retried batch really does duplicate. Loop single publishes when exactly-once matters more than throughput.
import process from "node:process";
import pg from "pg";
const API = "https://api.infrai.cc";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
export async function publishDue() {
const { rows } = await pool.query(
`SELECT id, user_id, channel, template, locale FROM reminder_queue
WHERE state = 'due' AND fire_at <= now() ORDER BY fire_at LIMIT 3000`,
);
let ok = 0;
for (const r of rows) {
const res = await fetch(`${API}/v1/queue/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
queue: "saas-reminders",
body: { reminder_id: r.id, user_id: r.user_id, channel: r.channel, template: r.template, locale: r.locale },
}),
});
const out = await res.json();
if (!out.ok) { console.error(`reminder ${r.id}: ${out.error.code}`); continue; }
await pool.query("UPDATE reminder_queue SET state = 'queued' WHERE id = $1", [r.id]);
ok += 1;
}
console.log(`published ${ok}/${rows.length} due reminders`);
return ok;
}
One worker, three channels
The consumer is where the channel decision lives, and keeping it there means adding push next quarter is a new branch rather than a new pipeline.
import process from "node:process";
const API = "https://api.infrai.cc";
const headers = {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
};
const QUEUE = "saas-reminders";
async function call(path, payload) {
const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const out = await res.json();
if (!out.ok) throw new Error(`${path}: ${out.error.code} ${out.error.message}`);
return out.data;
}
async function deliver(job) {
const routes = {
email: process.env.EMAIL_WEBHOOK_URL,
sms: process.env.SMS_WEBHOOK_URL,
push: process.env.PUSH_WEBHOOK_URL,
};
const url = routes[job.channel];
if (!url) throw new Error(`unknown channel ${job.channel}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
signal: AbortSignal.timeout(15_000),
});
if (res.status === 429) throw new Error("rate limited by channel provider");
if (!res.ok) throw new Error(`channel returned ${res.status}`);
}
export async function work() {
const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
for (const msg of items) {
try {
await deliver(msg.payload);
await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
} catch (err) {
console.error(`delivery ${msg.delivery_count} of ${msg.message_id}: ${err.message}`);
await call("/v1/queue/nack", { queue: QUEUE, message_id: msg.message_id });
}
}
return items.length;
}
Note the asymmetry that catches everyone once: ack wants the handle under receipt_handle, nack insists on message_id and rejects receipt_handle, and the consume response only ever gives you message_id. After three failed deliveries the message lands in the dead-letter queue, which you read by consuming saas-reminders.dlq by name — the dedicated DLQ listing route returned an empty array in our testing even with a non-zero dlq_count.
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"saas-reminders.dlq","max_messages":10}'
The channel is the expensive part
Moving a reminder through the queue costs $0.00002 per message, verified 2026-07-26; consume, ack, nack and stats are free within rate limits. At 50,000 reminders a month that’s $1 of queueing against whatever your SMS provider charges for 50,000 segments, which will be three orders of magnitude more. New accounts start with $2 in credit, and rates have moved down over time, so check rather than quote:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/queue/stats/saas-reminders" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Because the queueing is nearly free, the interesting question is what the delivery hangs off. Transactional email and SMS live behind the same credential as this queue, which means the reminder pipeline, the send, and the per-tenant cost attribution are one account and one invoice rather than three vendors and a spreadsheet.
Where we’d point you elsewhere
If your requirement list has the words “preference centre”, “digest” or “quiet hours” on it, a notifications platform is the right purchase and the queue below it becomes an implementation detail you don’t need to own. If the whole app is one Node process with Redis already attached, BullMQ gives you delays, repeatable jobs and retries without a second account — worth flagging that its delayed set has no seven-day ceiling, which this queue does.
And if reminders are one step inside a longer stateful process — with compensation paths, human approvals and a history you can replay months later to explain why a particular customer was contacted on a particular Tuesday — neither a queue nor a scheduler is the right abstraction. A workflow engine is.