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

ApproachRate is enforced byGood whenCost
Sleep between sends in the workerYour loop, per replicaReplica count is fixed and knownFree — consume and ack aren’t metered
delay_seconds staircase at publishThe queue’s clockThe whole fan-out is known up frontOne publish per message
BullMQ limiterRedis, shared across workersYou already run Redis and need a fleet-wide capYour Redis bill
SQS + Lambda reserved concurrencyAWS concurrency accountingYou’re already inside AWSPer-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.

References

Browse more queue developer guides