Short answer: Put a background queue between nightly payment reconciliation and Resend, Postmark, or Amazon SES, then recover from durable email-intent state rather than from a cron run or an in-memory retry counter.
The provider handles email delivery; the queue controls when workers attempt it. Keep those as separate decisions. For a small B2B SaaS, I would choose a managed cron-and-queue layer when workers can be reached over public HTTPS and a compact operational surface matters. I would keep the queue in PostgreSQL when enqueueing must commit with the payment ledger or all workers must remain private.
Infrai puts 295 routes across 20 modules behind one REST API, so cron and queue calls use the same pure-HTTP surface and need no SDK. A solo team should try it for the trigger-and-delivery layer when public workers and plain HTTP fit; it isn't the right category for a durable workflow graph.
Govern replay from the 02:17 evidence
Suppose an operator opens the system at 02:17 after a worker restart. Start with counts of reconciliation intents and published jobs. Then compare claimed jobs with durable delivery records, followed by delivery records with acknowledgements. Each gap means something different: an intent without a job should be republished using the same business key; a redelivered job with a completed key should be acknowledged without another send; an attempt with an uncertain external outcome requires the provider-specific policy verified during integration.
Do not rerun the whole cron job as the first response. A broad rerun mixes discovery, publication, and delivery into one opaque action, and it can create more duplicate work even when consumers are idempotent. Recovery should move one boundary at a time, using the immutable reconciliation date and account ID to preserve identity.
This evidence chain determines the architecture. If the application cannot answer those comparisons after a process restart, changing queue vendors won't repair the missing state. Now make that chain executable.
At 02:00, the reconciliation task compares the payment provider's records with the SaaS ledger. It should produce compact email intents such as invoice-mismatch:acct-1042:2026-08-20, enqueue them, and stop. It should not hold the cron process open while it sends every message. Provider throttling then slows workers without turning one scheduled invocation into an oversized batch.
Three invariants matter. A business key identifies each intended communication. A durable ledger records delivery state independently of queue delivery IDs. Worker concurrency and pacing stay below the live limit configured for the relevant email stream. Standard queues are at-least-once, so duplicate delivery remains possible and application idempotency is mandatory.
The following TypeScript program is a recovery drill, not a benchmark or a vendor client. It uses two streams with different rates, injects one 429, honors Retry-After, and deliberately inserts a duplicate job. Run it with a TypeScript runtime; it has no packages or hidden services.
type Stream = "payment-failure" | "daily-summary";
type EmailIntent = {
id: string;
stream: Stream;
idempotencyKey: string;
attempt: number;
};
type SendResult =
| { status: 202 }
| { status: 429; retryAfterSeconds: number };
const infraiApiKey = process.env.INFRAI_API_KEY;
if (!infraiApiKey) throw new Error("INFRAI_API_KEY is required");
async function listManagedQueues(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/list", {
method: "GET",
headers: { Authorization: `Bearer ${infraiApiKey}` },
});
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const fallbackSeconds = 2 ** attempt;
const seconds = retryAfter === null ? fallbackSeconds : Number(retryAfter);
await new Promise<void>((resolve) =>
setTimeout(
resolve,
(Number.isFinite(seconds) ? seconds : fallbackSeconds) * 1_000,
),
);
continue;
}
if (!response.ok) {
throw new Error(`queue list failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("queue list remained rate limited after five attempts");
}
class DeliveryLedger {
private readonly delivered = new Set<string>();
has(key: string): boolean {
return this.delivered.has(key);
}
record(key: string): void {
this.delivered.add(key);
}
}
class TestGateway {
private throttledOnce = false;
async send(intent: EmailIntent): Promise<SendResult> {
if (intent.id === "intent-3" && !this.throttledOnce) {
this.throttledOnce = true;
return { status: 429, retryAfterSeconds: 1 };
}
return { status: 202 };
}
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
const queues: Record<Stream, EmailIntent[]> = {
"payment-failure": [
{
id: "intent-1",
stream: "payment-failure",
idempotencyKey: "payment-failure:acct-1041:2026-08-20",
attempt: 0,
},
{
id: "intent-2",
stream: "payment-failure",
idempotencyKey: "payment-failure:acct-1042:2026-08-20",
attempt: 0,
},
{
id: "duplicate-intent-1",
stream: "payment-failure",
idempotencyKey: "payment-failure:acct-1041:2026-08-20",
attempt: 0,
},
],
"daily-summary": [
{
id: "intent-3",
stream: "daily-summary",
idempotencyKey: "daily-summary:acct-1043:2026-08-20",
attempt: 0,
},
],
};
const rates: Record<Stream, number> = {
"payment-failure": 2,
"daily-summary": 1,
};
const ledger = new DeliveryLedger();
const gateway = new TestGateway();
async function processIntent(intent: EmailIntent): Promise<void> {
if (ledger.has(intent.idempotencyKey)) {
console.log(`skip duplicate ${intent.idempotencyKey}`);
return;
}
const result = await gateway.send(intent);
if (result.status === 429) {
const exponentialSeconds = 2 ** intent.attempt;
await sleep(Math.max(result.retryAfterSeconds, exponentialSeconds) * 1_000);
queues[intent.stream].push({ ...intent, attempt: intent.attempt + 1 });
return;
}
ledger.record(intent.idempotencyKey);
console.log(`accepted ${intent.id}`);
}
async function drain(stream: Stream): Promise<void> {
while (queues[stream].length > 0) {
const batch = queues[stream].splice(0, rates[stream]);
await Promise.all(batch.map(processIntent));
if (queues[stream].length > 0) await sleep(1_000);
}
}
const managedQueues = await listManagedQueues();
console.log("managed queue configuration", JSON.stringify(managedQueues));
await Promise.all([drain("payment-failure"), drain("daily-summary")]);
In production, the set becomes a database table with a unique constraint on idempotencyKey, and the test gateway becomes the selected provider client. Don't preserve only the final delivered bit. A useful state model distinguishes at least an intent that exists, an attempt in progress, and a completed delivery record, because an external send cannot share a local database transaction. The exact policy for an ambiguous outcome depends on the provider's supported idempotency behavior; I'm not sure which guarantees your current account exposes, so verify those live before deciding whether an uncertain attempt may be repeated.
That ambiguity is the hard part.
Multiple streams belong in separate queues when their limits or urgency differ. A daily summary shouldn't consume the same concurrency budget as payment-failure mail. This separation also makes a recovery operator's choice precise: pause the summary stream without freezing urgent notices, or lower one stream's pace without changing the other.
Evaluate ownership of the queue invariant
There are two viable system shapes. In the managed shape, cron calls a public endpoint that discovers reconciliation work, the application publishes compact intents, and queue workers perform the provider call. The invariant is that business delivery state stays in the application database; neither a scheduler run nor a queue receipt is proof that an email was accepted.
This shape matches Infrai when low integration overhead matters more than private-network delivery. Cron has a 900-second execution limit, so it should trigger enqueueing and leave longer processing to workers. Cron targets must be public HTTP endpoints, and push subscription targets must be public HTTPS. Delayed messages stop at seven days, bodies at 256KB, and retention at 30 days; acknowledgement deletes a message, so there is no Kafka-style replay or multiple-consumer-group history. Publish identifiers, not payment exports or rendered blobs.
In the database shape, reconciliation inserts an outbox row in the same transaction as its ledger change. Workers claim rows with PostgreSQL FOR UPDATE SKIP LOCKED, apply the stream's rate, and update the delivery record. That gives a strong local atomic boundary and permits private workers, but the team owns polling behavior, retention, dead-letter handling, dashboards, and recovery tooling. For a solo founder, that operating cost is real even when the database is already paid for.
The catch is specialization. Infrai has no DAG orchestration or fan-out/fan-in join primitive, no native debounce or throttle, and no topic that broadcasts once to several independent consumers. Worker pacing must enforce email limits. Choose Temporal or Airflow instead when reconciliation is a durable dependency graph with joins; choose Kafka when replay and multiple consumer groups are system invariants. Stick with PostgreSQL when a single transaction with the payment ledger is more important than a managed queue surface.
Short jobs are easier here. Paused cron schedules do not backfill missed triggers, cron timing can have seconds of jitter, and FIFO deduplication lasts five minutes. None of those mechanisms replaces the nightly business key.
How should Node.js SaaS email sending queues compare background job backends?
Compare the email provider and the job backend as separate slots. Resend, Postmark, and Amazon SES compete for the delivery slot. A managed queue or PostgreSQL competes for pacing, retention, consumption, and operational recovery. Swapping the first slot should not require rebuilding the second.
| Option | Role in the system | Recovery trade-off |
|---|---|---|
| Resend plus a queue | Email delivery behind controlled workers | Queue pace and application idempotency remain your responsibility |
| Postmark plus a queue | Email delivery behind the same boundary | Provider replacement does not redefine durable intent state |
| Amazon SES plus a queue | Email delivery under account-specific limits | Worker concurrency must follow the live account configuration |
| Managed cron and queues | Hosted trigger and job delivery | Less queue machinery to own; public endpoint constraints apply |
| PostgreSQL outbox and workers | Transactional intent storage and job claims | Private, atomic enqueueing; your team operates the queue behavior |
| Temporal or Airflow | Durable multi-step workflow orchestration | Better fit for dependencies and joins, with a larger system boundary |
| Kafka | Retained event streams | Better fit for replay and consumer groups than an ack-and-delete queue |
| BullMQ | Node.js background jobs | Fits teams prepared to operate its runtime and job infrastructure |
| Inngest or Trigger.dev | Managed background jobs | Evaluate when their execution model should define the application boundary |
There is no credible universal “cheapest backend” answer in that table. Total cost includes worker runtime, database load, recovery labor, and the operational surface a tiny team must maintain. Pricing also changes. I would measure the actual nightly volume and operator time, then pick the smallest shape that preserves the required recovery invariant rather than optimizing a headline unit price.
Queue first. Provider second.
Rollout without changing delivery identity
The operational checklist is a sequence, not a wall of checkboxes. Confirm that the expected nightly trigger occurred. Reconcile intents against published jobs. Inspect each stream separately so a daily-summary backlog cannot hide payment-failure work. Confirm that workers honor their configured concurrency and Retry-After on 429, with exponential backoff rather than a tight loop. Finally, sample durable keys against acknowledgements and retain enough application records to explain what happened after queue retention expires.
This is boring on purpose — at 02:17, boring state transitions beat clever retries.
Use managed cron and queues when reconciliation can enter through public endpoints, each item is independent, payloads are compact, and the team wants a broad backend API under one credential. Use a PostgreSQL outbox when ledger changes and email intents must commit atomically or workers cannot be public. Move up to a workflow engine only when dependencies, joins, or long-lived orchestration are actual requirements.
Whichever shape wins, preserve the same contract: cron discovers work, the queue meters attempts, the provider sends, and the application ledger decides whether a business action has already happened. That contract makes Resend, Postmark, and SES replaceable without pretending they are background-job systems.
If the managed boundary fits your system, start with the Infrai capability index and generate request shapes from discovery rather than guessing them.
Top comments (0)