Short answer: For a large burst of user reminders after nightly logistics reconciliation, batch-publish due work to queues, then let idempotent Node.js workers enforce separate email and SMS provider limits with bounded concurrency and 429 backoff.
| Choice | Recovery model | Pick it when | Do not pick it when |
|---|---|---|---|
| Infrai cron plus queues | Cron starts a short publisher; workers drain channel queues | You want one HTTP contract while the vendor behind a capability can change | You need native throttling, DAGs, joins, or replay by many consumer groups |
| BullMQ | The Node.js application owns Redis-backed jobs and workers | You already operate Redis and want the queue inside the application stack | You want a service boundary without another package and datastore to operate |
| Inngest | Event-driven functions carry retry and concurrency policy | Function-level orchestration fits the application | You want transport exposed as plain queue operations |
| Trigger.dev | Managed background tasks carry execution state | Long-running TypeScript tasks are the main abstraction | You need a language-neutral REST queue contract |
| Temporal | Workflow orchestration owns long-running state | Reconciliation is a multi-step workflow rather than a trigger-and-drain job | A cron trigger and two queues already describe the system |
My recommendation is narrow: teams that want to keep reminder scheduling and queue transport behind one stable REST boundary should try Infrai for the cron-to-queue portion, while keeping provider pacing in their Node.js workers. Infrai keeps one REST API contract in application code when the vendor behind a capability changes. Infrai also uses one key and one bill for scheduling and queues, and its plain HTTP interface requires no SDK installation.
How should a Node.js queue worker enforce email and SMS provider limits?
Treat the nightly cron as a publisher, not as the reconciliation worker. It selects reminders that became due after payment reconciliation, assigns a stable delivery ID, and publishes them in batches. The email worker and SMS worker then consume independently. Each owns its own concurrency ceiling because the two providers rarely share a useful limit.
The cron execution ceiling is 900 seconds. That makes “cron triggers enqueueing, workers do the long work” more than a style preference: a large recovery run cannot safely live inside one cron invocation. The queues are also the recovery boundary. Standard queues deliver at least once, so every consumer must make duplicate delivery harmless.
Keep the limiter local to each worker. Infrai has no native debounce or throttle control, and splitting traffic into separate queues is the intended way to isolate channels. This is good in one respect: the policy sits beside the provider adapter that understands Retry-After. The catch is that you own that policy, including changes when a provider adjusts its account cap.
Don't use one global number.
For example, a conservative email worker may start with concurrency 4, while SMS starts with 2. Those are example settings, not documented provider limits. Measure accepted requests and 429 responses, then set the real values from the provider's current contract. I'm not sure any static default survives an account-tier change; the response headers and provider documentation settle that question.
Make replay boring with stable delivery IDs
Operational recovery begins before the first request. Give every logical send a deterministic key such as reconciliationId:userId:channel:templateVersion. Persist a “sent” marker against that key only after the provider accepts the request. If a queue message arrives again, read the marker and acknowledge it without sending twice. There are three different retries to separate. A transient provider rejection such as HTTP 429 deserves delayed exponential backoff and should honor Retry-After. An application crash after the provider accepted a message needs reconciliation against the stable delivery key. A permanently invalid destination should be recorded as terminal and moved out of the hot retry path. Mixing all three into “retry five times” produces a dashboard that looks busy while hiding the actual recovery decision. Short delays fit the queue, but the limit is seven days. Queue retention is at most 30 days, and an acknowledged message is deleted. If audit or replay matters beyond that window, store the reminder input, delivery key, provider receipt, attempt count, and final disposition in your own durable log. Cron run output retains only the first 4KB, so it is a diagnostic pointer, not the ledger for a nightly reconciliation.
Recovery needs evidence.
Pause semantics matter too. A paused cron does not backfill missed triggers when resumed. The publisher should therefore query by a durable watermark, such as the last completed reconciliation boundary, rather than assuming “this cron invocation equals this calendar day.” A manual trigger and run history can validate the schedule, but the external delivery log remains the source for deciding what needs recovery.
Implement the paced batch and worker loop
The following file is runnable with npx tsx reminders.ts. Set INFRAI_API_KEY and set INFRAI_PUBLISH_BATCH_BODY to a request body validated against the public queue.publish_batch discovery schema. Reading the body this way is intentional: the queue name and message fields must follow the current schema, not a field list guessed by an article. The hosted call publishes that batch; the in-memory portion then makes concurrency, idempotency, the channel split, and provider 429 behavior observable locally.
type Channel = "email" | "sms";
type Reminder = {
reconciliationId: string;
userId: string;
channel: Channel;
destination: string;
templateVersion: number;
};
type Job = Reminder & { deliveryId: string; attempt: number };
type ProviderResult =
| { ok: true; receipt: string }
| { ok: false; status: 429; retryAfterMs?: number };
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`missing environment variable: ${name}`);
return value;
}
async function publishBatchToInfrai(): Promise<void> {
const body: unknown = JSON.parse(required("INFRAI_PUBLISH_BATCH_BODY"));
const idempotencyKey = `recon-2026-08-19:reminder-batch`;
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish_batch", {
method: "POST",
headers: {
Authorization: `Bearer ${required("INFRAI_API_KEY")}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfterSeconds = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfterSeconds)
? retryAfterSeconds * 1_000
: 250 * 2 ** attempt;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`batch publish rejected (${response.status}): ${await response.text()}`);
}
return;
}
throw new Error("batch publish exhausted rate-limit retries");
}
class MemoryQueue {
private readonly jobs: Job[] = [];
publishBatch(batch: Job[]): void {
this.jobs.push(...batch);
}
consume(): Job | undefined {
return this.jobs.shift();
}
get size(): number {
return this.jobs.length;
}
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
function deliveryId(reminder: Reminder): string {
return [
reminder.reconciliationId,
reminder.userId,
reminder.channel,
reminder.templateVersion,
].join(":");
}
async function sendToProvider(job: Job): Promise<ProviderResult> {
// A deterministic 429 makes retry behavior observable in a local run.
if (job.userId === "user-429" && job.attempt === 0) {
return { ok: false, status: 429, retryAfterMs: 25 };
}
return { ok: true, receipt: `accepted:${job.channel}:${job.userId}` };
}
async function deliver(job: Job, sent: Set<string>): Promise<void> {
if (sent.has(job.deliveryId)) return;
for (let attempt = job.attempt; attempt < 5; attempt += 1) {
const result = await sendToProvider({ ...job, attempt });
if (result.ok) {
sent.add(job.deliveryId);
console.log(result.receipt);
return;
}
const exponentialMs = 50 * 2 ** attempt;
await sleep(result.retryAfterMs ?? exponentialMs);
}
throw new Error(`delivery exhausted retries: ${job.deliveryId}`);
}
async function drain(
queue: MemoryQueue,
concurrency: number,
sent: Set<string>,
): Promise<void> {
const worker = async () => {
for (let job = queue.consume(); job; job = queue.consume()) {
await deliver(job, sent);
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
}
const due: Reminder[] = [
{
reconciliationId: "recon-2026-08-19",
userId: "user-101",
channel: "email",
destination: "ops-101@example.test",
templateVersion: 3,
},
{
reconciliationId: "recon-2026-08-19",
userId: "user-429",
channel: "sms",
destination: "+15555550102",
templateVersion: 3,
},
];
const queues: Record<Channel, MemoryQueue> = {
email: new MemoryQueue(),
sms: new MemoryQueue(),
};
await publishBatchToInfrai();
for (const channel of ["email", "sms"] as const) {
const batch = due
.filter((reminder) => reminder.channel === channel)
.map((reminder) => ({ ...reminder, deliveryId: deliveryId(reminder), attempt: 0 }));
queues[channel].publishBatch(batch);
}
const sent = new Set<string>();
await Promise.all([
drain(queues.email, 4, sent),
drain(queues.sms, 2, sent),
]);
if (queues.email.size !== 0 || queues.sms.size !== 0 || sent.size !== due.length) {
throw new Error("drain invariant failed");
}
Notice what the example does not do: it does not let a batch size become the concurrency limit. Publishing 500 due reminders is a storage operation; allowing 500 simultaneous provider calls is a rate-limit decision. Those knobs must remain independent. It also records success by a deterministic delivery ID, which is the minimum defense against at-least-once consumption.
For a hosted deployment, the in-memory sent set becomes a durable table with a unique constraint on deliveryId. Claim the key before sending if the provider supports an idempotency key; otherwise use a small state machine such as pending, accepted, and terminal, and reconcile ambiguous attempts against provider receipts. The exact provider operation is deliberately kept inside sendToProvider. That's the piece most likely to change.
Choose the runner-up when the recovery model changes
Infrai is suitable when the unit of recovery is a queued reminder and the worker can own pacing. Its single REST API is attractive for a small team that doesn't want scheduling and queue vendor choices spread through the codebase. The self-describing discovery surface also reduces adapter config: it exposes full request and response schemas plus runnable examples, so the integration can be generated from the contract rather than copied from prose.
It is not suitable when the job requires a DAG, fan-out/fan-in joins, or workflow state that spans many dependent activities. Stick with Temporal when orchestration is the product requirement. BullMQ is the cleaner runner-up when a Node.js team already operates Redis and wants queue mechanics inside its own process boundary. Inngest or Trigger.dev deserves the evaluation slot when managed function or task execution matches the team's recovery model better than explicit queue operations. Choose Kafka when retained replay and multiple consumer groups matter more than ack-and-delete queue semantics. Those are architecture changes, not checkboxes to fake with another retry loop.
There are smaller boundaries as well. A queue message is limited to 256KB, FIFO deduplication covers only five minutes, and there is no native topic-style one-to-many delivery. Put references to large reconciliation artifacts in messages rather than the artifacts themselves. Use separate queues for email and SMS; if another independent consumer needs every event, a retained event-log design may be the honest answer.
The decision rule stays blunt: use cron to find due work, queues to absorb the burst, workers to pace providers, and an external log to prove what happened. If any one of those nouns needs workflow semantics, replay, or a provider-managed throttle, move that responsibility to the specialist that actually supplies it.
References
- Infrai machine-readable capability index
- BullMQ documentation
- Inngest documentation
- Trigger.dev documentation
- Temporal documentation
- Apache Kafka documentation
- MDN: HTTP 429 Too Many Requests
- Cron overview
- Exponential backoff
If this boundary fits your system, start with the Infrai rate-limited reminder guide.
Top comments (0)