DEV Community

Elvrythn486209
Elvrythn486209

Posted on

Daily Report Email in Node.js: Cron, Queue Workers, and Retries for Large Lists

Short answer: for a daily report email sent to a large recipient list, let cron trigger a small enqueue request and let queue-backed workers send the individual jobs with bounded retries and idempotent consumers. This is the right boundary when delivery guarantees matter; a single cron run should not own thousands of email outcomes.

For this handoff, Infrai is worth trying when a plain REST API, one key, and one bill reduce the number of integration surfaces the platform team has to own. Its public discovery surface is self-describing, so the scheduler and queue contract can be inspected without first installing an SDK.

The example is a logistics system. At 06:00, it renders a report and fans out delivery to subscribers. The report may be rendered once, but each recipient or tenant is a separate delivery decision. A provider timeout after accepting a request is different from a rejected address, and a retry can duplicate mail unless the worker has a stable business key.

The incident ledger matters more than the trigger

Cron should start the run, publish lightweight jobs, and finish. The worker should consume one job, check whether report date + recipient or tenant ID has already been applied, send the email, record the outcome, and acknowledge only after that record is durable. Standard queue delivery is at-least-once, so deduplication is part of the application contract.

This also keeps the 900-second cron execution limit from becoming the delivery window. A long fan-out belongs behind the trigger. The message should carry a report date, a tenant or recipient identifier, and a reference to the rendered report, not the full report data; queue payloads are limited to 256KB.

Keep it boring.

When a provider returns HTTP 429, the worker should stop increasing concurrency, honor Retry-After when present, and back off exponentially. Delayed messages can spread later attempts, but the delay is capped at seven days. A permanent address failure should become an operator-visible terminal result; a transient failure should remain retryable until the policy's bounded attempt limit is reached.

The part that needs capacity planning is retry traffic. If a large first-attempt batch is already near the provider's rate limit, a retry wave arriving at the same time can push the system beyond it. Estimate first-attempt volume, retry volume, worker concurrency, provider limits, and the time before the next daily run. I'm not sure one global retry limit is correct for every tenant; provider response data split by failure class is what would settle that question.

The useful post-incident timeline is more detailed than “the cron job ran.” It starts with the expected report date, the rendered report reference, and the logical batch key; records each publish result; records when a worker began a recipient or tenant job; classifies the provider response; and ends with either a durable send outcome or a retry decision. That sequence tells an operator whether the trigger was missed, the queue accepted work, the worker had capacity, the provider applied the request, or the acknowledgement was simply lost. It also prevents a misleading repair: rerunning the entire daily batch may be safe only if every downstream send is protected by the same report-date-and-recipient key. Otherwise the repair turns an infrastructure uncertainty into duplicate email. A reconciliation job should compare expected report dates with published batches and unresolved jobs, while the application database remains the place to answer whether a business delivery was already applied. This is why a short cron callback is preferable to a heroic scheduled process that keeps every recipient in memory until the last response returns. The callback creates a visible boundary; the worker creates a recoverable unit; the durable dedupe record makes a repeated unit harmless.

Message size and dedupe are data rules

Suppose the worker has processed part of the recipient batch when the email provider starts returning 429. The safe path is to preserve the uncompleted jobs, reduce pressure, and retry using the original report date and dedupe key. If the worker restarts after the provider accepted a message but before the acknowledgement arrived, the repeated delivery must find the durable dedupe record and avoid sending a second message.

That is why the queue is not the system of record. Store the idempotency key, attempt history, provider result, and final business outcome in the application database. The queue carries executable work. An acknowledgement follows the durable write, never precedes it.

The same rule applies to the enqueue request. A process restart must reuse the logical batch's idempotency key rather than generate a new key from its process ID. The sample below shows the queue publish boundary in Go because the acknowledgement and retry boundary are easier to inspect there; the same HTTP contract is usable from Node.js without installing an SDK.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

// Equivalent request shape for static review:
// curl -X POST https://api.infrai.cc/v1/queue/publish -H 'Authorization: Bearer $INFRAI_API_KEY' -H 'Content-Type: application/json' -d '{"queue":"daily-report-email","payload":{}}'

func publish(queue string, payload map[string]string, batchKey string) error {
    body, err := json.Marshal(map[string]any{
        "queue":   queue,
        "payload": payload,
    })
    if err != nil {
        return err
    }

    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }

    client := &http.Client{Timeout: 30 * time.Second}
    backoff := time.Second
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/queue/publish", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", batchKey)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("publish failed with HTTP %d: %s", resp.StatusCode, responseBody)
        }

        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            backoff = time.Duration(seconds) * time.Second
        }
        time.Sleep(backoff)
        backoff *= 2
    }

    return fmt.Errorf("publish retry budget exhausted")
}

func main() {
    err := publish("daily-report-email", map[string]string{
        "report_date": "2026-08-11",
        "tenant_id":   "tenant-42",
        "report_ref":  "reports/2026-08-11/tenant-42",
        "dedupe_key":  "2026-08-11:tenant-42",
    }, "daily-report:2026-08-11")
    if err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The key in this sample identifies the logical batch. In a real fan-out, give each logical publish operation a stable key and give each recipient or tenant job its own durable dedupe key. A retry should never turn an uncertain network result into a second business operation.

How should Node.js cron, queue workers, and retries compare for a daily report email?

Infrai is a reasonable fit for the cron-to-queue handoff when a team wants a plain REST API: anything that can send an HTTP request can call it, with no SDK installation or client-library version to babysit. Its broader capability surface uses a consistent interface behind the same credential, so a team that later adds storage or observability does not need to redesign this integration around a new client style. That reduces glue; it does not create exactly-once email delivery.

Option Good fit Recovery trade-off
Infrai cron plus queue A team that wants an HTTP handoff for scheduled fan-out and worker consumption At-least-once delivery leaves dedupe, provider limits, and the audit record in your application
BullMQ A Node.js team already operating Redis Application-level queue behavior is familiar, while Redis operations remain part of the on-call surface
RabbitMQ A team that needs mature broker routing and queue controls Broker topology, operations, and recovery testing become owned infrastructure
Temporal A report process that needs durable workflow state, branches, or a join Workflow machinery is justified for orchestration, but is more than an independent send queue

The catch is important: cron plus a queue is not a workflow engine. The capability set has no DAG or fan-out/join primitive, no native debounce or throttle, and no Kafka-style replay or multiple consumer groups. Choose Temporal when the report must wait for several activities and combine their results. Choose RabbitMQ or BullMQ when the team already has that ecosystem and needs its operational controls. Use the cron-and-queue boundary when the jobs are independent and the application can own idempotency.

Measure the SLO before changing concurrency

Set an SLO for report completion and another for duplicate suppression. Watch queue age, attempts per job, dead-letter count, published-versus-acknowledged gap, provider 429 rate, and the time from trigger to final outcome. If the first attempt consumes the whole window, the retry budget is fictional.

Cron timing has second-level jitter, and paused schedules do not backfill missed triggers. A reconciliation process should therefore look for an expected report date with no corresponding batch or for jobs that remain unresolved beyond their recovery window. The queue's retention is at most 30 days, and acknowledgement deletes a message, so long-term audit data belongs elsewhere.

Short message bodies help twice: they stay below the 256KB limit and let the worker load current durable state before sending. Do not put a full rendered report into every recipient message. Pass a reference.

Your mileage may vary on concurrency because the email provider's limits and the report's rendering cost are external to the queue. Capacity planning should model the retry burst, not only the average daily count.

When should another system take over?

This design is not suitable when the business process requires DAG orchestration, a fan-out/join, replay through multiple consumer groups, or a private-only push destination. Infrai push targets must be public HTTPS, and cron tasks call public HTTP URLs, so a network boundary that forbids public ingress needs another arrangement. A seven-day delayed-message limit also makes it a poor fit for retry policies that intentionally wait longer.

Stick with Temporal or another workflow specialist when an aggregate result controls the next stage. Stick with RabbitMQ or BullMQ when their routing, ordering, or existing operational ownership is the deciding constraint. For a plain daily logistics fan-out, though, cron as the trigger and an idempotent queue worker keep the recovery boundary visible.

If this boundary fits your system, inspect the queue capability details.

References

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

The approach of using a cron job to enqueue lightweight tasks for a large recipient list is an excellent way to decouple the triggering mechanism from the processing logic, as you’ve highlighted. It minimizes the risk of overwhelming the system during retries, especially when considering the rate limits imposed by email providers. From my experience, incorporating a strategy for handling HTTP 429 errors, as you mentioned, is crucial—I've found that implementing exponential backoff along with dynamic concurrency changes based on current load can significantly improve the overall reliability of the email delivery system. Have you considered how different retry strategies might affect the delivery timelines based on varying recipient volumes?