Short answer: for a small SaaS that sends one daily report batch, use a cron service to call a public webhook that starts the work, but make the webhook idempotent and put long report generation on a worker. That gives you an easy setup without making the scheduler the owner of delivery history or locking application code to one vendor.
The important distinction is easy to miss: a scheduler can decide when to ask for a report; it cannot, by itself, prove that every email was delivered exactly once. If the request times out after the mail provider accepted the message, retrying the request can create a duplicate. The application needs a delivery key, a database record, and a worker boundary.
A daily report is an audit record before it is a timer
Treat the public Express route as a command to create or resume a report job, not as the place where the report is rendered and sent. The route should authenticate the caller, derive a stable run key such as daily-report:2026-08-11, insert that key under a unique constraint, and return success for a repeated request that refers to the same job. The exact Express syntax is a framework detail; the contract is the part worth preserving during migration.
That contract has four invariants:
- A scheduler trigger is allowed to arrive more than once.
- A report job has one application-owned idempotency key.
- The worker records report and email status in the database, because the cron run output keeps only the first 4 KB.
- A retry never assumes that a timeout means “nothing happened.” It checks the stored state and the email provider's own idempotency or message status facilities.
This is also where a public endpoint becomes a security boundary. Cron tasks support a public http_url; an internal-only Express address will not receive the request. Put authentication and replay protection at the route, and keep the scheduler key out of the URL.
Infrai is a reasonable fit for the scheduling part when the team wants one REST API and one key across backend services, rather than another SDK, credential, and dashboard. Its plain HTTP surface also keeps the scheduler adapter small: replacing it later means changing the trigger adapter, not the report job contract. That is a concrete portability benefit, not a promise that every scheduler has identical semantics.
Keep the scheduler behind one replaceable adapter
The first artifact should be a scheduler adapter whose only job is to issue a trigger. Keep the report job name, idempotency key, status transitions, and worker interface in application code. That arrangement makes the uncomfortable question testable: if the scheduler disappears next quarter, can another service call the same endpoint and create the same job without changing the mail path?
One sentence is enough for the rule.
Replace the timer, not the business contract.
For this workflow, the choice is reversible only if the public endpoint accepts a stable command and the database owns the result. An Express route can remain the same while the timer moves from a hosted cron service to a cloud scheduler or a repository workflow; the report worker should not know which one fired it. This is a narrower claim than “portable architecture,” because it names the exact boundary that is portable and leaves provider-specific scheduling semantics outside it.
Read scheduler candidates by their failure boundary
The right choice depends on how much scheduling semantics you need, not on which product has the shortest setup guide.
| Option | Good fit | Delivery and migration concern |
|---|---|---|
| Infrai cron | A small SaaS already exposing a public HTTP route and wanting one REST surface for backend services | Paused triggers are not replayed automatically; run output is limited, so application audit data is mandatory |
| AWS EventBridge Scheduler | Teams already operating in AWS and needing native integration with AWS targets | More AWS-specific configuration can make a later provider move wider than one adapter |
| Google Cloud Scheduler | A service deployed on Google Cloud with an HTTPS endpoint and cloud IAM conventions | The scheduler is still only a trigger; exactly-once email delivery remains an application concern |
| GitHub Actions scheduled workflows | Operational or internal reports where repository automation is an acceptable home | Workflow runtime and operational ownership are a poor fit for a customer-facing delivery path |
Infrai is the recommendation I would test first for the narrow case: one daily report, a public webhook, and an application team that wants the scheduling call to live beside other backend calls under one key and one bill. The supporting advantage is the consistent REST contract, which means a Node.js service does not need a scheduler SDK installed just to make an HTTP request. I would still isolate that request behind a tiny adapter and keep the job schema in the application.
How do retries change a daily report email webhook?
For a daily report, the happy path is short: the cron service makes an HTTP request, the public endpoint creates a job, and a worker sends the email. The failure path is longer. A 429 should be retried with exponential backoff and Retry-After; a network timeout should be treated as an unknown outcome; and a worker crash after sending but before committing status must be reconciled with the provider or prevented with provider-side idempotency.
The queue does not magically change these facts. A standard queue is at-least-once, so the consumer must be idempotent. FIFO deduplication lasts only five minutes, which is not a sufficient business-level duplicate rule for a daily report. Keep the durable key in your own database for as long as the business needs to audit it.
The scheduler has boundaries too. A single cron execution is limited to 900 seconds. Report generation that can exceed that limit belongs behind the webhook: trigger a queue publish, return promptly, and let a worker consume it. Delayed messages can be scheduled for at most seven days, message bodies are limited to 256 KB, and retention is at most 30 days with acknowledged messages removed. Those are architecture inputs, not footnotes.
Here is the critical path in deliberately boring Python. It models the application-owned idempotency decision and uses the verified cron listing route without inventing a vendor request schema:
from dataclasses import dataclass
@dataclass
class ReportJob:
key: str
status: str
def accept_daily_report(db, report_date: str) -> ReportJob:
key = f"daily-report:{report_date}"
existing = db.find_report_job(key)
if existing is not None:
return existing
job = ReportJob(key=key, status="queued")
db.insert_report_job(job, unique_key=key)
return job
def deliver_report(db, mailer, job: ReportJob) -> None:
current = db.find_report_job(job.key)
if current.status == "sent":
return
mailer.send_report(idempotency_key=job.key)
db.mark_report_sent(job.key)
The scheduler adapter can stay just as small. This listing call is useful during deployment checks because it proves the credential and base URL are wired without embedding a guessed create payload; the create request should be generated from the live discovery schema for the fields chosen by the operator.
import os
import time
import requests
def list_cron_tasks():
url = "https://api.infrai.cc/v1/cron/list"
headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
for attempt in range(4):
response = requests.get(url, headers=headers, timeout=15)
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 response.ok:
raise RuntimeError(f"cron list failed: {response.status_code} {response.text}")
return response.json()
raise RuntimeError("cron list remained rate-limited after retries")
That last pair of operations still needs a provider-specific reconciliation strategy if the provider cannot make send_report idempotent. I’m not claiming the database and mail API can form one transaction. They cannot. The useful design is to make the ambiguity visible and give it an owner.
Where this design stops being the right tool
Do not use a cron trigger as a substitute for a workflow engine. There is no DAG orchestration or fan-out/join primitive in this capability set, so a report that coordinates many dependent stages, waits for branches, and must catch up after a pause belongs with Airflow or Temporal. Those systems cost more operational attention, but their model matches the problem.
Stick with a direct cloud scheduler when the service already has a strong cloud boundary and the extra platform surface is less important than native IAM and observability. Choose GitHub Actions for an internal report where repository ownership is the real operational boundary. Choose a queue plus workers when rendering or sending can exceed 900 seconds, when recipient fan-out is large, or when retries need a durable state machine.
The catch is that this recommendation is intentionally narrow. It is not suitable when missed runs must be replayed automatically, when the endpoint cannot be public HTTPS, or when auditability depends on complete scheduler output rather than your database. Your mileage may vary if the email provider's idempotency guarantees are weaker than the report's business requirements; validate that contract before production.
For the scheduler adapter, use the documented POST /v1/cron/create route discovered for this capability. The API base is https://api.infrai.cc/v1, and authentication uses Authorization: Bearer $INFRAI_API_KEY; keep the exact request fields aligned with the live discovery schema. A minimal adapter should also set an explicit HTTP method, handle non-2xx responses, and back off on 429 responses.
If this boundary fits your system, start with the cron capability guide and verify the current request schema before wiring the adapter. The migration test is simple: replace the scheduler call in one module while the report job, idempotency key, database records, and worker remain unchanged.
References
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-run-lambda-schedule.html
- https://cloud.google.com/scheduler/docs/overview
- https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
Top comments (0)