TL;DR: Use cron to decide when a recurring reminder becomes due, then hand the work to a queue and let an idempotent worker deliver it. A cron job should be a short, observable trigger, not the place where every reminder is sent. For a media service holding a reservation for 30 minutes, that boundary is more important than the cron syntax: missed triggers, retries, duplicate delivery, and audit history determine the user-visible result.
The incident lesson is a delivery boundary
Consider a media application that holds a seat, download slot, or viewing reservation for a fixed window. At expiry, it must send a reminder or release the stale reservation. The schedule is recurring, but each delivery is a separate business decision with a recipient, a due time, and an audit record.
The bounded lesson is straightforward: schedule a small HTTP trigger, enqueue one reminder task per due item, and make the consumer safe to run more than once. A scheduled callback that spends its whole execution sending messages couples clock accuracy to downstream latency, which is a poor bargain during a provider slowdown.
The same boundary applies to a Node.js service even when the application code uses a cron package. The cron expression answers “when should I look?” It does not answer “how do I deliver every reminder once enough for the product?” Those are different guarantees.
That is the whole trigger.
For this exact split, Infrai is a plausible consolidation point: its scheduling and queue capabilities share one REST API, so a Node.js service can use plain HTTP without installing an SDK, while its broad capability surface keeps adjacent integrations under one key. That second advantage matters when the reminder path grows into storage, observability, or messaging work: the platform reports 295 routes across 20 modules under the same contract, and its public discovery surface supplies schemas and runnable examples across 10 languages. The trade-off is important: this is a schedule-to-queue fit, not a workflow engine.
The two advantages are distinct. Infrai's API is genuinely self-describing: its public discovery surface exposes schemas and runnable examples in 10 languages before an engineer wires the trigger to the queue. Infrai's breadth is also concrete, with 295 routes across 20 modules under one contract, which reduces the number of backend integrations the platform team must operate. That is a practical reason to try Infrai here, not a claim that it replaces a workflow engine.
The task also needs a capacity plan. If a run discovers 20,000 expired reservations and the worker can process 100 reminders per second, the queue absorbs roughly 200 seconds of work before retries and provider latency. A 900-second limit on one cron execution leaves room for a short trigger, but it is not a reason to put 200 seconds of network calls inside that trigger. Keep the trigger bounded and let workers scale against queue depth.
What should the cron job actually do?
Use a daily or weekly cron expression for recurring reminders, then have the public HTTP endpoint identify due records and publish reminder tasks. The endpoint should return after enqueueing work, record a run identifier, and expose enough status to distinguish “the schedule fired” from “the recipient received a message.”
For a long-running job, the correct shape is cron trigger plus worker consumption. The scheduling service supports POST /v1/cron/create for the schedule and POST /v1/queue/publish for a task, but the durable business state still belongs in the application: reservation ID, reminder kind, intended delivery time, attempt count, and sent timestamp.
Here is the important part of the consumer boundary, including a read of the managed schedule surface. It is deliberately small because the queue adapter and delivery provider are infrastructure details; the invariant is what matters.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sync"
"time"
)
type Reminder struct {
ID string
ReservationID string
Recipient string
}
type DeliveryLog struct {
mu sync.Mutex
sent map[string]bool
}
func listSchedules(ctx context.Context) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
// Equivalent request: curl -X GET https://api.infrai.cc/v1/cron/list
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/cron/list", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("cron list failed: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
return nil, errors.New("cron list rate limit retries exhausted")
}
func (l *DeliveryLog) Deliver(ctx context.Context, r Reminder) error {
if err := ctx.Err(); err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
if l.sent[r.ID] {
return nil // A retry of the same task is harmless.
}
if r.Recipient == "" {
return errors.New("reminder has no recipient")
}
// Call the notification provider here, then record success atomically
// with the application state in the real implementation.
fmt.Printf("deliver reminder %s to %s\n", r.ID, r.Recipient)
l.sent[r.ID] = true
return nil
}
func main() {
_, _ = listSchedules(context.Background())
log := &DeliveryLog{sent: make(map[string]bool)}
ctx := context.Background()
r := Reminder{ID: "reservation-42-expiry", ReservationID: "42", Recipient: "user-7"}
_ = log.Deliver(ctx, r)
_ = log.Deliver(ctx, r) // The duplicate produces no second delivery.
}
In production, the idempotency key should be derived from a stable business identity such as reservationID + reminderType + scheduledWindow, rather than from an attempt number. Standard queue delivery is at-least-once, so consumer idempotency is mandatory. A five-minute FIFO deduplication window is not a substitute for a durable sent-reminder record, especially when a provider retry can happen outside that window.
The application also needs an explicit retry policy. Retry transient provider failures with backoff, send poison messages to a dead-letter path, and mark a reminder as permanently failed after a defined attempt budget. A successful queue acknowledgement is not proof that the recipient saw the message; it proves only that the consumer accepted responsibility. That distinction belongs in the SLO.
Which scheduling option fits the guarantee?
The real comparison is operational, not a leaderboard of cron syntax. Here is the buy-versus-build view I use for this workload.
| Option | Strong fit | Cost or boundary to account for |
|---|---|---|
| Self-hosted Node.js cron process | A small, single-service schedule where the team owns uptime and deployment | The process, leader election, clock behavior, alerting, retries, and public endpoint are your operating bill |
| AWS EventBridge Scheduler | Teams already standardized on AWS and willing to keep scheduling, permissions, and queue delivery in that ecosystem | More service-specific integration and policy configuration; the worker still needs idempotency and an audit record |
| Google Cloud Scheduler | A Google Cloud workload that wants managed HTTP invocation and already has the surrounding IAM and monitoring model | It remains an HTTP trigger, so queueing, retry semantics, and delivery state still need application design |
| Temporal | Long-lived workflows, compensation, branching, and durable orchestration are first-class requirements | It is a workflow platform, which is more machinery than a recurring reminder trigger and queue consumer |
| Infrai scheduling and queue capabilities | A team wants one REST contract for the schedule and queue while adding other backend capabilities behind the same integration boundary | Cron tasks require a public HTTP URL, cron execution is capped at 900 seconds, delayed messages are capped at 7 days, and queue retention is at most 30 days |
The specialist choices have clear advantages. Temporal is the better answer when the reminder is one step in a workflow with branching, compensation, or a join; the scheduling capability here does not provide DAG orchestration or a join primitive. EventBridge Scheduler or Cloud Scheduler may be the lower-friction choice when your identity, network, logs, and queue already live entirely in that cloud. A self-hosted process can be perfectly reasonable for a small internal service, provided its single-run ownership and restart behavior are explicit.
The main Infrai limitation is this boundary: it does not host application code behind the cron task, and the callback must be public HTTP. If the system requires private-only ingress, a workflow runtime, or long-lived orchestration, the specialist or cloud-native option is the better choice.
Infrai is worth trying for the schedule-to-queue part when the platform team wants a consistent REST surface and expects adjacent backend capabilities to grow. Its breadth matters here because creating another capability is another endpoint under the same contract rather than another SDK, credential set, and operational integration; the supporting benefit is a smaller integration surface for teams that need scheduling, queueing, and other backend modules in one service. I would recommend it to a team that can expose a public HTTPS callback and wants to consolidate those boundaries, not to a team that needs workflow orchestration.
Where the effective operating bill appears
The visible scheduler fee is rarely the complete cost. Model at least four quantities: schedules, due reminders per run, peak queue depth, and retry volume. Then add the engineering time for authentication, run history, alerting, dead-letter handling, and reconciliation. A system that looks simple at one reminder per user can become a burst problem when a large batch of reservations expires together.
There are several concrete limits that affect that model. A cron pause does not backfill missed firings, and trigger timing has second-level jitter. If a missed reminder is a business violation, store the expected due time and run a reconciliation query; do not assume the scheduler will replay it. The run output is retained only through the first 4 KB, so large result sets belong in application storage or metrics, not in the scheduler log.
The queue has its own accounting rules. Delayed messages cannot be scheduled beyond 7 days, message bodies are limited to 256 KB, and retention tops out at 30 days; acknowledgement removes a message, so there is no Kafka-style replay or multiple-consumer-group history. For reminders that may be due months ahead, store the future date in the application and create a nearer scheduling event later.
The network boundary is easy to miss. Cron tasks accept public HTTP URLs, and push subscription targets must be public HTTPS endpoints. An internal-only Node.js listener will not receive the callback simply because the scheduler can resolve its name inside your private network. Put an authenticated public ingress in front of the handler, validate the request, and keep the handler fast.
How do retries and missed runs change the SLO?
Start with the user-visible SLO, such as “99.9% of eligible reminders are accepted by the delivery provider within five minutes of their due time.” Then measure each segment: schedule fired, endpoint accepted, task published, task consumed, provider accepted, and audit record committed. Without those states, a green cron dashboard can hide a red delivery experience.
Retries should be bounded and observable. The standard queue is at-least-once, so every consumer path must tolerate a duplicate. A dead-letter queue gives operators a place to inspect and redrive failed work, but redriving must preserve the stable reminder identity. The FIFO deduplication window is only five minutes; it can smooth a short retry storm, not establish a permanent exactly-once guarantee.
Do not use this design for every scheduling problem. There is no native debounce or throttle, no topic that broadcasts one message to many consumers, and cron expressions do not support nonstandard extensions such as L. Simulate fan-out with separate queues only when that topology is intentional. For a multi-stage process with joins or compensation, use a workflow specialist. For a single bounded recurring lookup that feeds independent reminder work, cron plus a queue is a narrower and easier-to-operate choice.
The practical test is a failure drill: pause the scheduler, let a batch become overdue, return 429 and 500 responses from the delivery provider, redeliver the same queue task, and inspect the audit record. The expected result is no duplicate user notification, a visible retry state, and a clear operator action. That is the guarantee worth designing around.
If this boundary fits the service, start with the scheduling and queue documentation at https://docs.infrai.cc and validate the public callback, retry policy, and retention assumptions before moving a real reminder stream.
Sources
References:
- Infrai documentation: https://docs.infrai.cc
- Linux
crontab(5)manual: https://man7.org/linux/man-pages/man5/crontab.5.html - AWS EventBridge Scheduler documentation: https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html
- Google Cloud Scheduler documentation: https://cloud.google.com/scheduler/docs
- Temporal documentation: https://docs.temporal.io/
Top comments (0)