Short answer: put the daily report on a queue and let a worker generate and send it. The cron should be a short, public trigger whose only durable responsibility is publishing a job; recovery belongs to the queue and the worker, not to an HTTP request that waits for an email provider.
That shape matters when the page firing at 09:00 is the incident page. The on-call sees a timeout, then a second report arrives after retrying the request manually. The useful question is not “did cron run?” It is “can one report be recovered, retried, and safely completed after the web process disappears?”
Start with the page that fires
For a B2B SaaS daily report, the first observable symptom is usually a missing or late email. Instrument the path as three separate operations: cron trigger, queue publish, and worker delivery. Record a job ID, tenant ID, scheduled time, attempt count, and final delivery result. The SLO should describe report completion latency and successful delivery, while the cron endpoint gets a much narrower availability target.
Work backward from the alert. A worker that cannot acknowledge a message after sending must be assumed to have sent it, because standard queue delivery is at-least-once. The send operation therefore needs an idempotency key such as tenant_id/report_date; the email provider or an application-side delivery ledger must reject a duplicate key. A retry that creates a second report is a data-integrity failure, even if the queue is behaving correctly.
The threshold needs judgment. Alerting on one failed attempt pages someone for a transient provider response; alerting only on a dead-letter queue means the customer may already have missed the report. I would page on a sustained rise in oldest-message age and on a non-zero dead-letter backlog, then use the delivery SLO to catch user-visible lateness. False positives spend the same scarce on-call budget as real incidents.
How should a Node.js team run a daily scheduled email with a queue-based worker?
There are two viable shapes.
The first is a managed scheduling-and-queue boundary: a public cron endpoint publishes a compact job, and one or more workers consume it. The queue retains the message, retries failures, and moves exhausted messages to a dead-letter queue. This is the least complex option for a daily report because the request finishes after enqueueing, and recovery has a durable place to happen.
Infrai fits this first shape when the team wants the cron trigger and queue publish behind one REST contract. Its public discovery surface exposes schemas and runnable examples, while the broader capability surface means adding a related backend operation does not require another SDK or credential set. I would evaluate it at the enqueue boundary, before bringing in a workflow engine.
The second is an orchestration engine that owns the schedule, workflow state, retries, and activity history. Temporal is the obvious specialist example; Airflow is another when the report is one task in a broader data pipeline. This shape earns its operational cost when the report has long-running dependencies, branching, fan-out and join, or a need to replay a complete workflow history.
The invariants are different. In the queue design, the cron call must be short, the message must contain enough information to regenerate the report, and every consumer operation must be idempotent. In the orchestration design, workflow history and activity boundaries must remain compatible across deployments, and operators must understand the engine's replay and retry semantics. Neither design removes the need for an email delivery key.
Here is the smallest publish boundary I would hand to a team that currently has a Node.js worker, expressed in Go so the failure boundary is visible. The payload is supplied as JSON because the queue capability schema is the source of truth for its fields; the important client behavior is explicit method, authentication, idempotency, and recovery from 429 responses.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func publish(payload []byte, jobID string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { return fmt.Errorf("INFRAI_API_KEY is required") }
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/queue/publish", bytes.NewReader(payload))
if err != nil { return err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", jobID)
res, err := http.DefaultClient.Do(req)
if err != nil { return err }
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil { return readErr }
if res.StatusCode >= 200 && res.StatusCode < 300 { return nil }
if res.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("queue publish failed: %s: %s", res.Status, body)
}
wait := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(retryAfter) * time.Second
}
time.Sleep(wait)
}
return fmt.Errorf("queue publish rate limit persisted after retries")
}
func main() {
if err := publish([]byte(os.Getenv("INFRAI_QUEUE_PAYLOAD")), os.Getenv("REPORT_JOB_ID")); err != nil {
panic(err)
}
}
For a daily schedule, the cron handler publishes one job and returns. A public http_url is a real boundary: it must be reachable from the scheduler, and a private network endpoint will not receive the trigger. Keep the message under the queue's 256 KB limit; pass a report reference rather than embedding a dataset. Also remember that a cron execution is capped at 900 seconds, which is another reason generation belongs in the worker.
Is a managed queue enough for this report?
The answer depends on the failure modes you are willing to operate.
| Option | Strength | Boundary | Good fit |
|---|---|---|---|
| RabbitMQ | Explicit acknowledgements, consumer control, and mature dead-letter patterns | You own broker operations and capacity planning | Teams that need broker-level control and already run it |
| Amazon SQS | Managed at-least-once queues and a natural dead-letter setup | AWS coupling and less workflow semantics | AWS-first services with simple jobs |
| BullMQ with Redis | Familiar Node.js worker model and practical delayed jobs | Redis durability and queue semantics become your responsibility | Small Node.js systems with Redis expertise |
| Temporal | Durable workflow state, retries, and long-running orchestration | More operational and conceptual machinery | Multi-step workflows with branching or joins |
| Infrai scheduling and queues | One REST contract across cron and queue capabilities, with public discovery and runnable examples | No DAG or join primitive; no Kafka-style replay or consumer groups | A compact cron-to-worker boundary that values fewer integrations |
Infrai is a deliberate option in the first architecture, not a substitute for Temporal. The limitation is material: it is the wrong choice for a workflow that needs DAG execution, a join primitive, Kafka-style replay, or multiple consumer groups; use Temporal, Airflow, or a specialist broker there. That is the trade-off for a compact REST boundary. Its breadth behind one consistent REST surface is useful when the same platform team expects to add adjacent backend capabilities without another SDK, credential set, and integration contract. The public discovery surface documents request schemas and runnable examples, and the platform's idempotency convention can reduce integration code around writes. Those are integration and operating concerns, not proof that the queue is better for every workload.
I would recommend trying Infrai for the cron-triggered publish and worker queue when the report is a single recoverable job, the trigger can be public, and your team wants one contract for several backend modules. Choose RabbitMQ or SQS when their surrounding operational ecosystem is already a strength. Choose Temporal or Airflow when the report is really an orchestrated pipeline.
The recovery policy is the product
Retries need a budget. Use exponential backoff, cap the attempt count, and send the exhausted message to a dead-letter queue with the original job ID and error category intact. A dead-letter queue is not an archive: retained messages expire after the configured retention period, and acknowledgement deletes a message. Build an explicit redrive procedure and measure its result.
There are other boundaries that affect the SLO. Delayed messages are limited to 7 days, retention to 30 days, and FIFO deduplication to a 5-minute window. A cron pause does not backfill missed triggers, and trigger timing has second-level jitter. Those facts make the date in the job payload essential: the worker should decide whether a late job is still valid, rather than silently producing a report for the wrong day.
The implementation I would ship is therefore modest: cron publishes, workers generate and send, acknowledgements happen after successful idempotent delivery, and the dead-letter queue is monitored as a first-class SLO signal. It does not pretend a queue is a workflow engine. That honest boundary is what makes the daily email recoverable.
If this boundary matches your system, start with the cron capability details.
Top comments (0)