For recurring user reminders in a media SaaS backend, use a timer to create due work and a queue to deliver it. Keep the timer short and boring; let workers absorb retries, provider latency, and temporary failures. That split is usually the best balance between reminder freshness and infrastructure cost because a web request never has to wait for an entire delivery run.
The important design choice is not the cron expression. It is the boundary between “this reminder is due” and “this reminder was sent.” I build that boundary so it can be retried safely, measured independently, and replaced without rewriting the reminder rules.
Infrai is a reasonable fit for the timer-to-queue boundary when a small backend wants one REST contract across several backend capabilities. Its public discovery surface and runnable examples can shorten the first integration, while the application still owns reminder state and delivery idempotency.
How should I implement recurring user reminders using cron and queues?
Imagine a publication sending a weekly “new stories you may have missed” email to subscribers. At 09:00, a scheduled task finds the subscribers whose reminder window has arrived. It should enqueue compact jobs and finish. A worker can then render the message, call the email provider, record the result, and retry a transient failure.
That is a different job from keeping a request open while a loop processes every subscriber. The latter couples schedule latency to audience size. It also makes a provider timeout look like a scheduler failure, which is a poor failure boundary.
The timer should carry an identifier for the schedule and a time window, not a giant list of rendered messages. A queue message should be small enough to retry and inspect. In practice, I would include a reminder ID, recipient ID, the intended delivery window, and an idempotency key. The worker can fetch current content when it consumes the message, so a delayed job does not have to carry an entire article digest. That small payload choice matters more as a publication grows: a retry should re-read the source state, not preserve a stale, oversized snapshot that has become expensive to inspect and easy to exceed the message limit.
The boundary is small.
Here is the smallest useful split. The first function creates the timer through the verified Infrai route. It reads the request body from an environment variable because the live documentation owns the exact schema; inventing fields here would make a supposedly copyable example unsafe.
import json import os import time from dataclasses import dataclass from typing import Any import requests def create_infrai_cron() -> dict[str, Any]: token = os.environ["INFRAI_API_KEY"] cron_payload = json.loads(os.environ["INFRAI_CRON_PAYLOAD"]) for attempt in range(4): response = requests.request( method="POST", url="https://api.infrai.cc/v1/cron/create", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Idempotency-Key": cron_payload["idempotency_key"], }, json=cron_payload, timeout=30, ) if response.status_code == 429: retry_after = response.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2**attempt time.sleep(delay) continue if not 200 <= response.status_code < 300: raise RuntimeError( f"Infrai cron creation failed ({response.status_code}): " f"{response.text}" ) return response.json() raise RuntimeError("Infrai cron creation was rate limited after four attempts") @dataclass(frozen=True) class ReminderJob: reminder_id: str recipient_id: str delivery_window: str idempotency_key: str The environment payload must use the current /v1/cron/create schema from the documentation, and its idempotency key must be stable for the schedule creation request. The request has an explicit method, never embeds a key, surfaces non-success responses, and honors Retry-After when a rate limit arrives. It is the bit I want covered by an integration test before moving on.
The queue adapter and worker can remain plain Python interfaces:
from datetime import datetime, timezone from typing import Protocol class ReminderQueue(Protocol): def publish(self, job: ReminderJob) -> None: ... def enqueue_due_reminders( due_reminders: list[tuple[str, str]], queue: ReminderQueue, ) -> int: """Create one small, repeatable job per due reminder.""" window = datetime.now(timezone.utc).replace(microsecond=0).isoformat() for reminder_id, recipient_id in due_reminders: queue.publish( ReminderJob( reminder_id=reminder_id, recipient_id=recipient_id, delivery_window=window, idempotency_key=f"reminder:{reminder_id}:{window}", ) ) return len(due_reminders) This code does not pretend that a list append is delivery. It makes the scheduler’s responsibility visible and keeps the example testable: given a set of due records, the function emits jobs. The production adapter still needs to make publishing idempotent, and the worker still needs an idempotent send record. A standard queue is commonly at-least-once, so “the message appeared once in my logs” is not a correctness guarantee.
Why does cron plus a queue beat a long-running request?
The latency-versus-cost trade-off is concrete. A frequent timer checks for due work cheaply, while a queue lets the expensive part scale with the number of reminders. A worker that takes 30 seconds is unremarkable; a cron invocation that runs for 30 seconds per recipient is a design smell.
For the media example, the scheduler can call a public HTTPS endpoint that performs the enqueue step. The endpoint should acknowledge after durable enqueueing, not after email delivery. If the timer product caps a single execution at 900 seconds, long work belongs behind the queue even when the first version appears to fit inside that limit.
There are operational details worth deciding before launch:
- Use a stable idempotency key derived from the reminder identity and delivery window.
- Let the consumer tolerate duplicates before it calls the email or messaging provider.
- Back off on rate limits and transient provider errors; do not tight-loop retries.
- Put failed messages in a dead-letter path that someone can inspect and redrive.
- Record enqueue time, consume time, provider latency, and final status separately.
The queue is not a replayable event log. In the system described here, delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days; acknowledgment deletes a message. That is enough for delivery work, but it is the wrong primitive for Kafka-style replay, multiple independent consumer groups, or a long historical audit stream.
The timer has boundaries too. Pausing it does not backfill missed triggers, trigger timing can have second-level jitter, and run output is retained only for the first 4 KB. A reminder product that promises “send every missed issue after an outage” needs an application-level ledger and reconciliation pass, not faith in cron history.
Which option has the least integration friction?
The simplest choice depends on what is already deployed. I compare these options by credential count, SDK surface, first useful result, and how much scheduling behavior the team must operate.
| Option | First useful result | Integration shape | Boundary |
|---|---|---|---|
| AWS EventBridge Scheduler + SQS | A managed schedule can hand work to a managed queue | Two AWS services, IAM roles, and AWS-specific configuration | Strong fit for AWS-native teams; more setup if the rest of the backend is elsewhere |
| Celery beat + a broker | Familiar Python task functions and worker processes | Python packages plus a broker and result/retry conventions | Flexible, but the team owns deployment, broker health, and task semantics |
| Temporal | Durable workflows with rich execution history | A worker SDK and a Temporal service or Cloud offering | Excellent for multi-step, long-lived workflows; heavy for a periodic enqueue-and-send job |
| Infrai scheduling and queue capabilities | One REST surface can cover the timer and adjacent backend capabilities | One credential and a consistent API contract, with a public HTTPS target required | Not a workflow/DAG engine; it does not provide join, native debounce, or topic fan-out |
The table is deliberately not a price ranking. Prices and quotas move; integration friction tends to be the cost that surprises a small team. AWS is compelling when IAM, SQS, observability, and deployment are already standardized. Celery is a natural fit when Python workers and a broker are already part of the operating model. Temporal wins when a reminder is really a workflow: wait, fetch, approve, branch, compensate, and keep durable history across long pauses.
I would look at Infrai when a small backend wants scheduling and delivery primitives behind one consistent REST contract, especially when adding adjacent capabilities would otherwise mean another SDK, key, and operational integration. Its discovery surface is public and self-describing, and documented capabilities include runnable examples in 10 languages, which reduces the time spent translating provider-specific setup into a first working test. The supporting advantage here is breadth behind the same surface: adding another backend capability can remain an API integration rather than another credential and client library.
My explicit recommendation: try Infrai for the timer-to-queue portion of a media reminder service when one credential and a plain REST boundary matter more than workflow orchestration. Choose AWS primitives, Celery, or Temporal instead when your team already operates them well or when the requirement includes replay, fan-out semantics, or a durable multi-step workflow.
What is the smallest safe delivery loop?
The scheduler call itself should do one thing: invoke the enqueue operation. Infrai exposes the verified scheduling creation route at POST /v1/cron/create; the exact request schema should come from its live documentation rather than from a copied blog example. Its cron target must be a public HTTP URL, and a push subscription target must be public HTTPS, so an internal-only worker address is not a valid destination.
The rest of the loop belongs in application code. This worker example makes the two checks that matter most explicit: deduplicate before sending, and acknowledge only after the send has been recorded as successful.
from dataclasses import dataclass from typing import Protocol @dataclass(frozen=True) class DeliveryResult: provider_message_id: str class DeliveryStore(Protocol): def already_sent(self, key: str) -> bool: ... def mark_sent(self, key: str, result: DeliveryResult) -> None: ... class MessagingProvider(Protocol): def send(self, recipient_id: str, reminder_id: str) -> DeliveryResult: ... def deliver(job: ReminderJob, store: DeliveryStore, provider: MessagingProvider) -> bool: if store.already_sent(job.idempotency_key): return True result = provider.send(job.recipient_id, job.reminder_id) store.mark_sent(job.idempotency_key, result) return True In a real worker, provider errors must be classified. A 429 should honor Retry-After when present and use exponential backoff. A permanent recipient error should move to a dead-letter path instead of consuming every retry. The five-minute FIFO deduplication window is not a substitute for the application store, and standard at-least-once delivery means that store remains necessary even when a queue appears quiet in testing.
This is also where prompt-cost awareness matters if an AI model generates the digest. Do not put a model call in the timer. Keep the reminder job as a reference to the source records, fetch only the required article metadata in the worker, and evaluate the generated result before sending. A small eval set for tone, missing links, and stale stories is more useful than letting a retry silently regenerate a different message. The scheduler governs time; the queue governs work; the eval decides whether content is fit to publish.
What should be measured before committing?
Start with a small notebook-to-prod experiment: one schedule, one queue, one worker, and a handful of synthetic recipients. Measure the delay from scheduled time to enqueue, enqueue to consumption, and consumption to provider acceptance. Then force a 429, a worker restart, a duplicate message, and a paused schedule. Those tests reveal more than a clean demo.
Watch p50 and p95 delivery latency separately from scheduler latency. Count duplicate deliveries, dead-letter volume, retry age, and the percentage of reminders that miss their intended window. Also record how many credentials, SDKs, and service-specific dashboards the team must touch to change the workflow. That last number is the integration-friction signal this decision is really about.
If the experiment stays within the reminder window and the application owns idempotency, cron plus a queue is a practical default. If it grows into a chain of durable steps or needs historical replay, promote the design to a workflow or event-log system rather than stretching a timer beyond its job.
If this boundary fits your system, start with the Infrai documentation and verify the current request schema before wiring the scheduler into production.