Daily report email to a huge recipient list: cron trigger plus queue fan-out

Send a scheduled daily report to tens of thousands of recipients: a cron trigger that only enqueues, chunked fan-out, pacing, retries and a dead-letter queue.

The pattern that survives a 40,000-recipient list is boring: the scheduled trigger publishes work and exits, and a separate worker does the sending. On Infrai that means POST /v1/queue/publish from the cron handler and POST /v1/queue/consume in a long-lived process, with unacked messages redelivered three times before they land in a dead-letter queue. Your scheduler never touches the mail provider at all.

The failure you’re designing against is one 20-minute loop that dies at recipient 12,300 with no durable record of who already received today’s report.

The cron handler’s only job is to enqueue

A scheduled handler that sends email directly has three problems stacked on top of each other. It has a wall-clock budget (most serverless cron runners cap out well before a large send finishes), it has no memory of partial progress, and a retry of the trigger re-sends everything from the top. Splitting the trigger from the send fixes all three, because the queue becomes the progress record.

Publish one chunk and you’ll see the shape immediately:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"daily-report-fanout","body":{"report_date":"2026-07-26","chunk":1,"recipient_ids":["u_1001","u_1002"]}}'

You don’t have to create the queue first — a publish to an unknown name creates it with defaults, which we confirmed on 2026-07-26. The defaults matter here, so read them once with GET /v1/queue/get/{queue}: 14-day retention, a 256 KB message ceiling, a 300-second visibility timeout, three deliveries before dead-lettering, and a companion DLQ named daily-report-fanout.dlq.

{
  "ok": true,
  "data": {
    "message_id": "qmsg_PdBdedHTNrlbrJpiwXlbFxde",
    "queue": "daily-report-fanout",
    "payload": { "report_date": "2026-07-26", "chunk": 1, "recipient_ids": ["u_1001", "u_1002"] },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T01:06:37.456587Z"
  },
  "metadata": {
    "cost_usd": 0.00002,
    "warnings": ["'body' is not a field of this endpoint; interpreted it as 'payload'. Use 'payload' directly to silence this warning."]
  }
}

Note the asymmetry: you send body, you read back payload. Both spellings are accepted on the way in, and the response tells you which one the server prefers.

Sizing the chunk

How many recipients belong in one message is the only real design decision, and it’s a trade-off between blast radius and publish volume.

Fan-out granularityMessages for 40,000 recipientsBlast radius of one failurePick it when
One message per recipient40,000One person’s report retriedPer-recipient rendering is expensive or personalised
One message per 200 recipients200200 reports re-sent on retryDefault — needs recipient-level dedupe downstream
One message for the whole list1Everything, and it won’t fitNever for a list this size

The third row isn’t rhetorical. A message over roughly 256 KB is rejected, so a single payload carrying 40,000 addresses fails outright — see QUEUE_MESSAGE_TOO_LARGE. Worth flagging that the rejection we observed came back as a confusing “already exists” style message rather than a clean size error, so check the payload size yourself before you publish.

Chunks of 100–500 are the practical band. We’d start at 200.

The producer

import process from "node:process";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");

const QUEUE = "daily-report-fanout";
const CHUNK = 200;

async function publish(chunkIndex, recipientIds, reportDate) {
  const res = await fetch(`${BASE}/v1/queue/publish`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      queue: QUEUE,
      body: { report_date: reportDate, chunk: chunkIndex, recipient_ids: recipientIds },
    }),
  });
  const out = await res.json();
  if (!res.ok || out.ok === false) {
    throw new Error(`publish chunk ${chunkIndex} failed: ${out.error?.code ?? res.status}`);
  }
  return out.data.message_id;
}

export async function enqueueDailyReport(allRecipientIds) {
  const reportDate = new Date().toISOString().slice(0, 10);
  const ids = [];
  for (let i = 0; i < allRecipientIds.length; i += CHUNK) {
    const slice = allRecipientIds.slice(i, i + CHUNK);
    ids.push(await publish(ids.length + 1, slice, reportDate));
  }
  console.log(`enqueued ${ids.length} chunks for ${reportDate}`);
  return ids;
}

const recipients = Array.from({ length: 1000 }, (_, n) => `u_${1000 + n}`);
await enqueueDailyReport(recipients);

Five chunks a second is plenty; the handler for a 40,000-person list finishes in a couple of seconds and returns. That’s what makes it safe to run under a 60-second serverless cron limit. If your trigger platform is stricter still, publish the chunk list from a durable job table and let the worker page through it — the queue doesn’t care who publishes.

The worker, paced to the sending quota

Your mail provider, not the queue, sets the speed limit. Amazon SES publishes a per-second sending rate per account (sending quotas), and blowing through it earns throttling rather than delivery. Consume in batches, send at your measured rate, ack what succeeded.

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"daily-report-fanout","max_messages":10}'
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
const QUEUE = "daily-report-fanout";
const SENDS_PER_SECOND = 14;

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code}`);
  return out.data;
}

async function sendReport(recipientId, reportDate) {
  await sleep(1000 / SENDS_PER_SECOND);
  console.log(`rendered and sent ${reportDate} report to ${recipientId}`);
}

let running = true;
process.on("SIGTERM", () => { running = false; });

while (running) {
  const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  if (items.length === 0) { await sleep(2000); continue; }
  for (const msg of items) {
    const { recipient_ids: recipientIds, report_date: reportDate } = msg.payload;
    try {
      for (const id of recipientIds) await sendReport(id, reportDate);
      await call("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
    } catch (err) {
      console.error(`chunk ${msg.payload.chunk} attempt ${msg.delivery_count} failed: ${err.message}`);
    }
  }
}

A chunk that throws is simply never acked, so it reappears once the 300-second lease lapses. delivery_count on the redelivered copy tells you which attempt you’re on, and after the third the message moves to daily-report-fanout.dlq on its own.

The retry story has a real limitation: attempts are fixed and evenly spaced, roughly six seconds apart in our testing, with no exponential ladder. For a provider outage that lasts an hour, three fast attempts will exhaust themselves and dead-letter the work. Building a proper backoff ladder means republishing with a per-message delay, which we cover in retrying failed jobs with exponential backoff.

Confirm the run actually drained

curl -sS "https://api.infrai.cc/v1/queue/stats/daily-report-fanout" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

You want available_count and in_flight_count at zero and dlq_count at zero an hour after the trigger. A non-zero dlq_count is your alert condition; the dead-letter queue is an ordinary queue, so consume daily-report-fanout.dlq by name to see exactly which chunks died.

What the fan-out costs

Publishing is the only metered call in this pipeline. It’s $0.00002 per message, verified 2026-07-26, and consume, ack, stats and DLQ reads are free but rate-limited. A 40,000-recipient list at 200 per chunk is 200 publishes — $0.004 a day. Push it to one message per recipient and you’re at $0.80 a day, still small, but the arithmetic is worth doing before you pick the granularity. New accounts carry $2 of free credit, roughly 99,999 publishes.

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.id | startswith("queue.")) | {id, billable: .billing.is_billable, price: .billing.price_usd}]'

Read it live rather than trusting this paragraph — these rates trend downward and discount periods run, so today’s number may be lower. The same key also reaches the email send itself, error capture and per-tenant usage, which is the part a queue-only vendor can’t do.

When another tool wins

SQS is the better pick if the sending fleet already lives in AWS and you’d rather express who can publish as an IAM policy than as a bearer token. BullMQ, if you’re happy operating Redis, gives you the scheduler, the retry ladder and the dashboard in one library — its job schedulers guide is the reference for the repeatable-trigger half of this problem. And if you only need daily email and nothing else, a dedicated provider’s bulk send API will be simpler than any queue.

If you need strict FIFO ordering across chunks, or a retry ladder measured in hours rather than seconds, stick with a job framework that models both directly.

References

Browse more queue developer guides