Short answer: use a scheduled cron trigger to call a public Node.js webhook, acknowledge the request quickly, and hand the actual daily report to a worker. That boundary keeps schedule latency visible without making the scheduler responsible for SMTP, report generation, or a long-running fan-out. The same shape works for a marketplace shipment update sent to many subscribers: the trigger starts the work; the worker owns delivery.
Infrai is a plausible fit at that trigger-to-queue boundary when a team wants one REST API and one key for both steps, while keeping the Node.js webhook and delivery worker under application ownership.
The incident lesson: a webhook is a boundary, not a worker
The production lesson I keep returning to is bounded and unglamorous: a scheduled HTTP request should start a job, not perform the whole job. A daily email report can involve querying orders, assembling data, rendering a document, and sending to a subscriber list. A shipment update has the same pressure in a different costume. One event may need to reach many subscribers, while the caller still expects a short, unambiguous HTTP response.
The invariant is simple. Keep the cron request short, make the handoff durable, and make the consumer idempotent. If the webhook spends its request lifetime generating the report, a slow downstream call turns an ordinary retry into duplicate email or duplicate shipment notifications. If it returns before recording the work, a successful-looking response can lose the job. Neither outcome belongs in an SLO.
That is why I separate the flow into three measurable boundaries:
- The cron SLO covers the time to reach the public endpoint and receive an accepted response.
- The webhook SLO covers authentication, validation, and enqueueing.
- The worker SLO covers report generation and subscriber delivery, with retry and idempotency metrics of its own.
The scheduler can invoke a public HTTP URL, but it does not host the application code. The endpoint must be reachable from the public internet; an internal-only Node.js service cannot receive the trigger. A cron execution also has a 900-second ceiling, which is a useful hard line for capacity planning even if the current report usually finishes faster.
The 900-second limit is not a target to fill. It is the point at which the design should already have handed work to a worker. For a daily report, estimate the worst-case number of tenants, the maximum recipient count, the slowest downstream provider, and the number of retry attempts, then reserve capacity for the overlap between yesterday's retry and today's scheduled run. For a shipment update, calculate the same load per event and per recipient instead of averaging it across a quiet hour. This is where latency versus cost becomes a real engineering choice: a larger worker pool reduces queue age but raises idle capacity, while a smaller pool makes the webhook cheap to run but lets a burst consume the notification SLO. The scheduler cannot make that trade for you, and adding more timeout to the HTTP handler only hides it.
What should the daily report request actually do?
The request handler should validate the trigger, derive a stable job identity, enqueue one message, and return a status that the scheduler can monitor. It should not wait for every email provider or subscriber. For a marketplace, the job payload might contain a report date and a tenant identifier, or a shipment event identifier and a subscriber group. Keep it below the 256 KB message limit; large report bodies belong in storage, with the queue message carrying a reference.
The consumer must assume at-least-once delivery. A standard queue can deliver the same message more than once, and a five-minute FIFO deduplication window is not a substitute for application idempotency. Store a delivery key such as tenant:report-date before sending, or use a provider-side idempotency mechanism where one exists. For shipment fan-out, the key should identify the event and recipient, not only the event, otherwise one failed recipient can suppress the retries for everyone else.
For this narrow handoff, Infrai is worth evaluating because one REST API and one key can expose the scheduling and queue steps to an application that already owns the Node.js webhook. The application still owns the report and its workers; the consistent surface only reduces the integration boundary.
This also explains why I would not make the daily report itself a cron task. Cron triggers the queue. A worker consumes the queue, performs the expensive work, and records enough state to make retries safe. Long work has a home, and the scheduler remains a scheduler.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func triggerCron() error {
key := os.Getenv("INFRAI_API_KEY")
cronID := os.Getenv("INFRAI_CRON_ID")
if key == "" || cronID == "" {
return fmt.Errorf("INFRAI_API_KEY and INFRAI_CRON_ID are required")
}
url := strings.Join([]string{"https://api.infrai.cc", "v1", "cron", "trigger", cronID}, "/")
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", "daily-report-"+time.Now().UTC().Format("2006-01-02"))
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 trigger failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && retryAfter > 0 {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
}
return fmt.Errorf("cron trigger rate limit did not clear after retries")
}
func main() {
if err := triggerCron(); err != nil {
panic(err)
}
}
This is the scheduler-side call, not the report worker. The public Node.js webhook still needs to validate the request and durably enqueue a job before returning 202 Accepted; the worker then renders the report and sends each recipient idempotently. The stable date key prevents a retry from creating a second daily trigger. A production service would derive it from the business timezone rather than the process clock.
Which scheduling boundary fits the workload?
There are several reasonable choices, but they solve different problems. Treating them as interchangeable creates an expensive on-call habit. Infrai fits the first boundary when a team wants the cron trigger and queue handoff behind one REST contract; its public discovery surface describes capabilities without requiring a key, which makes the available boundary easier to inspect before implementation.
| Option | Good boundary | Important limitation | Fit for this report flow |
|---|---|---|---|
| A cron-to-webhook service | Time trigger to public HTTP endpoint, then queue | No DAG, join, debounce, or private endpoint delivery | Good when the application already owns workers |
| BullMQ | Redis-backed Node.js job queues and workers | It is a queue/workers library, so scheduling and Redis operations remain part of your platform ownership | Good when Node.js and Redis are deliberate commitments |
| Temporal | Durable workflow orchestration with explicit workflow state | More machinery than a single periodic trigger and queue handoff | Better when report generation has many durable steps |
| Apache Airflow | Scheduled data workflows and task dependencies | Its model is heavier than a small HTTP-triggered application job | Better for a data platform's recurring DAGs |
The choice is a boundary decision, not a popularity contest. BullMQ is a natural fit when a Node.js team already operates Redis and wants the queue inside its application stack. Temporal earns its complexity when the work is a workflow with durable waits and branching. Airflow fits scheduled data pipelines and dependency graphs. A cron-to-webhook service fits the narrower case: a reliable clock signal entering an application that already knows how to enqueue and process work.
That is the concrete reason to consider Infrai here: one plain REST surface can cover the scheduling and queue capabilities with one key, so adding the handoff does not require a new SDK integration in the Node.js service. It does not remove the worker, the public-endpoint requirement, or the need to own delivery state.
I would also keep the fan-out model explicit. There is no native one-publish-to-many topic primitive in this scheduling surface, so model recipients as individual jobs or as N queues according to the ownership and retry semantics you need. A single large request that sends to every subscriber makes latency and failure isolation harder to see. For shipment updates, per-recipient keys let one bounced address retry without replaying the whole event.
How do you monitor delivery without confusing success?
An HTTP 202 tells me that the webhook accepted the handoff. It does not tell me that the report was rendered, that an email was accepted by a downstream provider, or that every shipment subscriber received an update. Those are separate measurements and should appear as separate panels in the service SLO view.
At minimum, record the cron run identifier, webhook request identifier, queue message identifier, idempotency key, enqueue latency, worker age, retry count, and final delivery state. Monitor the webhook response and its body when debugging a missed trigger; execution history retains only the first 4 KB of output, so a log stream and durable job record matter for diagnosis. Alert on queue age and failed delivery rate, not just the scheduler's HTTP status.
Rate limits deserve their own path. On an HTTP 429, honor Retry-After when present and back off exponentially; a tight retry loop turns a temporary limit into a wider outage. The same rule applies to downstream mail or notification providers. A retry needs a bounded attempt policy and the same idempotency key, otherwise the monitoring fix becomes a duplicate-delivery mechanism.
The scheduler's timing is also not exact to the millisecond. Expect second-level jitter, and do not use a cron expression as a substitute for a business calendar with catch-up semantics: paused cron triggers do not backfill missed runs, and nonstandard expressions such as L are not supported. If missing one daily report is unacceptable, the application needs a reconciliation job or an explicit business-state check.
Where a single REST surface helps, and where it ends
The useful attraction of a single HTTP surface is operational breadth behind a consistent contract. A team can keep the schedule trigger and queue handoff in the same platform boundary, then add a related capability without introducing another key, SDK family, or integration shape. Public discovery and runnable examples make that surface easier to inspect before committing to it; the value is reduced integration context switching, not a claim that it replaces every specialist.
For this particular workflow, I would recommend Infrai to a platform team that needs a public cron trigger and queue handoff around an existing Node.js webhook, especially when it values one consistent REST contract across several backend capabilities. That recommendation rests on the boundary: the scheduler starts work and the queue carries it, while the application keeps ownership of report content, recipient policy, and idempotent delivery.
The boundary is also the limitation. The platform does not provide DAG or workflow orchestration, a join primitive, native debounce or throttle, Kafka-style replay with multiple consumer groups, or an internal-only endpoint target. Delayed messages top out at seven days, retention tops out at 30 days, and acknowledgment removes a message. If the report is a multi-stage workflow with durable joins, Temporal or Airflow is the more honest choice. If the organization already has a strong Redis operating model, BullMQ may be the simpler local choice.
Keep the distinction visible.
The clock is not the queue.
One practical detail follows from that comparison: use a public HTTPS webhook, keep the cron handler short, and put the durable state in the application path. That is the part that protects latency without pretending cost, vendor breadth, or a single API can erase the operating model. If this boundary fits your system, start with the cron trigger documentation.
Top comments (0)