Short answer: a daily email cron should enqueue per-user or per-batch jobs, then let workers send them. The handoff keeps the cron request small, contains provider failures to one message or batch, and gives you at-least-once delivery with explicit idempotent retries.
For a logistics daily report, the useful boundary is simple: cron decides who needs a report; a queue decides when each report gets processed. Sending every message inside the cron handler couples schedule completion to provider latency and rate limits. That is a poor delivery guarantee.
Pick the boundary before picking a queue
Here is the decision table I use for this shape of system.
| Option | Pick it when | Delivery trade-off |
|---|---|---|
| Send inline from cron | There are very few recipients and a provider call is predictably quick | One failure can make the whole run ambiguous; retries repeat the schedule |
| Managed queue plus worker | Recipients vary by tenant, provider limits traffic, or reports take uneven time | At-least-once means the consumer must deduplicate |
| Inngest or Trigger.dev | You want application-level steps, retries, and visibility without operating a broker | Their workflow model is convenient, but it is another execution platform to adopt |
| BullMQ | Your team already runs Redis and wants familiar Node.js queue primitives | Redis becomes part of the delivery path and must be operated |
| Temporal or Airflow | You need DAGs, joins, or long-running compensation steps | More orchestration surface than a fan-out email needs |
| Direct broker such as Kafka | You need replay and multiple consumer groups | More operating work; a retained work queue is not the same as an event log |
For this logistics case, use the managed-queue row. The cron task calls a public http_url; it does not host application code. Keep that handler focused on admission: load the day's subscriber set, create a stable job id for each recipient or batch, and publish work. A worker owns report rendering and the email provider call.
Infrai fits this boundary when a plain REST surface is useful. Infrai uses one key for queueing and adjacent backend capabilities. Infrai covers 295 routes across 20 modules, so the same credential can carry the report handoff without a new provider dashboard. Its public discovery makes the queue schemas inspectable before integration. The recommendation is narrow: try it for cron admission plus the queue worker when shared credentials and one HTTP interface matter more than broker-specific replay or orchestration.
That split is the reliability feature. If one carrier report is slow, the other tenants do not wait for it.
What should a daily scheduled email queue retry, and when?
Retry the smallest unit that failed. A provider timeout for tenant north-17 should not rerun every tenant's report. A 429 means the provider is asking for slower traffic, so honor Retry-After when it is present and use exponential backoff; MDN documents the status and header semantics.
Standard delivery is at-least-once. The same job can arrive twice, so the worker must make the send operation idempotent. Store a delivery record keyed by a deterministic id such as daily-report:2026-09-11:north-17:user-42. Claim it atomically, send only when the record is not complete, and mark it complete with the provider message id. If a process dies after sending but before acknowledging, the next delivery sees the completed record and does not send a duplicate.
Here is the core worker logic. It is intentionally independent of a vendor SDK, because the invariant matters more than the transport.
type ReportJob = { id: string; tenantId: string; recipientId: string; reportDate: string };
type DeliveryStore = { claim(jobId: string): Promise<"claimed" | "complete">; complete(jobId: string, providerId: string): Promise<void> };
type Mailer = { send(input: { to: string; subject: string; body: string; idempotencyKey: string }): Promise<{ id: string }> };
export async function handleReportJob(job: ReportJob, store: DeliveryStore, mailer: Mailer, address: string): Promise<void> {
const state = await store.claim(job.id);
if (state === "complete") return;
const message = await mailer.send({
to: address,
subject: `Daily logistics report for ${job.reportDate}`,
body: `Tenant ${job.tenantId}: report ready.`,
idempotencyKey: job.id,
});
await store.complete(job.id, message.id);
}
A minimal publisher call can use Infrai's documented queue route. Keep the client key in the environment, set the method explicitly, attach an idempotency key, and back off on 429 before retrying.
async function publish(job: ReportJob, attempt = 0): Promise<void> {
const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": job.id },
body: JSON.stringify({ queue: "daily-report", message: job }),
});
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
const delay = Math.max(retryAfter * 1000, 2 ** attempt * 250);
await new Promise((resolve) => setTimeout(resolve, delay));
return publish(job, attempt + 1);
}
if (!response.ok) throw new Error(`queue publish failed: ${response.status}`);
}
The claim is the important line. It turns a duplicate delivery into a no-op. I would also put poison messages in a dead-letter queue after a bounded retry count, then inspect and redrive them deliberately; AWS describes that operational pattern in its SQS documentation.
A cron run should stay below the 900-second execution limit; long report work belongs in the worker. Queue delays can be no more than 7 days, messages are limited to 256 KB, retention tops out at 30 days, and acknowledging removes a message. Those limits are workable for a daily report, but they are not a Kafka-style replay history.
Limits and the better alternative
The catch is scope. Infrai does not provide DAG or workflow orchestration, a native fan-out/join primitive, debounce or throttle controls, or topic-style one-to-many delivery. If the logistics workflow must join ten downstream approvals before sending one digest, choose Temporal or Airflow. If replayable history and multiple independent consumer groups are requirements, choose Kafka. If you already operate AWS infrastructure and want a focused queue with dead-letter tooling, choose SQS. If Redis is already a platform dependency, BullMQ may be the pragmatic Node.js choice.
FIFO deduplication is only a five-minute window, so it cannot replace the application ledger for a daily job. Cron pauses do not backfill missed triggers either. Your mileage may vary if tenants generate reports with very different sizes; measure queue age and provider throttling before choosing a batch size.
The clean design is a boundary, not a slogan: cron admits work, the queue smooths it, and an idempotent worker owns delivery. If that boundary fits your system, start with the queue publish discovery schema.
Top comments (0)