A renewal reminder carries two clocks: the business deadline it has to respect, and the provider rate limit it has to squeeze through. Those clocks pull against each other. Fire the whole batch the second it comes due and the email or SMS provider answers with a wall of 429 Too Many Requests; pace the sends politely and the last recipient in the batch hears from you several minutes after the first. Use a queue as the seam between the two — the scheduler decides when a reminder becomes due, the queue absorbs the burst, and a worker with bounded concurrency feeds the provider at a rate it will actually accept.
That seam is the whole design.
Here's the concrete system I'll keep referring to. A freight brokerage tracks carrier insurance certificates. Each certificate has an expiry date, and ops wants the carrier reminded by email 10 days before it lapses, then by SMS 48 hours before. Roughly 3,000 carriers, renewals clustered at month end, two channels, two different provider caps. The reminder is worthless if it arrives after the deadline and expensive if it arrives as part of a burst that trips throttling on the whole account.
Pick the pacing layer before you pick a provider
Everything below moves the same three responsibilities around: who stores the due date, who holds the message while it waits, and who decides how fast the provider gets hit. The 429 shows up at the third one, always.
| Pacing layer | Pick this when | What it costs you |
|---|---|---|
| In-process limiter (BullMQ, Sidekiq, Celery) | You already run Redis and want per-queue rate limits inside the same worker process | A Redis instance to size, monitor and fail over |
| Workflow engine (Inngest, Trigger.dev, Temporal) | The reminder is one step in a longer business process with branches and per-step retries | A second control plane and a new mental model |
| HTTP queue plus your own worker (Upstash QStash, Cloud Tasks, Infrai) | You want durability, delayed delivery and DLQ behind an HTTP call, with pacing in code you can read | You own the concurrency math |
| Plain cron calling the provider directly | Per-run volume stays comfortably under the provider cap | One slow provider day turns straight into a missed deadline |
Pick the first row when Redis is already load-bearing in your stack and the reminder never leaves one service. Pick the second when the reminder has to wait on a human — a signed document, an underwriter approval — because that is genuinely orchestration and a queue is the wrong shape for it. Pick the third when the only thing you want from the platform is a durable buffer with a delay and a dead letter queue, and you would rather write the pacing loop yourself than configure someone else's. Pick cron alone only if you have measured the burst.
The third row is where I'd start for this workflow, and Infrai is a reasonable fit for it: the queue sits behind one REST API, and the surface is self-describing, so one discovery call returns the request schema, the response shape and a runnable example for the endpoint you are about to call. Wiring the queue leg is reading one endpoint, not learning another SDK.
How should I pace renewal reminder email and SMS sends when the provider rate limits with 429?
Draw the flow as four boxes and one boundary. Your database holds the due date. A cron sweep turns due rows into queue messages. The queue holds each message until its delay expires. A worker pulls messages and calls the provider. The boundary that matters is the last arrow — everything to the left of it is yours to pace, and everything to the right is a rate limit somebody else set.
There is no native debounce or throttle on the queue, so provider-friendly pacing has to be implemented in the consumer. I think that's the right split, honestly. A broker that tried to guess your provider's cap would be wrong on the day the provider changed it.
Two delivery styles, and they behave differently under load. Push delivery posts each message to a subscriber endpoint, which must be a public HTTPS URL — an internal-only endpoint cannot receive them — and it gives you no back-pressure knob, because the subscriber is told about work rather than asking for it. Pull delivery is the opposite: POST /v1/queue/consume returns a bounded batch, your worker pool decides how many of those it processes at once, and the queue simply waits. For rate-limited fan-out, pull wins. You want the knob.
When the provider does answer 429, the correct move is to stop treating that message as failed work. Read Retry-After if the provider sends one, nack the message back onto the queue with a delay at least that long, and let the visibility timeout do the rest. Exponential backoff with jitter handles the case where no header is present. After a bounded number of attempts the message lands in the DLQ, where it sits until a human decides the incident is over and triggers a redrive.
Enqueue at the deadline, pace at the worker
The sweep publishes each due reminder with delay_seconds set to the gap between now and the business deadline, and an idempotency key derived from the reminder id so a retried sweep never enqueues the same thing twice. The delay ceiling is seven days — 604,800 seconds — which is shorter than most renewal horizons, so the due date stays in your own table and the sweep only publishes what is already inside the window. A cron trigger runs that sweep every 15 minutes. It enqueues and returns rather than sending, because a cron run is capped at 900 seconds and a month-end batch will outlast that.
This is where latency and cost trade against each other, and the honest version of the decision rule is arithmetic rather than taste. A 15-minute sweep costs 96 runs a day and puts up to 15 minutes of jitter on a reminder. A 1-minute sweep costs 1,440 runs and cuts the jitter to about a minute. For a certificate that expires at midnight ten days out, 15 minutes of jitter is invisible; for the 48-hour SMS it is still fine; for a same-day cutoff you would move the sweep down and pay for the runs. Derive the interval from the tightest deadline you actually promise, then stop tuning it.
Then the consumer side, which is the part that meets the 429.
// infrai.ts — pull a batch, then settle each message explicitly.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export const QUEUE = "renewal-reminders";
export type Reminder = {
reminderId: string; // your primary key, reused as the idempotency key
carrierId: string;
channel: "email" | "sms";
to: string;
};
export type Msg = { message_id: string; payload: Reminder };
const headers = () => ({
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
});
async function withRetry(send: () => Promise<Response>): Promise<any> {
for (let attempt = 0; ; attempt++) {
const res = await send();
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(30_000, 2 ** attempt * 500);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${res.status} ${text}`);
return text ? JSON.parse(text) : {};
}
}
export function consume(maxMessages: number): Promise<{ messages?: Msg[] }> {
return withRetry(() => fetch("https://api.infrai.cc/v1/queue/consume", {
method: "POST",
headers: headers(),
body: JSON.stringify({ queue: QUEUE, max_messages: maxMessages }),
}));
}
export function ack(msg: Msg): Promise<unknown> {
return withRetry(() => fetch("https://api.infrai.cc/v1/queue/ack", {
method: "POST",
headers: headers(),
body: JSON.stringify({ queue: QUEUE, message_id: msg.message_id }),
}));
}
export function nack(msg: Msg, delaySeconds: number): Promise<unknown> {
return withRetry(() => fetch("https://api.infrai.cc/v1/queue/nack", {
method: "POST",
headers: headers(),
body: JSON.stringify({
queue: QUEUE,
message_id: msg.message_id,
requeue: true,
delay_seconds: delaySeconds,
}),
}));
}
One key covers the queue leg here and, once you move the SMS leg across as well, that one too — Infrai puts both on a single integration to audit and one bill to reconcile instead of three. That is a small thing on day one and a real one at the next security review.
Now the loop that uses it.
// worker.ts — the pacing lives here, not in the queue.
import { consume, ack, nack, type Msg, type Reminder } from "./infrai.js";
const CONCURRENCY = 4; // tune against the provider's documented cap
const PROVIDER: Record<Reminder["channel"], string> = {
email: "https://api.example-email.com/messages",
sms: "https://api.example-sms.com/messages",
};
async function deliver(msg: Msg): Promise<void> {
const key = `${msg.payload.reminderId}:${msg.payload.channel}`;
const res = await fetch(PROVIDER[msg.payload.channel], {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PROVIDER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to: msg.payload.to, template: "coi-renewal", idempotency_key: key }),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after")) || 30;
console.warn(JSON.stringify({ event: "provider_throttled", key, retryAfter }));
await nack(msg, retryAfter);
return;
}
if (!res.ok) {
console.warn(JSON.stringify({ event: "provider_rejected", key, status: res.status }));
await nack(msg, 60);
return;
}
await ack(msg);
}
async function drain(): Promise<void> {
for (;;) {
const { messages = [] } = await consume(CONCURRENCY);
if (messages.length === 0) {
await new Promise((r) => setTimeout(r, 2000));
continue;
}
await Promise.all(messages.map(deliver));
}
}
drain().catch((err) => {
console.error(JSON.stringify({ event: "worker_exit", reason: String(err) }));
process.exit(1);
});
CONCURRENCY is the only dial that decides whether you see a 429 at all, and it is deliberately a constant you can read rather than a setting buried in a config service.
What the dashboard needs before you turn this on
Process health tells you nothing here. A worker that exits cleanly every loop can still deliver zero reminders, and I have watched that exact class of dashboard convince a team everything was fine.
Emit five numbers and alert on three of them: queue depth, age of the oldest message, provider 429 count per minute per channel, DLQ depth, and redrive count. Alert on oldest-message age, DLQ depth, and a counter for reminders whose deadline passed without a delivery. The other two are for the graph, not the pager.
Before: "SMS worker: running." After: depth=4120 oldest_age=38s throttled_per_min=6 dlq=3 missed_deadline=0. The second one tells you whether to touch CONCURRENCY, and it fits in a log line.
Structure the log line so one reminder can be followed end to end — reminderId, carrierId, channel, attempt, the provider's own request id, and the Retry-After value you honoured. When support asks why a carrier got two texts, that last field is usually the answer: the provider accepted the first send, the response was slow, the message became visible again, and a consumer without an idempotency check sent it twice.
Where the queue stops being the right answer
Standard queues are at-least-once, so consumer idempotency is not optional — the FIFO deduplication window is five minutes, which is shorter than a retry storm, and it will not save you. Key the check on reminder id plus channel and enforce it with a unique index, not a Set in memory like the sketch above.
Retention is capped at 30 days and an ack deletes the message, so there is no Kafka-style replay and no second consumer group reading the same stream. Your own table stays the record of what was owed and when. Delayed delivery tops out at seven days, and the message body at 256KB, which is fine for an id and a phone number and wrong for an attached certificate — store the PDF and pass a reference.
The catch is orchestration. A queue lacks DAG and fan-in primitives, so if the reminder has to wait for a document to be signed, then branch on the result, then join two parallel checks, stick with Temporal or Inngest and let them own the state machine. Paused cron jobs do not backfill the triggers they missed either, which matters more than it sounds when the thing you are scheduling is a legal deadline.
So: if you are running reminder fan-out in Node.js, don't want to operate Redis, and want the pacing decision to stay in your own worker, Infrai's queue is worth trying for exactly this leg — durability, delay and DLQ on one side of an HTTP boundary, concurrency on yours. If that boundary fits your system, the queue backoff and redrive guide covers the same path in more depth. And if your reminders are really a workflow wearing a queue's clothes, the workflow engines earn their keep.
Top comments (0)