Node.js reminder fan-out: batch publish, paced workers, provider caps
Fan out user reminders through an Infrai queue so your email and SMS providers never see more than their allowed rate, with a Node 22 worker you can scale by replica count.
A reminder blast breaks in a predictable place: the provider throttles you, half the sends come back 429, and your retry logic turns a slow evening into an outage. Infrai’s queue holds the backlog and hands it out in bounded chunks, which fixes the burst — but it has no rate limiter of its own, so the pacing lives in your worker loop. What follows is the full shape: two queues, a batched fan-out, and a drain rate you can prove.
Two queues, one per channel. Email absorbs a burst; SMS almost never does.
Split the fan-out by provider ceiling
SendGrid documents per-second API limits that sit in the hundreds; Twilio’s guidance for long-code SMS is closer to one message per second per number, and it recommends spreading a campaign rather than firing it. Those two numbers are an order of magnitude apart. One queue drained at one rate is therefore always wrong for one of the channels — either you’re crawling through email or you’re melting the SMS route.
So create a queue per channel, and let each one carry its own drain rate:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"reminder-blast","type":"standard","dlq":"reminder-blast-dlq"}'
{
"ok": true,
"data": {
"name": "reminder-blast",
"type": "standard",
"account_id": "acct_email_77c768e42148275b",
"message_retention_days": 14,
"max_message_size_kb": 256,
"visibility_timeout_default": 300,
"delivery_delay_seconds": 0,
"max_receive_count": 3,
"dlq_name": "reminder-blast-dlq"
}
}
The dlq field names the dead-letter queue. Leave it out and you still get one, called <queue>.dlq — dead-lettering is on by default rather than something you opt into, which is the opposite of the SQS default and catches people out in a good way.
Batch the publish, stagger inside the batch
POST /v1/queue/publish_batch takes an array, and each element carries its own delay_seconds. That turns the fan-out itself into a coarse scheduler: element n becomes visible n seconds from now, so a 600-recipient run arrives at the worker already spread over ten minutes.
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish_batch" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"reminder-blast","messages":[
{"payload":{"user_id":"usr_1042","template":"trial_ending"}},
{"payload":{"user_id":"usr_1043","template":"trial_ending"},"delay_seconds":1},
{"payload":{"user_id":"usr_1044","template":"trial_ending"},"delay_seconds":2}
]}'
{
"ok": true,
"data": {
"items": [
{
"message_id": "qmsg_UHzJxWUMbfcmnUUTNiofHZ1l",
"queue": "reminder-blast",
"payload": { "user_id": "usr_1042", "template": "trial_ending" },
"status": "available",
"available_at": "2026-07-26T01:18:34Z",
"delivery_count": 0
}
],
"next_cursor": null
}
}
One naming quirk to know before you copy this. The single-message route POST /v1/queue/publish accepts body as an alias and rewrites it to payload, telling you so in metadata.warnings; inside publish_batch, that alias doesn’t apply and an element with body is rejected with messages[0].payload must be an object. Read the warnings array — the API is unusually chatty about renaming your fields, and it’s free information.
The worker that can’t go too fast
Concurrency here is a product of two numbers you control: how many replicas you run, and how long each one waits between sends. One replica with a 1,000 ms gap is one message per second, flat, no matter how deep the backlog gets. Two replicas is two. There’s no shared token bucket to coordinate, which is a limitation if you need a global limit across a fleet that autoscales — but for a reminder queue whose replica count you set yourself, arithmetic beats coordination.
import process from "node:process";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY before starting the worker");
const QUEUE = "reminder-blast";
const GAP_MS = 1000; // one send per second per replica
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function readBatch() {
const res = await fetch("https://api.infrai.cc/v1/queue/consume", {
method: "POST",
headers,
body: JSON.stringify({ queue: "reminder-blast", max_messages: 10 }),
});
const out = await res.json();
if (!out.ok) throw new Error(`consume: ${out.error.code} ${out.error.message}`);
return out.data.items;
}
async function finish(messageId) {
const res = await fetch("https://api.infrai.cc/v1/queue/ack", {
method: "POST",
headers,
body: JSON.stringify({ queue: "reminder-blast", receipt_handle: messageId }),
});
const out = await res.json();
if (!out.data.acked) console.warn(`ack ignored for ${messageId} — lease already expired?`);
}
async function giveBack(messageId) {
await fetch("https://api.infrai.cc/v1/queue/nack", {
method: "POST",
headers,
body: JSON.stringify({ queue: "reminder-blast", message_id: messageId }),
});
}
async function sendReminder(payload) {
const res = await fetch("https://mailer.internal.example.com/reminders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 429) throw new Error("provider throttled us");
if (!res.ok) throw new Error(`mailer ${res.status}`);
}
for (;;) {
const items = await readBatch();
if (items.length === 0) { await sleep(2000); continue; }
for (const item of items) {
try {
await sendReminder(item.payload);
await finish(item.message_id);
} catch (err) {
console.error(`${item.message_id} delivery ${item.delivery_count}: ${err.message}`);
await giveBack(item.message_id);
}
await sleep(GAP_MS);
}
}
Note the shape of the response you’re iterating. You send receipt_handle because that’s what the reference documents, and the server maps it onto message_id — the value you pass is the message_id you got back from consume. Ack an id the queue has never heard of and you get HTTP 200 with {"acked": false} rather than an error, so check that field; a silent false is how a whole batch gets redelivered twenty minutes later with QUEUE_MESSAGE_NOT_IN_FLIGHT nowhere in sight.
Prove the drain rate
curl -sS "https://api.infrai.cc/v1/queue/stats/reminder-blast" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"queue": "reminder-blast",
"message_count": 1,
"available_count": 1,
"in_flight_count": 0,
"delayed_count": 1,
"dlq_count": 0,
"oldest_message_age_seconds": 41
}
}
Sample available_count a minute apart and divide — that’s your real throughput, and it’s the number to compare against the provider’s published cap. delayed_count is where staggered messages sit before they’re visible.
Where to put the throttle
| Approach | Rate is enforced by | Good when | Cost |
|---|---|---|---|
| Sleep between sends in the worker | Your loop, per replica | Replica count is fixed and known | Free — consume and ack aren’t metered |
delay_seconds staircase at publish | The queue’s clock | The whole fan-out is known up front | One publish per message |
| BullMQ limiter | Redis, shared across workers | You already run Redis and need a fleet-wide cap | Your Redis bill |
| SQS + Lambda reserved concurrency | AWS concurrency accounting | You’re already inside AWS | Per-request plus Lambda time |
What it costs, and what it doesn’t do
Publishing is the only billed call in this workflow: $0.00002 per message, verified 2026-07-26, so a nightly run to 50,000 users is about $1.00. Consume, ack, nack and stats are free and rate-limited instead of metered, which means a paced worker costs exactly what a fast one does. New accounts get $2 in credit. Rates drift downward and discount campaigns run, so read today’s figure rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data.capabilities[] | select(.id | startswith("queue.")) | {id, billing}'
The limits worth knowing before you commit: max_messages above 10 is a hard 400, max_receive_count is fixed at 3 no matter what you send to the update route, and per-message delay_seconds tops out at 604800 (seven days). The 400 you get for exceeding that ceiling is unhelpfully worded — it complains that the queue already exists — so trust the number, not the message.
If you need a fleet-wide limiter with per-group keys, BullMQ’s rate limiter is the better tool and it’s honest about needing Redis. If your reminders are already inside a Rails app, Sidekiq is less machinery than any HTTP queue. What you get here instead is the rest of the job on one credential: the same key drains this queue, sends the email, stores the rendered attachment and files the error when a send fails, with one bill and one usage query to attribute the run to a tenant.