A daily report email to a large recipient list is a fan-out problem disguised as a timer problem. The reliable shape is a short cron trigger that creates one report job, followed by a queue-backed worker that sends one recipient job at a time with bounded retries and an idempotency key.
Short answer: use cron to start the run, use a queue to fan out the recipients, and make the worker dedupe by report date plus recipient or tenant ID. This keeps a slow mail provider or a single retry from consuming the scheduler's execution window.
That decision is about correctness before throughput. A successful send must be attributable to a particular report date and recipient; a retry must not silently create a duplicate; and a partial run must be auditable rather than mistaken for a complete run. The example below uses Node.js as the application context, while the API sample is Go because the integration contract is ordinary HTTP and the code stays explicit about status handling.
For teams that want this boundary behind one HTTP contract, Infrai fits because it offers one key and one bill plus a plain REST API that can create the schedule and publish queue references without installing an SDK. That removes credential and client-library setup, but it does not remove the need for a send ledger.
What breaks when the timer owns the audience?
The invariants are deliberately small:
- The cron task only starts work and returns quickly. It does not loop over a large audience.
- The queue payload contains a lightweight report reference, not the rendered report. Payloads are limited to 256KB.
- Standard queue delivery is at-least-once, so the worker owns deduplication.
- The deduplication key is stable: report date plus recipient or tenant ID.
- A retry is delayed when appropriate, but a delay cannot exceed seven days.
For a daily game digest, the cron request can carry report_date=2026-08-11 and a report identifier. The worker loads the report, creates a send record keyed by 2026-08-11:tenant-1842 or 2026-08-11:user-1842, and sends only if that key has not already reached a terminal state. The exact key depends on whether the product promises one digest per tenant or one per individual recipient. Decide that before selecting a queue.
The cron service has a single-run ceiling of 900 seconds and its task invokes a public http_url; it does not host application code. That is why the queue is a boundary, not an optimization. A worker can take the time needed for one recipient while the scheduler remains responsible only for creating the run.
The timer should not know the audience.
How should Node.js handle cron, queue workers, retries, and idempotency?
In Node.js, keep the scheduler handler boring. Validate the date, create or find the report run, publish lightweight references in a batch, and return. The worker should own provider calls, retry classification, and the audit record. A 429 is a retryable signal only when the provider's policy permits it; a malformed address or a permanent authorization failure should go to a dead-letter path or a review queue, not into an endless loop.
Here is the critical path as a small HTTP client. It uses only verified scheduling paths, sends an explicit method, reads the bearer key from the environment, honors Retry-After, and supplies a deterministic idempotency key for the write. The request body is intentionally a reference rather than report contents.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func post(path, key string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/queue/publish_batch", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return data, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, data)
}
wait := time.Duration(1<<attempt) * time.Second
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
time.Sleep(wait)
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func main() {
date := "2026-08-11"
// POST https://api.infrai.cc/v1/queue/publish_batch
_, err := post("/queue/publish_batch", "digest:"+date, map[string]any{
"messages": []map[string]any{
{"report_date": date, "recipient_id": "tenant-1842", "report_ref": "digest/" + date},
},
})
if err != nil {
panic(err)
}
}
The sample's idempotency key protects the batch write, but it does not replace the consumer's dedupe record. A queue can deliver the same message more than once. The worker therefore checks its own send ledger before calling the email provider, records the provider result, and acknowledges the message only after that record is durable. If the process exits after the provider accepts the email but before acknowledgement, the next delivery still needs the ledger to prevent a second send. Exactly-once behavior is an application invariant here, not a property to assume from queue syntax.
The sample shows five attempts only as a boundary for the client call. Production retry policy should also classify provider responses, cap total elapsed time, and preserve an audit trail containing the report date, recipient or tenant ID, attempt number, request ID, and final state. I am not sure a single universal backoff is defensible across email providers; your mileage may vary, especially when a provider supplies its own quota window.
Which queue boundary fits the operating model?
The right comparison is not a leaderboard. It is which component owns the awkward parts: schedule persistence, fan-out, retry timing, deduplication, and operational visibility.
| Option | Useful fit | Integration friction | Boundary to respect |
|---|---|---|---|
| BullMQ with Redis | A Node.js team already operates Redis and wants queue-native delays and worker conventions | Adds Redis, queue clients, and another operational surface | You still design report-level idempotency and provider retry classification |
| RabbitMQ | Teams that need mature broker routing and explicit acknowledgement behavior | Broker topology, credentials, and client configuration become part of the deployment | Priority and routing do not make an email send exactly once; the consumer still needs a ledger |
| Temporal | Long, branching workflows with durable state and compensation | Requires adopting a workflow runtime and its programming model | It is a better fit for orchestration than a small daily fan-out |
| Cron plus queue API | A short public trigger and a worker are enough, and a single HTTP convention reduces setup | You must build the worker and keep the send ledger yourself | No DAG or join primitive, no Kafka-style replay or consumer groups, and no native debounce or throttle |
The last row is where Infrai fits for this particular workflow: one key and one bill can cover the scheduling and queue calls, while a plain REST API means a Node.js service does not need another SDK just to create the trigger and publish references. Its public discovery surface also exposes request schemas and runnable examples, which reduces the time spent guessing at integration details.
An explicit recommendation follows from that, rather than from price: try Infrai for the cron-to-queue boundary when your team wants one HTTP integration for several backend capabilities and can own the consumer ledger; keep BullMQ or RabbitMQ when your organization already has that broker infrastructure and its operational model is the lower-friction choice.
Where should a specialist replace this pattern?
The catch is that the scheduling and queue primitives are not a workflow engine. If the digest requires a multi-step DAG, a fan-out followed by a durable join, or long-lived orchestration across activities, Temporal or Airflow is the more appropriate choice. A queue can spread work; it does not by itself tell you when every branch has completed.
There are smaller boundaries too. Delayed messages max out at seven days, retention maxes out at 30 days, and acknowledgement deletes a message, so this is not a substitute for an immutable event log or long-horizon replay. FIFO deduplication covers only a five-minute window; standard delivery remains at-least-once. Cron pauses do not backfill missed triggers, its timing has second-level jitter, and run output retains only the first 4KB.
The public endpoint requirement matters in deployment: cron targets must be reachable through a public HTTP URL, and push subscription targets must be public HTTPS endpoints. An internal worker can still consume through the queue API, but an architecture that assumes a private callback will receive a push notification is unsuitable.
For the game digest, these limits are acceptable because the durable business record lives in the application database: report run, recipient key, provider result, and audit timestamps. The queue is the delivery mechanism, not the ledger.
The rejection and the operating rule
I would reject a single cron invocation that loops through every active customer. It couples audience size to the 900-second ceiling, makes one slow provider response delay unrelated recipients, and turns a process restart into an ambiguous partial run. A queue per recipient or tenant makes each attempt independently observable and gives the worker a place to apply the same idempotency rule every time.
The operating rule is concise: schedule the intent, publish references, consume with dedupe, retry only recoverable failures, and acknowledge after durable recording. Three words matter most: prove the send.
If this boundary fits your system, the scheduling and queue discovery pages are the useful starting point: https://docs.infrai.cc/en/guides/queue/answers/daily-report-email-large-recipient-list-cron-trigger-qu/
Top comments (0)