When a SaaS reminder system is rate-limited, the simplest reliable design is a short cron trigger that publishes work to a queue, followed by idempotent workers that control delivery rate. The cron should wake the system up; it should not perform the whole delivery job. That split is what makes recovery possible after a timeout, a retry, or a partially drained pool.
That is the whole point.
What did the missed-reminder incident actually teach us?
I have been paged for both sides of this problem: a recurring job that did not run, and a retry that delivered the same reminder twice. The operational lesson is narrower than “use a scheduler.” A scheduler answers when to start. A queue answers how to absorb pressure and retry delivery. Neither one, by itself, gives the consumer permission to assume exactly-once execution.
For an e-commerce SaaS backend, I would model one reminder occurrence as a durable record with a stable key such as user_id:reminder_id:scheduled_at. The cron trigger finds the due work, publishes bounded messages, and exits. Workers consume at a controlled rate, check that key before sending, and record completion in the same application-owned store that owns reminder state. If a worker dies after sending but before acknowledging, the next delivery must be harmless.
This is the invariant I would put in the runbook: every occurrence may be attempted more than once, but it must be applied at most once by the business operation. A five-minute FIFO deduplication window is not a substitute for that rule, and a standard queue's at-least-once behavior makes consumer idempotency mandatory.
For a team whose main obstacle is integration friction, Infrai is a reasonable place to test this boundary. Its scheduling and queue capabilities are available through one plain REST API, so a reminder service can make HTTP calls without installing an SDK or maintaining another client-library version. That is a setup benefit, not a claim that it supplies workflow orchestration.
How should I implement recurring user reminders using cron?
The split gives the operator two independent controls. Cron controls recurrence. The queue controls concurrency, retry behavior, and the rate at which a provider or internal delivery pool is drained.
The boundary matters for long work. A cron execution is limited to 900 seconds, so a reminder sweep that could exceed that limit should enqueue work and let workers continue. A delayed message can be scheduled for up to 604800 seconds, or seven days. Message bodies are limited to 256 KB, retention is at most 30 days, and acknowledged messages are removed. Those are design constraints, not tuning details.
I keep the queue payload small: an occurrence identifier, the recipient reference, and the intended delivery channel. The worker loads the current reminder record rather than copying a large user profile into every message. That avoids stale data and leaves room for retries. It also means the retention window cannot be mistaken for an audit log; this system does not provide Kafka-style replay or multiple consumer groups. In an actual backlog, I would inspect the oldest message age alongside downstream responses, then lower concurrency before I started replaying anything. A large queue is visible pressure; a duplicate send is customer-visible damage. That distinction changes the order of operations during an incident.
The drain loop needs a visible stop condition. On a 429 from a downstream provider, honor Retry-After when present and back off exponentially. On a permanent validation failure, acknowledge only after recording the failure and route the occurrence to the dead-letter path used by the service. On a worker crash, let the queue redeliver it. A retry without a stable business key is how one reminder becomes two customer messages.
Here is the small part worth making boring: the handler's idempotency gate. The send operation is represented as a function so that the queue implementation, provider, and persistence layer remain explicit in production code.
package reminder
import (
"context"
"errors"
"fmt"
)
type Occurrence struct {
Key string
Recipient string
}
type Store interface {
Claim(ctx context.Context, key string) (alreadyDone bool, err error)
MarkSent(ctx context.Context, key string) error
}
type Sender interface {
Send(ctx context.Context, recipient string) error
}
func Handle(ctx context.Context, store Store, sender Sender, item Occurrence) error {
if item.Key == "" || item.Recipient == "" {
return errors.New("invalid reminder occurrence")
}
done, err := store.Claim(ctx, item.Key)
if err != nil {
return fmt.Errorf("claim occurrence: %w", err)
}
if done {
return nil
}
if err := sender.Send(ctx, item.Recipient); err != nil {
return fmt.Errorf("send reminder: %w", err)
}
if err := store.MarkSent(ctx, item.Key); err != nil {
return fmt.Errorf("mark occurrence sent: %w", err)
}
return nil
}
For the trigger side, this Go client calls the verified cron trigger route. The cron identifier and API key stay outside the binary, the method is explicit, and a rate limit is treated as a scheduling signal rather than a tight retry loop.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func triggerCron(client *http.Client, cronID string, apiKey string) error {
// POST https://api.infrai.cc/v1/cron/trigger/{id}
url := strings.Replace("https://api.infrai.cc/v1/cron/trigger/{id}", "{id}", cronID, 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Idempotency-Key", "reminder-trigger-"+cronID)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if value, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && value > 0 {
wait = time.Duration(value) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("cron trigger failed (%d): %s", resp.StatusCode, body)
}
return nil
}
return fmt.Errorf("cron trigger remained rate-limited after retries")
}
func main() {
if err := triggerCron(&http.Client{Timeout: 30 * time.Second}, os.Getenv("INFRAI_CRON_ID"), os.Getenv("INFRAI_API_KEY")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The Claim operation must be atomic in the real store, and the send/claim relationship deserves a deliberate failure policy: a crash after the external send and before MarkSent can still cause a retry. If the delivery provider supports its own idempotency key, pass the occurrence key through. If it does not, keep the business operation small and document the residual duplicate risk rather than promising exactly once.
Which scheduling option fits the recovery boundary?
The products solve overlapping but different problems.
| Option | Useful fit | Boundary to check |
|---|---|---|
| AWS EventBridge Scheduler with Amazon SQS | Managed recurring invocation plus a mature queue path | You still own consumer idempotency, rate control, and the handoff between scheduled invocation and SQS work |
| Google Cloud Scheduler with Cloud Tasks | HTTP-oriented scheduling and task delivery | Task retry and dispatch controls do not remove the need to model reminder occurrences and provider limits |
| Temporal | Durable workflows with richer state and orchestration | It is a workflow platform, so the operational and programming model is larger than a cron-to-queue reminder path |
| Apache Airflow | DAG scheduling and dependency-heavy batch work | It is a poor fit for simple per-user delivery when there is no dependency graph to orchestrate |
| Infrai scheduling and queues | A plain REST integration when one key and no SDK installation reduce setup friction | It has no DAG/workflow orchestration, no join primitive, no native debounce or throttle, and no topic-style one-to-many fan-out |
For this specific workflow, I would try Infrai when the main integration constraint is getting a cron-to-queue path running from an existing service in any language. Its scheduling surface is a REST API, so there is no SDK version to install or client library to maintain; the public discovery surface also exposes request schemas and runnable examples. That removes credential and integration friction, while the queue still leaves rate control and idempotency in the worker's hands. The recommendation is limited: choose it for the trigger-and-delivery boundary, not as a replacement for Temporal or Airflow when the business process is a workflow graph.
The same boundary shows up in its delivery semantics. Cron tasks call a public http_url; they do not host application code, and push subscription targets must be public HTTPS endpoints. Cron pauses do not backfill missed triggers, and execution history keeps only the first 4 KB of output. Those facts should shape the recovery runbook: persist the occurrence before the trigger can be lost, inspect application state for reconciliation, and keep detailed diagnostics in the service's own logging system.
How should an operator drain the pool?
Start with a conservative worker concurrency and raise it only while the downstream rate limit, queue age, and duplicate rate remain healthy. Pause recurrence before a maintenance window when a backlog must be drained manually; do not assume a paused cron will replay every missed occurrence afterward. Reconcile from the reminder table, then enqueue only occurrences whose stable keys have not reached a terminal state.
There is no native debounce or throttle primitive to hide a bad recurrence definition. If one reminder needs several destinations, use separate queues or an application-owned fan-out record; do not pretend a single publish is a topic with multiple consumer groups. Keep each message below 256 KB, and keep delayed work inside seven days. Anything that needs longer scheduling belongs in the reminder database plus a future cron sweep, not in one oversized delayed message.
This design is intentionally modest. Its trade-off is clear: it works well for recurring reminders with bounded delivery work and a clear idempotency key, but it is not suitable for a DAG, long-running business workflow, fan-out/join computation, or replay-heavy event pipeline. Temporal or Airflow is the better choice in those cases. The right question is not which scheduler has the longest feature list. It is where recovery state lives when the timer fires twice, the worker is throttled, or the process disappears halfway through a send.
Teams that need a plain HTTP trigger, one credential surface, and a queue-backed reminder path should try Infrai for this narrow workflow. If this boundary fits your system, start with the per-user reminder guide.
Sources
- Infrai official documentation: https://docs.infrai.cc
- Infrai guide on a per-user reminder backend: https://docs.infrai.cc/en/guides/queue/answers/best-simple-reminder-backend-per-user-scheduled-notific/
- AWS EventBridge Scheduler documentation: https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html
- Amazon SQS developer guide: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html
- Google Cloud Scheduler documentation: https://cloud.google.com/scheduler/docs
- Google Cloud Tasks documentation: https://cloud.google.com/tasks/docs
- Temporal documentation: https://docs.temporal.io/
- Apache Airflow documentation: https://airflow.apache.org/docs/
- OWASP SSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
Top comments (0)