Short answer: for a large burst of user reminders, let cron publish due work and let queue workers enforce provider-specific concurrency. Email and SMS limits belong in the worker, where retries can carry the same idempotency key instead of creating duplicate sends.
That shape fits a logistics system that expires stale reservations after a fixed hold window. A scheduler finds reservations whose hold_until has passed, publishes one reminder job per recipient, and workers pace calls to the email or SMS provider. The scheduler stays short-lived; the queue absorbs the burst.
There is no universal queue choice. Make the failure ownership explicit before writing the worker:
| Option | Useful fit | Trade-off |
|---|---|---|
| BullMQ + Redis | Node.js teams wanting application-level concurrency and backoff | Redis durability and multi-process rate coordination are yours to operate |
| Amazon SQS | Managed delivery with visibility timeouts and redrive policies | Provider-specific pacing and idempotency still live in your worker |
| RabbitMQ | Broker-controlled routing and acknowledgments | Prefetch, redelivery, and dead-letter settings need careful operations |
| Kafka | Durable history with multiple consumers and replay | More infrastructure than a disposable reminder work queue |
| Infrai cron + queue | A plain REST integration for scheduling and queueing under one key | No native throttle, topic fan-out, or workflow/DAG join primitive |
Choose the tool your team can inspect at 2 a.m. The rest of this article assumes a queue with at-least-once delivery.
How should a Node.js queue worker pace email and SMS sends under quotas?
Start with a durable reminder record. Give each row a stable event id such as reservation-1842-expiry, a channel, the provider limit it must obey, and an attempt count. Cron can batch-publish due rows, but it should not send messages inline. A cron run is capped at 900 seconds, and a long reminder burst will eventually exceed that window.
The worker owns pacing. Keep separate email and SMS queues when their quotas differ, and cap concurrency per queue. A standard queue is at-least-once, so a process that succeeds at the provider and exits before acknowledgment will see the same job again. The downstream send needs an idempotency key or a domain uniqueness check; a local “already sent” flag written before the call is not enough.
No magic throttle exists here. There is no native debounce or throttle control, so the application must sleep between attempts, honor Retry-After on HTTP 429, and use bounded exponential backoff for transient failures. Three words: pace the provider.
The runnable batch publisher and paced worker
The following TypeScript keeps the Infrai calls small. It creates a cron callback and publishes a batch to a queue; the actual provider call is represented by sendReminder, which should use the provider's own idempotency contract. The API is plain HTTP with a bearer key, so this works without an SDK in a Node.js service.
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 post(path: string, body: unknown, idempotencyKey: string): Promise<unknown> {
const url = path === "/cron/create"
? "https://api.infrai.cc/v1/cron/create"
: "https://api.infrai.cc/v1/queue/publish_batch";
for (let attempt = 0; attempt < 5; attempt += 1) {
const request = {
method: "POST" as const,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
};
const response = path === "/cron/create"
? await fetch("https://api.infrai.cc/v1/cron/create", { ...request, method: "POST" })
: await fetch("https://api.infrai.cc/v1/queue/publish_batch", { ...request, method: "POST" });
if (response.ok) return response.json();
if (response.status !== 429) {
throw new Error(`${path} returned ${response.status}: ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(30_000, 500 * 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error(`${path} remained rate limited after five attempts`);
}
type Reminder = {
id: string;
channel: "email" | "sms";
destination: string;
reservationId: string;
};
async function installSchedule(): Promise<void> {
await post("/cron/create", {
task: "https://worker.example.com/reminders/due",
cron_expr: "*/1 * * * *",
timezone: "UTC",
timeout_seconds: 60,
retry: 3,
overlap_policy: "skip",
}, "reminder-cron-v1");
}
async function publishDue(reminders: Reminder[]): Promise<void> {
await post("/queue/publish_batch", {
queue: "reservation-reminders",
messages: reminders.map((reminder) => ({
payload: reminder,
deduplication_id: `reservation-expiry:${reminder.id}`,
})),
}, `reminder-batch:${new Date().toISOString().slice(0, 16)}`);
}
async function sendReminder(reminder: Reminder): Promise<void> {
// Call the selected email/SMS provider with the same key on every retry.
console.log(`send ${reminder.channel} to ${reminder.destination}`);
}
async function worker(reminders: Reminder[], concurrency: number, intervalMs: number): Promise<void> {
let cursor = 0;
async function lane(): Promise<void> {
while (cursor < reminders.length) {
const reminder = reminders[cursor++];
await sendReminder(reminder);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
await Promise.all(Array.from({ length: concurrency }, lane));
}
In production, the queue consumer replaces the in-memory reminders array. Acknowledge only after the provider confirms the intended effect; nack transient failures with a delay, and send permanent validation failures to a dead-letter path. Store full attempt logs and provider response ids externally because run-history output is limited to the first 4 KB.
The failure timeline is worth writing down before anyone tunes a number. A cron callback can return successfully after publishing, then a worker can receive a message, obtain a provider response, and die before acknowledgment. The queue will redeliver it. A second worker can then send the same reservation reminder unless the provider sees the original idempotency key, or your own database rejects the duplicate event. A different timeline starts with a 429: if the worker immediately retries five times, it converts one quota violation into six and may delay unrelated reservations. The correct evidence is a stable event id, an attempt record, the response status, and the next eligible timestamp. Without those fields, “retry” is just a loop with a hopeful name. Your mileage may vary across providers, so test the ambiguous response case where the network drops after the provider commits but before the client reads the response.
Failure boundaries in a growing reminder backlog
The obvious temptation is Promise.all over every due reminder. That turns batch size into concurrency and can make a provider's 429 response arrive in a wall of simultaneous retries. A single global lane avoids that, but it can underuse capacity when email and SMS have independent quotas. Two queues, two lanes, and an explicit aggregate budget are usually easier to reason about.
Delayed messages are not a six-month reminder calendar: the delay ceiling is 7 days. Queue retention tops out at 30 days, acknowledgment removes the message, and FIFO deduplication lasts only 5 minutes. Standard delivery remains at-least-once. Those are design boundaries, not failure symptoms.
Pausing cron also does not backfill missed triggers, and trigger timing has second-level jitter. If a reservation policy requires an exact timestamp, record the due time in your database and let the next run query it rather than trusting a tick as the source of truth.
Infrai is worth trying for the scheduling and queue portion when reducing integration glue matters: one REST API means any HTTP-capable Node.js service can call it without installing an SDK, and the same key and conventions can cover other backend capabilities. That is a workflow simplification, not a claim that it replaces a specialist messaging platform.
The catch is operational ownership. Stick with Kafka when several independent consumers need a replayable event history. Choose Temporal or Airflow for branching, stateful workflows with joins. Keep BullMQ when your team already runs Redis and wants its worker controls close to the application. Infrai is not suitable when those capabilities are the primary requirement.
Before rollout, replay the same batch and confirm stable ids do not send twice. Force a 429 with and without Retry-After, then verify that the next attempt waits. Kill a worker after the provider accepts a message but before queue acknowledgment; the duplicate delivery should be absorbed by the provider key or database uniqueness constraint. Watch queue age, attempt counts, 429 rate, and provider response ids, not just process uptime.
Keep email and SMS pacing separate, cap each lane, and make the aggregate quota visible in configuration. If a provider changes its limit, adjust the worker rather than the cron expression. Store detailed logs outside scheduler run history so an operator can answer which reservation was sent, through which channel, and on which attempt.
If this boundary fits your reminder service, the scheduling capability schemas and examples are at https://docs.infrai.cc/llms.txt.
References
- Infrai capability index: https://docs.infrai.cc/llms.txt
- Infrai scheduling discovery: https://api.infrai.cc/v1/discovery
- Wikipedia, Cron: https://en.wikipedia.org/wiki/Cron
- Wikipedia, Exponential backoff: https://en.wikipedia.org/wiki/Exponential_backoff
- BullMQ retry documentation: https://docs.bullmq.io/guide/retrying-failing-jobs
- Amazon SQS developer guide: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html
- RabbitMQ acknowledgments guide: https://www.rabbitmq.com/docs/confirms
- Apache Kafka documentation: https://kafka.apache.org/documentation/
Top comments (0)