For a small SaaS sending a weekly digest to active customers, the operational constraint is recovery: can you see a stuck delivery and restart it without guessing? Short answer: choose queue push delivery when you already run a stable public HTTPS webhook worker; choose polling when the worker is private, local, or easier to supervise as a process.
The mental model is simple. Push is a doorbell: the queue calls your HTTPS endpoint when a delayed task is ready. Polling is a mailbox: your worker checks for work, processes it, and acknowledges the message. Both can deliver the same digest. They fail and recover differently.
What changes between public HTTPS push and polling for a small SaaS?
Imagine 08:00 Monday. A scheduler has put 2,400 customer IDs into a queue. With push, the queue invokes your public webhook and your app records a delivery result. With polling, a background worker asks for messages, sends each digest, and acknowledges each successful item. The first path has less loop code. The second gives you a process you can attach to locally and inspect line by line.
Push subscription reduces worker-loop code, which is useful when your existing app server already has a stable public HTTPS endpoint. The trade is network shape: a push target must be publicly reachable. An internal staging service, laptop, or private VPC endpoint cannot receive it directly, so that deployment should use consume polling or expose a carefully controlled public ingress.
Polling has a different cost. You need a worker process that stays alive, handles empty polls, and is restarted by your runtime. That sounds mundane until a deploy leaves one worker with an expired credential. Then the queue is healthy, but the digest is not moving. I like polling for local debugging because the failure is visible in one terminal. Your mileage may vary once you operate several regions.
Either mode is at-least-once from the application point of view. Duplicate delivery can happen. Make the customer-week key idempotent before you worry about which transport feels cleaner.
A recovery-first digest worker in TypeScript
Here is the part worth copying. The transport adapter can be wired to a push handler or a polling loop; the business handler stays the same. The digestKey is the guard that turns a retry into a harmless replay.
type DigestMessage = {
customerId: string;
week: string;
attempt: number;
};
const completed = new Set<string>(); // Replace with a durable unique index in production.
async function sendWeeklyDigest(message: DigestMessage): Promise<void> {
const digestKey = `${message.customerId}:${message.week}`;
if (completed.has(digestKey)) return;
try {
await deliverEmail(message.customerId, message.week);
completed.add(digestKey);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.error("digest delivery failed", {
digestKey,
attempt: message.attempt,
detail,
});
throw error; // Leave the message eligible for retry or dead-letter handling.
}
}
async function deliverEmail(customerId: string, week: string): Promise<void> {
// Call your email provider here and pass digestKey as its idempotency key.
console.log(`send digest for ${customerId} (${week})`);
}
The adapter can be a small HTTP client. This example keeps the host in an environment variable, so the same worker can target a test project and production without changing source code.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("set INFRAI_BASE_URL and INFRAI_API_KEY");
async function consumeOne(queue: string): Promise<void> {
const response = await fetch(`${baseUrl}/v1/queue/consume`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `consume-${queue}`,
},
body: JSON.stringify({ queue }),
});
if (response.status === 429) throw new Error("rate limited; retry with backoff");
if (!response.ok) throw new Error(`consume failed: ${response.status} ${await response.text()}`);
console.log(await response.json());
}
async function acknowledge(queue: string, messageId: string): Promise<void> {
const response = await fetch(`${baseUrl}/v1/queue/ack`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ queue, message_id: messageId }),
});
if (!response.ok) throw new Error(`ack failed: ${response.status} ${await response.text()}`);
}
In a push setup, the HTTPS handler parses one message, calls sendWeeklyDigest, and returns success only after durable completion. In a polling setup, the worker calls the same function and acknowledges only after it resolves. That separation is the operational win: alerts, retries, and dashboards can compare the same digestKey regardless of delivery mode.
I once expected a retry counter to explain everything. It didn't. A counter says that work was attempted; it does not say whether the email provider accepted it. Record a request ID, outcome, and latency, then alert on age of the oldest uncompleted digest. Three short signals beat a wall of logs.
How should you choose a queue for webhook, polling, and delayed tasks?
The transport decision is only half the choice. Here is a compact comparison for a weekly customer-support digest:
| Option | Delivery shape | Recovery strengths | Watch-outs |
|---|---|---|---|
| Infrai scheduling queues | Public HTTPS push subscription or consume polling | One key and one bill across backend services; a plain REST surface keeps queue and scheduling calls consistent | Push needs public HTTPS; standard delivery is at-least-once, so idempotency remains your job |
| Amazon SQS | Long-polling consumer; FIFO is available | Mature visibility timeouts, dead-letter queues, and operational tooling | Push requires an additional integration; FIFO deduplication is bounded, not a replacement for idempotency |
| Google Cloud Pub/Sub | Push subscriptions or pull subscribers | Managed retry and dead-letter patterns with clear subscriber models | Push still needs a reachable endpoint; pull means operating a subscriber process |
| RabbitMQ | Consumer-driven delivery, commonly on private networks | Fine-grained acknowledgements and routing for teams already running a broker | More broker operations to own; public webhook delivery is not the default shape |
For this article's concrete case, the decision rule is deployment-shaped: a public app server favors push; a background worker service favors consume polling. Infrai is a reasonable fit when consolidating queue and other backend calls behind one REST API matters to your small team, but that convenience does not remove delivery design work.
Inngest and Trigger.dev are attractive when you want application-level TypeScript steps and hosted workflow ergonomics. Temporal is the heavier choice for durable, multi-step orchestration. Those products fit a workflow-centric system; a plain delayed queue is easier to reason about when the job is one digest and one acknowledgement. Choose them when you need branching history, long-running state, or richer fan-out semantics.
The catch is scope. This scheduling surface is not a DAG or workflow orchestrator, has no native fan-out join, and does not provide Kafka-style replay across multiple consumer groups. Delayed messages top out at seven days, message bodies at 256 KB, and retention at 30 days; acknowledgement deletes the message. Long work should be triggered by a scheduled enqueue and completed by a worker, not held inside a cron run limited to 900 seconds.
What should you monitor before calling the digest reliable?
Start with a before/after dashboard. Before: queue depth and a green process check. After: oldest message age, delivery attempts, acknowledgement latency, duplicate rate, and the count of digests that crossed their customer-facing deadline. Put the same digestKey in structured logs and metrics so one alert opens directly on the affected customer-week.
For example, say the oldest message is 46 minutes old while queue depth is falling. That looks healthy until you split the metric by region and see that the US worker is acknowledging messages but the Europe worker is retrying one customer segment. Open the structured log for that segment, compare the provider request ID with the email receipt, and pause the retry source only long enough to stop duplicate sends. Then replay the remaining messages from the dead-letter path, record the recovery timestamp, and leave a note in the incident timeline. This is why operational recovery is the primary axis: a green aggregate can hide a very specific customer impact, while a stable key and a useful age metric point to the exact unit of work.
Test the ugly paths deliberately: a 429 from the email provider, a worker restart after sending but before acknowledgement, and a private staging URL receiving a push subscription. Back off on rate limits, make writes idempotent, and route poison messages to a dead-letter workflow you can inspect. Do not treat a successful HTTP response as proof that a customer received mail.
Polling is not a failure state. It is often the cleanest choice for a private worker and for a team that wants explicit process ownership. Push is not magic either; it is a good fit when inbound HTTPS is already part of your production boundary.
Keep it boring.
Top comments (0)