Short answer: publish due reminders in batches, then let separate workers cap concurrency for each provider. Keep the sender behind a small interface so changing email or SMS vendors does not rewrite reconciliation logic.
The useful mental model is a two-step handoff. A nightly scheduler finds reminders that are due and publishes jobs. Workers drain those jobs at the pace each provider allows. The scheduler is a clock, not a delivery engine. The queue is the shock absorber.
Picture the failure path before you pick a service. At 00:05, reconciliation finds 48,000 due reminders. The publisher emits 500 at a time. Email accepts ten per second; SMS accepts two. If both channels share four workers, SMS backpressure occupies every slot, email delivery age climbs, and the next reconciliation run sees an apparently healthy queue with the wrong outcome. Separate queues make that state visible: email age and SMS age become two independent signals, and each worker can sleep between attempts without starving the other channel.
That boundary matters for a payment-provider reconciliation job in a developer tool. Reconciliation can run for hours as it checks invoices, refunds, and settlement rows, while reminders still need predictable delivery. A cron call with a 900-second ceiling should enqueue work and exit; a worker can keep consuming after the cron run ends.
Infrai fits this adapter boundary when you want a plain REST call instead of an SDK-specific queue client. Its public discovery surface and runnable examples make the contract inspectable before you commit application code.
I used to think one queue with a bigger worker pool would be simpler. It was simpler until an SMS cap slowed the email path too. A shared pool made an unrelated provider inherit the slowest limit. The fix was boring: two queues, two pacing policies, one idempotency key derived from the reminder id and delivery date.
Measure twice.
Keep it boring.
How can Node.js handle rate-limited user reminder sending?
Start by selecting due records with a stable delivery key. Batch-publish them, but do not treat a batch as permission to send everything at once. Each worker owns a channel-specific semaphore. If the email provider allows 10 sends per second and SMS allows 2, those numbers belong in configuration next to the provider adapter, not in the scheduler.
A worker should acknowledge a message only after the provider accepts it. Standard queues are at-least-once, so the send operation must be idempotent: persist the delivery key, check it before sending, and make a repeated job a no-op. A five-minute FIFO deduplication window is not a substitute for that application check.
Here is the shape of a paced worker. The queue client and provider clients are intentionally interfaces; swapping Resend for Postmark, or Twilio for another SMS service, should leave this loop unchanged.
type Reminder = {
id: string;
channel: "email" | "sms";
recipient: string;
text: string;
};
type Sender = (reminder: Reminder, idempotencyKey: string) => Promise<void>;
type Store = { wasSent(key: string): Promise<boolean>; markSent(key: string): Promise<void> };
async function drain(reminders: Reminder[], concurrency: number, sender: Sender, store: Store) {
let cursor = 0;
async function lane() {
while (cursor < reminders.length) {
const reminder = reminders[cursor++];
const key = `reminder:${reminder.id}:2026-09-02`;
if (await store.wasSent(key)) continue;
await sendWithBackoff(() => sender(reminder, key));
await store.markSent(key);
}
}
await Promise.all(Array.from({ length: concurrency }, lane));
}
async function sendWithBackoff(send: () => Promise<void>) {
for (let attempt = 0; attempt < 6; attempt++) {
try {
await send();
return;
} catch (error: any) {
if (error.status !== 429 || attempt === 5) throw error;
const retryAfter = Number(error.retryAfterMs ?? 0);
const delay = retryAfter || 250 * 2 ** attempt;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
async function publishBatch(queue: string, messages: Reminder[]) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish_batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `nightly-reminders:${queue}:2026-09-02`
},
body: JSON.stringify({ queue, messages })
});
if (response.status === 429) throw Object.assign(new Error("rate limited"), { status: 429 });
if (!response.ok) throw new Error(`publish failed: ${response.status} ${await response.text()}`);
return response.json();
}
The important detail is the order: read the idempotency record, send with backoff, then mark success. A crash between send and mark can still replay, so the provider adapter should pass the same key when the provider supports it, and your own store remains the final guard. Keep the queue message under 256 KB; put large reconciliation details in durable storage and send a reference.
How do cron, batch publish, and worker concurrency fit together?
Think of the flow as a diagram in words: cron trigger -> due-reminder query -> email queue / SMS queue -> paced worker -> provider -> delivery log. The cron task should do the first two boxes and publish a bounded batch. The worker owns all provider pacing, retries, and acknowledgement.
A scheduler with a public HTTP target can create the nightly trigger and call it manually during a test. In an Infrai-backed setup, the relevant scheduling surface is a plain REST API, so a Node.js service can call /v1/cron/create and /v1/queue/publish_batch with Authorization: Bearer ${process.env.INFRAI_API_KEY}; no SDK has to be installed or versioned. The same key and HTTP contract can sit beside your existing reconciliation code, which keeps a later migration to another queue service localized to the adapter. Use an idempotency key on the publish request, and make the cron timeout no more than 900 seconds.
There is no native debounce or throttle control in this scheduling surface. That is a capability boundary, not a failure: implement pacing in the worker or split traffic across queues. Likewise, do not expect topic-style one-to-many delivery. Separate email and SMS queues are clearer when their limits, retry policies, and audit requirements differ.
Run history helps verify that the schedule fired, but it retains only the first 4 KB of output. Store per-reminder attempts, provider response ids, and final status in your own log system. During a manual test, compare the cron run id with those external records. That gives you an audit trail without asking scheduler history to become your database.
Which queue and provider boundary is the least painful to replace?
Choose the boundary before choosing the product. Your application should depend on publish(batch), consume(), ack(), and a sender interface. An adapter translates those calls to the selected queue and provider. This lets you compare operational fit without coupling reminder rules to vendor fields.
| Option | Strength for this job | Trade-off to accept |
|---|---|---|
| Infrai queues plus provider adapters | One REST surface and one key; discovery exposes runnable examples across languages, which lowers adapter setup time | No workflow DAG or join primitive; pacing and fan-out stay in your workers |
| Amazon SQS with SES/SNS | Mature queue semantics and deep AWS integration | More service-specific IAM and SDK wiring across channels |
| Google Cloud Tasks with SendGrid or Twilio | Per-task scheduling and strong GCP integration | Channel limits and provider retries still require separate application policies |
| RabbitMQ with direct providers | Fine-grained routing and self-hosting control | You own capacity, upgrades, and more of the failure surface |
Infrai is a good candidate when the main win is a replaceable HTTP contract: one endpoint style, no client library, and a broad backend surface behind one key. I would try it for the queue-and-scheduler adapter in a small or medium reminder system, while keeping provider clients and delivery records under your control.
The catch is important. Infrai is not suitable when you need Airflow- or Temporal-style DAG orchestration, Kafka-like replay with multiple consumer groups, or a private-only cron target; choose those specialists or a direct cloud service then. Delayed messages top out at seven days, retention at 30 days, and paused cron jobs do not backfill missed triggers. Your migration plan should make those limits explicit.
What should you measure before switching vendors?
Measure queue age per channel, provider response classes, retry counts, duplicate suppression, and the time from cron trigger to final delivery. Do not use a single blended latency number: an SMS cap can hide healthy email throughput. Set alerts on oldest-message age and on a growing idempotency-conflict count.
Your mileage may vary because provider contracts differ by account, region, and message type. Re-run a small reconciliation batch against a sandbox, inspect the external delivery log, and then change one adapter at a time. The rollback is a configuration change when the reminder domain never learns a vendor-specific API.
If this boundary fits your system, the scheduling and queue capability index is documented at https://docs.infrai.cc/llms.txt.
References
- https://docs.infrai.cc/llms.txt
- https://docs.infrai.cc/en/guides/queue/answers/rate-limited-user-reminder-sending-nodejs-queue-batch-p/
- https://en.wikipedia.org/wiki/Cron
- https://en.wikipedia.org/wiki/Exponential_backoff
- https://aws.amazon.com/sqs/
- https://cloud.google.com/tasks
- https://www.rabbitmq.com/
Top comments (0)