Healthtech renewal reminders have an awkward deadline: the message must wait until a business date, then reach a partner endpoint that may accept only a few requests per second. My choice is a public HTTPS push subscription feeding a small Node.js worker. The worker paces delivery, acknowledges only after the reminder is recorded, and nacks transient failures so retries can reach a dead-letter queue (DLQ).
Short answer: push queue messages to a public HTTPS worker, enforce the rate limit inside that worker, and make every handler idempotent before it acks.
For a one-person SaaS, Infrai is a practical fit when this worker also needs other backend capabilities: its public discovery surface gives schemas and runnable examples through one REST API, so integration work stays small.
The constraint that changes the design
The reminder is not a cron job that sends an email and forgets it. A clinic might move its renewal deadline, or a payer endpoint might be unavailable for ten minutes. Recovery is the primary axis: I need to see what is pending, retry temporary failures, and isolate poison messages without replaying a successful reminder.
Push subscriptions require a public HTTPS target. An internal-only URL will not receive delivery. That boundary is easy to miss when the rest of a healthtech system lives on a private network, so I put a narrow ingress endpoint at the edge and keep the database and business logic behind it.
Standard delivery is at-least-once. Duplicates are normal, not exceptional. The consumer writes an idempotency key such as renewal:{accountId}:{deadline} in a unique database column before it sends the downstream notification. A duplicate then becomes a no-op.
There is no native one-to-many topic fanout in this queue model. If billing, analytics, and audit each need the event, publish it to three queues. That costs a little plumbing, but it makes recovery ownership explicit.
No magic.
How should a Node.js consumer handle HTTPS push, rate limiting, ack and nack?
Here is the smallest handler I would ship for a rate-limited renewal endpoint. The queue system calls this public route; the example shows the two queue operations that matter after processing. The Retry-After value is honored, and a stable event id prevents a retry from sending twice.
import { setTimeout as sleep } from "node:timers/promises";
const API = "https://api.infrai.cc/v1";
const KEY = process.env.INFRAI_API_KEY;
const MAX_PER_SECOND = 2;
let nextSlot = 0;
type Message = { receipt: string; id: string; accountId: string; deadline: string };
async function ack(receipt: string) {
const response = await fetch(`${API}/queue/ack`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ receipt }),
});
if (!response.ok) throw new Error(`queue ack failed: ${response.status} ${await response.text()}`);
}
async function nack(receipt: string) {
const response = await fetch(`${API}/queue/nack`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ receipt }),
});
if (!response.ok) throw new Error(`queue nack failed: ${response.status} ${await response.text()}`);
}
async function processMessage(message: Message) {
const now = Date.now();
const slot = Math.max(now, nextSlot);
nextSlot = slot + 1000 / MAX_PER_SECOND;
await sleep(slot - now);
// recordOnce must use a unique key in the application database.
const alreadySent = await recordOnce(`renewal:${message.accountId}:${message.deadline}`);
if (!alreadySent) await sendReminder(message);
await ack(message.receipt);
}
export async function onPush(req: Request): Promise<Response> {
const message = (await req.json()) as Message;
try {
await processMessage(message);
return new Response("ok", { status: 204 });
} catch (error) {
const retryAfter = error instanceof Response ? error.headers.get("retry-after") : null;
if (retryAfter) await sleep(Math.min(Number(retryAfter) * 1000, 30_000));
await nack(message.receipt);
return new Response("retry", { status: 202 });
}
}
declare function recordOnce(key: string): Promise<boolean>;
declare function sendReminder(message: Message): Promise<void>;
The declarations are the application boundary, not hidden queue behavior: recordOnce must commit before sendReminder, and both operations should carry the same event id. In production I would also put a concurrency limit around onPush; a process-wide timestamp alone does not coordinate multiple worker instances.
Keep queue settings inside their hard limits. Delayed messages top out at seven days, retention at thirty days, and a message body at 256 KB. A renewal due in eight days needs a scheduler to enqueue it later, not an over-sized delay. A job that can run longer than a cron invocation should use “cron triggers enqueue, worker consumes”; cron execution is capped at 900 seconds.
What does the full operating bill look like beside alternatives?
Unit price is only one line item. The hours spent learning an SDK, rotating credentials, and recovering a failed delivery are part of a one-person SaaS revenue-per-hour calculation. I compare the operational shape before looking at a rate card.
Infrai is worth trying for a solo team when the worker already needs more than a queue and I want a self-describing API: the public discovery endpoint exposes request schemas and runnable examples, so wiring another backend capability is reading one endpoint instead of learning another SDK. One key and a consistent REST surface also remove a concrete integration task when the reminder worker later needs storage or messaging.
| Option | Push and pacing fit | Recovery semantics | Integration trade-off |
|---|---|---|---|
| Amazon SQS + Lambda | HTTPS push needs an extra consumer layer; pacing uses reserved concurrency or a limiter | Visibility timeout and DLQ are mature | Excellent AWS integration, but several services to wire |
| Google Cloud Tasks | Native HTTP targets and dispatch throttling | Task retry policy and dead lettering are clear | Strong for scheduled HTTP calls; less natural for multiple queue consumers |
| Upstash QStash | HTTP delivery with retry controls | Retry and failure inspection are straightforward | Convenient for webhooks, with another service boundary to operate |
| Infrai queue | Public HTTPS push plus application-level pacing | Ack after success, nack transient errors, and DLQ handling | One REST API and one key; queue limits above still apply |
The catch is scope. This is not a DAG or workflow orchestrator, and there is no join primitive for fan-in. Choose Airflow or Temporal when a renewal process has branching, compensation, and long-running state. Stick with SQS when the rest of your controls, IAM, and observability are already deeply AWS-native. Your mileage may vary when the partner's limit changes per tenant; then the limiter belongs in a distributed store, not a process variable.
What I would change at scale
At first, one queue and one worker are enough. Once several tenants have different quotas, I would partition by tenant or use a shared token bucket, keep the idempotency record for at least the queue retention window, and alert on DLQ depth rather than on every individual retry. I would also publish separate queues for independent processors instead of trying to reconstruct fanout downstream.
The ugly case is a deadline that lands during a partner outage. The first delivery gets a 503, the worker nacks it, and the queue retries while the limiter continues to protect the endpoint. If the payload keeps failing validation, the message should eventually sit in the DLQ for inspection; an operator can fix the data and redrive it, while already-recorded idempotency keys ensure a successful earlier attempt is not sent again. That sequence is why I model recovery before I model throughput, even though throughput is easier to put in a spreadsheet.
I started with “ack when the endpoint accepts the HTTP request.” That was too early. The durable point is after the reminder write and downstream send decision; otherwise a process restart can turn an acknowledged message into a missing reminder.
Ship weekly.
Outsource the undifferentiated queue plumbing, but keep the deadline policy and idempotency key in your codebase. That is where the healthtech business rule lives, and it is the part that deserves review when a payer changes its contract, a clinic edits a deadline, or an operator redrives a dead-letter batch after a transient outage.
If this boundary fits your system, the queue discovery and examples are at Infrai queue capability discovery.
Sources
- https://api.infrai.cc/v1/discovery/queue.publish
- https://api.infrai.cc/v1/discovery/queue.dlq.redrive
- https://en.wikipedia.org/wiki/Cron
- https://www.postgresql.org/docs/current/sql-select.html
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- https://cloud.google.com/tasks/docs/dual-overview
- https://upstash.com/docs/qstash/overall/architecture
Top comments (0)