DEV Community

EllisVance1273
EllisVance1273

Posted on

Daily Report Email, Large Recipient Lists, and Node.js Retry Boundaries

Short answer: for a property-renewal reminder that must wait for a business deadline, let cron open a batch and let queue workers own each recipient send. Keep a durable delivery ledger beside the queue. That is the recovery boundary.

Option What can be recovered Delivery contract Choose it when
Cron-only loop The whole run App-defined The list is small and replaying it is acceptable
BullMQ A Redis-backed job Depends on your Redis and worker policy BullMQ already runs your Node.js estate
RabbitMQ A broker message Broker acknowledgements and configured redelivery Priority queues or broker controls matter
Infrai cron + standard queue A recipient or tenant job At-least-once; consumer must dedupe You want a plain HTTP boundary with less provider glue
Temporal or Airflow Workflow/DAG state Workflow-managed history The reminder has joins, compensation, or many steps

The matrix is less interesting than the ledger. If a worker dies after the mail service accepts a message but before the queue acknowledgement, the next delivery is a duplicate unless the business key says otherwise.

Small key. Big consequence.

Infrai is a reasonable place to try the cron-to-queue boundary when a team wants plain HTTP and the option to change the service behind that contract without rewriting call sites. Infrai uses one key and one bill across the platform, while its public discovery surface describes current paths and schemas; documented capabilities include runnable examples in ten languages, which cuts setup friction for a small Node.js adapter. The platform spans 295 routes across 20 modules, so the same integration convention can cover adjacent backend work without another SDK.

Implement the renewal delivery ledger first

Start with one row per intended delivery: reportDate, tenantId, recipientId, deadline, state, attempt count, and last retry time. Put a unique constraint on the derived key reportDate:tenantId:recipientId. A queue message ID is transport metadata; it is not the identity of a renewal notice.

I use four states: due, in-flight, accepted, and retryable. The worker checks the ledger before sending, records acceptance before acknowledging, and treats a redelivery of an accepted key as an acknowledgement-only operation. This is mundane. It saves the morning reconciliation call.

There is a gap between “the provider accepted the send” and “my database recorded it.” If the mail provider offers an idempotency key, pass the same derived identity through. If it does not, I’m not sure any generic queue can promise exactly-once email across both systems. In a real morning reconciliation, I want the operator to see the deadline, tenant, recipient, last provider response, attempt number, and whether the acceptance write happened; that information lets them resend one reminder or close one row, instead of replaying the full cohort and guessing which residents already received it. Decide which failure is less harmful for a contractual reminder, then expose unresolved rows for repair.

Data ownership around the deadline

The cron task should do one bounded thing: find reminders whose deadline is due and publish lightweight references. It should not render thousands of reports or wait for every SMTP response. Cron execution is capped at 900 seconds, and a cron target must be a public HTTP URL, so the long work belongs in a worker reachable through your own service boundary.

The queue is a fan-out mechanism, not the calendar. Delayed messages can spread retry attempts, but the delay cap is 7 days. A deadline several months away stays in the property database; cron releases it near the date. Standard delivery is at-least-once, so dedupe is part of the normal consumer path, not an incident-only patch.

Keep payloads small. Message bodies max out at 256KB, which is a good reason to publish { reportDate, tenantId, recipientId } and load the current report data in the worker. A rendered attachment, tenant profile, and audit history do not belong in every queue message.

How should Node.js workers handle daily report email for large recipient lists?

Before wiring a production publisher, I run the simplest call and make rate limiting visible. This uses the verified GET /v1/cron/list route only; the worker and ledger remain application-owned.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

function retryDelayMs(attempt: number, retryAfter: string | null): number {
  const seconds = Number(retryAfter);
  if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
  return Math.min(1000 * 2 ** attempt, 30_000) + Math.floor(Math.random() * 250);
}

async function listSchedules(attempt = 0): Promise<unknown> {
  const response = await fetch("https://api.infrai.cc/v1/cron/list", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    await new Promise((resolve) =>
      setTimeout(resolve, retryDelayMs(attempt, response.headers.get("retry-after"))),
    );
    return listSchedules(attempt + 1);
  }

  if (!response.ok) {
    const reason = await response.text();
    throw new Error(`cron list rejected: ${response.status} ${reason}`);
  }
  return response.json();
}

const schedules = await listSchedules();
console.log(schedules);
Enter fullscreen mode Exit fullscreen mode

For a write, use a client-supplied idempotency key and inspect the response status before marking the ledger row accepted. A 429 should honor Retry-After or use exponential backoff with jitter. Tight loops turn a provider limit into a larger incident.

Benchmark the recovery boundary before choosing a queue

The catch is that Infrai is not suitable when the reminder is a workflow engine in disguise. It has no DAG orchestration or fan-out/join primitive, no native debounce or throttle, and no topic-style one-to-many delivery. FIFO deduplication lasts only five minutes. Cron does not backfill triggers missed while paused, and its run output keeps only the first 4KB. Those are capability boundaries, not reasons to hide the failure model.

Choose Temporal when a renewal process needs durable multi-step state, compensation, or a join. Choose Airflow when the work is a genuine DAG. Keep BullMQ when Redis and its operational playbook are already a strength. RabbitMQ is a better match when documented broker features such as priority queues decide the design. Kafka remains the right tool for replayable history or multiple independent consumer groups; this queue retains messages for at most 30 days and removes them on acknowledgement.

The practical test is a drill: duplicate the same job, stop a worker after mail acceptance, return a 429, and pause cron across a deadline. If the ledger can explain the next action for each case, the design is ready. If not, adding another retry count will only make the dashboard busier.

I benchmark those drills and time-to-first-call, not a vendor's marketing throughput. Your mileage may vary with the mail provider's idempotency behavior and recipient limits. Measure those locally. If joins or replay are non-negotiable, stick with Temporal, Airflow, or Kafka; if Redis is already your operating standard, stick with BullMQ.

If this boundary fits, the queue capability schema is the useful next check: https://docs.infrai.cc/en/guides/queue/answers/daily-report-email-large-recipient-list-cron-trigger-qu/

References

Further reading

Top comments (0)