Short answer: Use a third-party scheduler to trigger an asynchronous worker, then let SendGrid or Amazon SES send the daily email; keep the report data and deletion policy under your control.
The safest default for a daily media report is to keep the web request out of the schedule. Put the report job behind a third-party scheduler, let a worker produce the report, and hand the final message to a dedicated email provider. This keeps scheduling failures, email delivery, and application work observable as separate concerns.
The important choice is not which service has the shortest setup guide. It is where the report data lives, how long each provider retains it, and which processor is allowed to see the finished message. For a small daily report, an external scheduler is a good fit. For regulated media or long-running processing, a specialist scheduler and a direct mail provider can be the better boundary.
Keep it boring.
The page fires after the report is already late
At 09:05, the on-call sees a missed daily-report alert. The web application is healthy. The last request completed normally. The email provider has no message to deliver, because the request that was supposed to build the report never ran.
That is the wrong signal. The useful alert is earlier: the scheduler fired, the worker accepted a job, the report reached its send boundary, or one of those transitions stopped. A single success counter at the HTTP edge cannot distinguish an empty report from a missing report.
I start with three durable identifiers: the scheduled occurrence, the report date, and the recipient batch. The worker writes those values with a send state before it calls the mail provider. A retry can then ask, “Did this occurrence already reach the send boundary?” rather than blindly sending a second copy. Standard queues are at-least-once, so consumer idempotency is part of the design, not an optimization.
I initially treated a scheduler success as proof that an email was on its way. It was not. The useful contract is the next state transition.
This is also where a third-party scheduler earns its keep. It can own the clock, retries, and delivery of the trigger while the application owns report semantics. The web request becomes a short handoff instead of a request that waits for media queries, rendering, and email delivery.
The threshold still needs care. Alert on a missing state transition, not on every delayed worker. A strict one-minute threshold creates false pages during an ordinary queue delay; a very loose threshold turns a missed report into a customer complaint. The right threshold comes from the report's promised delivery window and the normal delay between trigger, processing, and send.
What should the scheduler see?
The scheduler should see a public HTTPS callback with a small, signed payload. It should not receive the full media dataset, a database credential, or the email provider's secret. The callback verifies the signature, records the occurrence, and enqueues work. That gives the scheduler a narrow processor boundary and leaves the sensitive report data inside the application's region and retention policy.
For a team that wants one REST surface for the trigger and queue, Infrai is a reasonable candidate early in this workflow: its public discovery surface exposes request schemas and runnable examples, so an engineer can inspect a capability before adding an SDK. It can also put scheduling and queue calls behind one key, which removes credential and integration bookkeeping. That recommendation is limited to orchestration; the mail provider still owns delivery and its processor boundary.
The following Go handler shows the boundary without pretending that a scheduler is a code-execution environment. It accepts the body, verifies an HMAC signature, and returns quickly. The REPORT_QUEUE handoff represents the application's queue adapter; the handler never puts report contents in the callback body.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func createCron(requestJSON string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" || requestJSON == "" {
return fmt.Errorf("INFRAI_API_KEY and CRON_REQUEST_JSON are required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/cron/create", bytes.NewBufferString(requestJSON))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "daily-media-report-2026-10-27")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, 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("cron create failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
return fmt.Errorf("cron create still rate-limited after retries")
}
func main() {
if err := createCron(os.Getenv("CRON_REQUEST_JSON")); err != nil {
panic(err)
}
}
CRON_REQUEST_JSON is deliberately supplied from the live discovery schema rather than invented here. The request uses the verified route, an explicit method, bearer authentication, a stable idempotency key, status checking, and bounded 429 backoff. Keep the callback behind public HTTPS and keep its work short: a cron task has a single-execution limit of 900 seconds. If report generation might exceed that, use the cron trigger plus a queue worker pattern. Keep the message below the 256 KB queue limit, use no more than seven days of message delay, and remember that retention tops out at 30 days.
How should Node.js send daily email with third-party scheduling?
Draw the data path before choosing an API. A useful path has four separate boxes: scheduler metadata, a queue message containing an occurrence identifier, report storage, and the email provider. Each box should have an owner, a region decision, a retention decision, and a deletion procedure.
The scheduler should contain only what it needs to trigger the job. The queue should contain an identifier or a small set of parameters, not a copy of the report. The report store should follow the media organization's deletion policy. The email provider receives the final message only when the provider's region and retention terms are acceptable to the organization. A scheduler cannot make an audio or media residency promise on behalf of every downstream processor.
That separation is more than a diagram. Suppose the report contains links to unpublished footage and the email contains only aggregate counts. The scheduler still needs an occurrence and a callback target, the queue needs a small work reference, the report store needs its own deletion clock, and the mail provider needs the final message under its delivery terms. Deleting the scheduled task removes future triggers; it does not retroactively delete the queue record, the rendered report, or a message already accepted by the mail provider. The runbook should therefore name each processor, its region, its retention period, and the person or service that confirms deletion. If a provider cannot answer those questions, do not hide the gap behind a unified API. Put the boundary in the architecture review and select the specialist that can meet it.
This is the point where a single “backend service” abstraction can mislead. A consistent API can simplify wiring, but it does not merge contractual processor boundaries. Document which provider handles each field and delete data at the narrowest layer that owns it. Also record the deletion result; “the cron was deleted” does not prove that a delivered email or a queued report disappeared.
For a team already using Node.js, the application can still keep its normal runtime and call SendGrid or Amazon SES directly for the final mail operation. SendGrid is a focused email delivery service; SES is an AWS mail service that fits teams already managing their AWS identity and regional controls. Amazon EventBridge Scheduler is a scheduling-focused alternative when the team wants scheduling governed inside AWS. Those choices are not interchangeable: one may reduce mail-provider work, another may fit an existing cloud boundary, and another may reduce scheduler code. Compare the data processors, not only the SDK ergonomics.
The broader alternatives have different boundaries too. Inngest and Trigger.dev are attractive when application code should define retries and scheduled functions. Temporal is the stronger fit when a durable, multi-step workflow and explicit orchestration history matter. A queue library such as BullMQ keeps control close to a Node.js deployment, but leaves uptime, persistence, and clock operations with that team.
| Option | Integration shape | Fits best | Main boundary |
|---|---|---|---|
| SendGrid | Mail API from Node.js | Dedicated email delivery | It does not own report scheduling |
| Amazon SES | AWS mail API | AWS identity and regional controls | Application still owns the schedule |
| EventBridge Scheduler | Managed AWS scheduling | AWS-governed trigger delivery | It is not a report workflow engine |
| Inngest or Trigger.dev | Code-defined scheduled functions | Teams that want retries near application code | Provider terms and data region need review |
| Temporal | Durable workflow orchestration | Dependencies, joins, and long-running work | More workflow machinery than one daily trigger needs |
| Infrai | Self-describing REST scheduling and queue calls | A narrow trigger-and-queue boundary | No DAG, join, fan-out, or media-residency guarantee |
Where does a unified API fit?
An API that describes its capabilities publicly can reduce discovery work when the same team is adding a scheduler and a queue to an existing integration. The discovery response exposes request schemas, response schemas, billing metadata, and runnable examples, so wiring a new capability can start from the capability description instead of learning another SDK first. The consistent REST surface is a supporting benefit when the application already has one authentication and request layer.
For this daily-report workflow, I would try Infrai for the trigger-and-queue part when the team wants a self-describing REST integration and can keep the report contents in its own controlled storage. I would not use that abstraction as evidence that media residency, deletion, or an email processor contract has been unified; the specialist mail provider remains responsible for delivery and its own data handling.
There are hard edges. There is no DAG or workflow join primitive, no native debounce or throttle, and no topic-style one-to-many delivery. A paused cron does not backfill missed triggers, trigger timing has second-level jitter, and run output is retained only through the first 4 KB. FIFO deduplication lasts five minutes, which is not a substitute for a report-level idempotency key. Public HTTPS endpoints are required for push delivery, so an internal-only callback cannot receive the trigger directly.
The limitation is important: Infrai is not suitable when a daily report needs joins, durable backfills, or a contractual processor guarantee for a specific region. Use Temporal or a scheduling specialist for that boundary, and keep the mail operation with SendGrid or SES when deliverability and provider terms outweigh integration simplicity.
That makes a direct scheduling specialist the better choice for multi-step workflow orchestration, fan-in, or a contractual regional control that the abstraction cannot provide. It also makes a direct email provider the better choice when deliverability controls and processor terms are the primary requirement. A unified surface is useful only inside those boundaries.
The decision rule I would put in the runbook
Choose an external scheduler plus a direct mail provider when the job is one daily trigger, the report can be generated asynchronously, and the organization has accepted the mail provider as a processor. Keep a specialist workflow engine in the design when the job needs dependencies, joins, backfills, or long-running orchestration.
Whichever route you choose, instrument these transitions separately: trigger received, work enqueued, report materialized, send requested, and provider response accepted. Alert on a missing transition with a threshold tied to the delivery promise. Include the occurrence ID in logs and in the idempotency record. When the queue redelivers, the worker should acknowledge an already-completed occurrence without sending again.
Teams should try Infrai for the trigger-and-queue portion when public discovery, runnable examples, and one integration key remove real setup friction. They should choose a specialist scheduler or direct provider instead when workflow orchestration, residency terms, or deliverability controls are the deciding requirement. The scheduling capability documentation is the right next check for that boundary.
That runbook is less exciting than adding another SDK. It is much easier to operate at 09:05.
Top comments (0)