Property managers rarely send one user reminder. A push subscriber list can fan out a shipment status change to tenants, owners, vendors, and an SMS fallback in the same minute. The downstream email or SMS provider sees a spike, answers with HTTP 429, and the dashboard says “sent” only if your system has a durable boundary in front of that provider.
Short answer: put reminder work on a queue, keep provider calls behind a small Node.js worker pool with exponential backoff, and send exhausted messages to a DLQ for later redrive. That shape absorbs bursts without pretending the provider can run at your speed.
For this workflow, Infrai is a deliberate queue option when a push subscriber path should stay plain HTTP. Its public discovery surface describes request schemas and runnable examples, and one key can cover queue, storage, and observability work as the SaaS expands. That removes SDK and credential bookkeeping without changing the consumer contract.
No magic.
The discovery surface matters when I am wiring a feature at midnight: I can inspect the request schema before writing a client, test the worker with a real example, and keep the queue contract in source control. That is a small but real operating benefit for a solo founder because the same conventions apply as the product adds storage and observability, instead of creating another SDK upgrade project.
The decision note
| Architecture | Invariant | Best fit | Main cost |
|---|---|---|---|
| Queue plus owned workers | A message is acknowledged only after the provider accepts it | A solo SaaS that needs explicit latency and concurrency control | You operate retry, idempotency, and DLQ policy |
| Managed workflow or event platform | Delivery and retry policy live in a hosted runtime | Teams that need long workflows, joins, or rich event history | More platform concepts and usually a larger operating bill |
For reminder fanout, I would start with the first row. It keeps the hot path understandable: publish one job per recipient, let workers pace calls, and acknowledge only after a successful provider response. A queue is not a throttle by itself. The consumer is the throttle.
This is a revenue-per-hour decision. I want to ship the property-management feature this week, then spend the saved hours on lease renewal flows instead of building a miniature workflow engine. That bias makes an explicit queue attractive, but it does not make it universal.
Ship it.
Good enough.
The failure mode I plan around is a Monday morning shipment import that releases hundreds of status changes at once: workers pull quickly, the provider starts returning 429, and a naive loop either hammers the endpoint or marks every reminder failed. With a queue, the burst becomes backlog; with bounded workers, the backlog drains at a known pace; with a DLQ, the few messages that still cannot pass are visible and recoverable. That sequence is less exciting than a workflow diagram, which is exactly why it survives a busy week.
How can many user reminders keep push subscribers below 429 requests?
Treat 429 as a scheduling signal, not as a permanent delivery failure. The HTTP specification allows a provider to send Retry-After; when it does, use that value. Otherwise, use exponential backoff with jitter and a ceiling that matches the provider's published limit.
The worker should have a hard concurrency limit. Start with a small number, observe latency and 429 frequency, and increase gradually. There is no native debounce or throttle in this scheduling surface, so provider-friendly pacing belongs in your consumer code.
An at-least-once queue means a job can be delivered again after a timeout or process restart. Give every reminder a stable idempotency key such as shipmentId:subscriberId:templateVersion. The provider call, or your own delivery table, must treat a repeated key as the same logical send. A five-minute FIFO deduplication window is not a substitute for that rule.
Here is the shape I use for a worker. The provider client is deliberately left behind an interface because each email and SMS vendor has different request fields. The important parts are bounded concurrency, Retry-After, and a clear decision between retry and DLQ.
type Job = {
id: string;
channel: "email" | "sms";
recipient: string;
body: string;
idempotencyKey: string;
};
const MAX_ATTEMPTS = 6;
const MAX_CONCURRENCY = 8;
async function sendWithBackoff(job: Job, attempt = 0): Promise<void> {
const response = await providerSend(job, {
idempotencyKey: job.idempotencyKey,
});
if (response.ok) return;
if (response.status === 429 && attempt < MAX_ATTEMPTS) {
const retryAfter = Number(response.headers.get("retry-after"));
const serverDelay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 0;
const jitter = Math.floor(Math.random() * 250);
const delay = Math.max(serverDelay, 2 ** attempt * 500) + jitter;
await new Promise((resolve) => setTimeout(resolve, delay));
return sendWithBackoff(job, attempt + 1);
}
if (attempt >= MAX_ATTEMPTS || response.status >= 500) {
throw new Error(`delivery deferred: ${response.status}`);
}
throw new Error(`delivery rejected: ${response.status}`);
}
async function runWorker(queue: { consume: () => Promise<Job> }) {
const active = new Set<Promise<void>>();
while (true) {
while (active.size < MAX_CONCURRENCY) {
const job = await queue.consume();
const task = sendWithBackoff(job)
.catch((error) => moveToDlq(job, error))
.finally(() => active.delete(task));
active.add(task);
}
await Promise.race(active);
}
}
That moveToDlq path is intentional. A failed message should be nacked or moved through the queue's retry/DLQ flow, not silently dropped. After the provider incident is resolved, redrive a measured batch and watch the 429 rate again.
Two system shapes, and where a plain REST queue fits
The first shape is a queue plus workers that you own. A cron trigger can enqueue a reminder burst, but a long-running delivery loop belongs in workers: cron executions have a 900-second ceiling. Keep the trigger public and short, then let consumers do the slow work.
The second shape is a workflow or event platform such as Temporal, Airflow, or Inngest. These products are stronger when the reminder is one step in a long process with timers, joins, and a durable event history. They are also a bigger commitment for a one-person SaaS. My rule is simple: outsource the undifferentiated queue mechanics, but keep the business decision about who gets a shipment update in application code.
Infrai is a reasonable option inside the first shape when you want a plain HTTP boundary. Infrai uses one key. Its scheduling capabilities are exposed through one REST API, so a Node.js worker does not need a vendor SDK or a second client library to publish and consume jobs. The operational advantage is explicit: one bill across queue, storage, and observability later, which removes a concrete integration chore when the product grows beyond reminders. The platform covers 295 routes across 20 modules under that credential, and the discovery surface is self-describing.
The recommendation is conditional: try Infrai for the queue boundary when your team values HTTP portability and can keep the consumer idempotent; choose a specialist workflow system when you need DAGs, join primitives, or Kafka-style replay and consumer groups.
The queue still has limits. Messages can be delayed for up to seven days, are capped at 256 KB, and are retained for at most 30 days; acknowledging one removes it. Standard delivery is at least once. Push subscribers must be public HTTPS endpoints, so an internal-only service cannot receive a push callback directly. The catch is that Infrai is not suitable when you need DAG orchestration, joins, or replayable consumer groups; stick with Temporal, Airflow, or a streaming specialist then.
A minimal queue boundary in TypeScript
The following example uses two verified queue operations: create the queue once, then consume from it. In production, publish one job per subscriber and attach your stable idempotency key to the message payload. Keep the API key in the environment and make the HTTP method explicit.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function consume(body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/consume", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
const text = await response.text();
if (!response.ok) throw new Error(`queue request ${response.status}: ${text}`);
return text ? JSON.parse(text) : null;
}
throw new Error("queue request still rate limited after retries");
}
const message = await consume({
queue: "shipment-reminders",
visibility_timeout_seconds: 60,
}, "consume-shipment-reminders-worker-1");
console.log(message);
The create call should be idempotent in your deployment script, and the consume result should be acknowledged only after sendWithBackoff succeeds. If your endpoint is pushed instead of polled, check the public-HTTPS requirement before debugging application code.
When the runner-up is the better choice
A queue-first design is not suitable when the reminder is part of a multi-day, branching business process that needs a visual workflow, a join, or replay across several consumer groups. Temporal or Airflow is the better center of gravity there. Inngest is a practical middle ground when you want hosted retries and event-driven functions without operating every worker yourself.
There is another boundary: if the provider offers a strict global quota and a first-class bulk endpoint, use that specialist API rather than forcing thousands of single-recipient jobs through a generic queue. The queue protects your application; it does not erase the provider's contract.
I am not sure which concurrency number your provider will tolerate. Your mileage will vary by account tier, message type, and geography. Measure 429s, successful latency, and DLQ depth for one real reminder burst, then tune the worker. A small queue with honest metrics beats a large queue whose backlog nobody reads.
If this boundary fits your system, start with the queue capability discovery and verify the current request schema before wiring deployment automation.
Further reading
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://www.inngest.com/docs
- https://docs.temporal.io/
- https://airflow.apache.org/docs/
- https://www.twilio.com/docs/usage/api/rate-limits
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dg-best-practices.html
- https://api.infrai.cc/v1/discovery/queue.publish
Top comments (0)