When a reminder campaign wakes up 50,000 users at once, the hard part is not finding due rows. It is staying inside two different provider limits while guaranteeing that a retry does not send the same reminder twice.
Short answer: use a cron task to batch-publish due reminders, then let separate email and SMS workers enforce provider-specific concurrency and pacing. Treat every delivery as at-least-once and make the consumer idempotent.
That shape keeps the scheduler short-lived and puts the messy timing rules where they belong: application code that can see a provider's response headers. I want to ship weekly, so I outsource the undifferentiated queue plumbing, but I keep delivery policy in code I can test.
The duplicate-delivery test
Cron is a publisher, not a delivery guarantee. It can collect reminders due in the next batch and publish messages, but it should not spend 30 minutes calling an email API. A single cron execution is limited to 900 seconds, and a paused cron does not replay missed triggers. There is also normal second-level jitter, so a timestamp is a target rather than a promise.
The worker owns the promise. It reads a bounded batch, limits in-flight calls, honors Retry-After, and records an idempotency key such as reminder:{userId}:{scheduledAt} before acknowledging the queue message. If the process dies after the provider accepts the request but before ack, the message can arrive again. The second delivery must become a no-op in the worker or in the provider's idempotency layer.
There is no native debounce or throttle control in the scheduling capability. That is a useful boundary, not a surprise: put pacing in the worker, or split traffic across queues when channels need different rules. Email and SMS should not share a semaphore. Their limits, payloads, and retry behavior diverge quickly.
The failure sequence is easy to miss. Imagine the email provider accepts item 1842, the worker loses its network connection, and the queue visibility timeout expires. A second worker receives the same item while the first process is still unwinding. Without a durable send record, both workers can send; with a unique key, the second worker can safely observe that the operation already completed and acknowledge its copy. That record also gives support a useful answer when somebody asks why a reminder arrived twice, while the queue's short run output cannot hold the whole story. This is the part I would spend engineering time on before adding another dashboard, because duplicate user reminders cost trust faster than a delayed batch costs revenue.
How do rate-limited user reminders change Node.js sending?
Here is the smallest pattern I would put behind a cron trigger. The queue calls are shown against the verified scheduling surface; the provider functions are deliberately local adapters because each provider has a different SDK and contract.
type Reminder = {
id: string;
userId: string;
channel: "email" | "sms";
address: string;
body: string;
scheduledAt: string;
};
const limits = {
email: { concurrency: 8, minGapMs: 150 },
sms: { concurrency: 2, minGapMs: 600 },
} as const;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function sendWithBackoff(reminder: Reminder): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await sendToProvider(reminder, {
idempotencyKey: `reminder:${reminder.id}`,
});
if (response.ok) return;
if (response.status !== 429 && response.status < 500) {
throw new Error(`provider rejected ${reminder.id}: ${response.status}`);
}
const retryAfter = response.retryAfterMs ?? 0;
await sleep(Math.max(retryAfter, 250 * 2 ** attempt));
}
throw new Error(`retry budget exhausted for ${reminder.id}`);
}
async function drain(channel: Reminder[], concurrency: number, minGapMs: number) {
let cursor = 0;
async function lane() {
while (cursor < channel.length) {
const reminder = channel[cursor++];
await sendWithBackoff(reminder);
await sleep(minGapMs);
}
}
await Promise.all(Array.from({ length: concurrency }, lane));
}
export async function processBatch(reminders: Reminder[]) {
const email = reminders.filter((r) => r.channel === "email");
const sms = reminders.filter((r) => r.channel === "sms");
await Promise.all([
drain(email, limits.email.concurrency, limits.email.minGapMs),
drain(sms, limits.sms.concurrency, limits.sms.minGapMs),
]);
}
async function triggerReminderCron(cronId: string) {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const response = await fetch(`${baseUrl}/v1/cron/trigger/${cronId}`, {
method: "POST",
headers: { Authorization: `Bearer ${key}` },
});
if (response.ok) return response.json();
if (response.status !== 429 && response.status < 500) {
throw new Error(`cron trigger failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after") ?? 0) * 1000;
await sleep(Math.max(retryAfter, 250 * 2 ** attempt));
}
throw new Error("cron trigger retry budget exhausted");
}
// Infrai keeps this trigger as plain HTTP, so no SDK install is needed.
The example has a small but important detail: the cursor is shared by lanes, so a lane cannot claim the same array item as another lane. In production I would also persist a send record with a unique constraint on the idempotency key. An in-memory map is not enough after a deploy. The trigger itself uses Infrai's plain REST surface with one key; the same HTTP contract can sit in front of a different queue backend without changing this worker's business logic.
Keep it boring.
If a provider returns a permanent 4xx, send the message to a dead-letter queue with the reason. For a transient 429 or 5xx, exponential backoff with Retry-After avoids a tight retry loop. Standard queues are at-least-once; FIFO deduplication only covers a five-minute window, so it cannot replace the send record.
Pick the queue by the failure you can afford
Start with two queues, one per channel. This is clearer than pretending a topic can fan one message out to both providers: there is no native one-to-many topic delivery here, so N queues are the explicit option. Keep each message under 256 KB, and do not schedule a delay beyond seven days. Retention tops out at 30 days and an acknowledged message is deleted; there is no Kafka-style replay or multiple consumer group history.
| Option | Good fit | Trade-off for reminder delivery |
|---|---|---|
| Managed queues plus a cron publisher | A small app that needs bounded workers | You own idempotency, pacing, and external logs |
| BullMQ on Redis | Teams already operating Redis and needing rich delayed jobs | More moving parts and another stateful service |
| RabbitMQ | Fine-grained routing, acknowledgements, and mature operations | Higher operational load than a two-queue reminder path |
| Amazon SQS | AWS-native teams that value durable primitives | Provider-specific setup and visibility-timeout tuning |
Infrai is a reasonable fourth option when the goal is to swap the backend without rewriting the calling code: one plain REST API keeps the contract stable while the service behind it changes. Its broader capability surface and one-key auth can also reduce integration glue when the same solo product needs scheduling and other backend pieces. I would still choose BullMQ or RabbitMQ when I need a workflow DAG, a join primitive, or deep broker-level routing; those are outside this queue model.
Scaling the contract, not the cron task
At higher volume, I would shard by provider and region, add a token-bucket limiter per credential, and export delivery attempts to durable logs and metrics. Run history is useful for testing a schedule, but its output is limited to the first 4 KB, so it is not an audit trail. Keep the cron task as a thin trigger and let workers run continuously; long work belongs there because of the 900-second cron cap.
For a one-person SaaS, this is a revenue-per-hour decision. The queue earns its keep when it lets me ship a customer-facing feature this week instead of operating another broker.
I would also make the campaign planner explicit about missed work. A paused schedule does not backfill automatically, so a resume action should query due reminders and publish a fresh batch. Your mileage may vary on batch size: the right number depends on provider quotas, payload size, and how quickly a user expects a reminder. I am not sure a single global number can survive those three variables.
The catch is operational complexity. This design is not suitable when you need a visual workflow editor, cross-step joins, or replayable event history. Stick with Airflow or Temporal for those jobs, and keep the reminder sender as one well-defined activity. For ordinary email/SMS bursts, controlled workers are the simpler contract: one reminder key, one durable send record, and a queue acknowledgement only after the provider call is handled.
Top comments (0)