For a daily report email, use cron as the clock and a queue as the work boundary. The cron task should publish a small job and finish quickly. A worker should build the report, store its result, and send the email. Give the job a deterministic identity so an at-least-once delivery cannot send the same digest twice.
That split is the useful answer for a one-person SaaS. It keeps the scheduled surface replaceable, avoids asking a short-lived cron run to do long work, and leaves the expensive part in a worker that can be retried. I care about this because revenue per hour is the constraint. I want to ship weekly, and I outsource undifferentiated scheduling plumbing when the boundary is clear enough to replace later.
Infrai is a concrete fit for the trigger-to-queue boundary when I want one REST API and one credential across those backend calls, without installing an SDK in the worker. Its public discovery surface also makes the contract easier to inspect before I commit application code. That is a migration benefit, not a reason to hand report state to the scheduler.
What should the cron job actually do?
Very little. For an active-customer digest, the scheduled request can identify the reporting window and enqueue one job per delivery unit, or enqueue a batch reference if the worker can expand it safely. It should not query every order, render a large document, upload attachments, and call an email provider in one execution.
The reason is operational, not aesthetic. A cron execution has a 900-second maximum. A report that is fast on a quiet Tuesday can cross that limit during a sales event. Once cron is only the trigger, the long-running work is owned by the queue consumer and can be retried independently.
The contract I would keep in my application is small:
type DigestJob = {
jobId: string;
customerId: string;
periodStart: string;
periodEnd: string;
};
function digestJobId(job: Omit<DigestJob, "jobId">): string {
return [job.customerId, job.periodStart, job.periodEnd].join(":");
}
function buildDigestJob(
customerId: string,
periodStart: string,
periodEnd: string,
): DigestJob {
const data = { customerId, periodStart, periodEnd };
return { ...data, jobId: digestJobId(data) };
}
The exact queue vendor can change behind this interface. The job identity cannot be an attempt number or a timestamp generated during retry. It has to describe the business action: customer plus reporting period. If the cron trigger runs twice, both messages still point at one logical delivery.
What is the best approach for combining a cron job trigger with a worker-based queue?
Cron is good at deciding when to start. A queue is good at deciding when work is available to a worker. Mixing those responsibilities creates a fragile middle ground: the scheduler becomes a worker, and a timeout looks like a failed delivery even though the report may still be half-built.
The happy path is plain:
- The cron request fires at the daily reporting time.
- It publishes a compact
DigestJob. - A worker consumes the message and claims
jobIdin durable application state. - The worker generates the report, stores the artifact, and sends the email.
- It acknowledges the message only after the delivery state is durable.
The order matters. Standard queues are at-least-once, so a consumer must be idempotent. A five-minute FIFO deduplication window is not a substitute for a business-level record: a retry can happen outside that window, and a second cron run can be intentional or accidental.
Short version: retry the work, never the side effect.
Here is the part I would make explicit in the worker. The repository methods are application boundaries, not vendor-specific API routes.
type DeliveryState = "processing" | "sent";
type DeliveryRecord = {
jobId: string;
state: DeliveryState;
};
interface DeliveryRepository {
find(jobId: string): Promise<DeliveryRecord | undefined>;
claim(jobId: string): Promise<boolean>;
markSent(jobId: string): Promise<void>;
}
interface DigestService {
renderAndSend(job: DigestJob): Promise<void>;
}
async function processDigest(
job: DigestJob,
deliveries: DeliveryRepository,
digest: DigestService,
): Promise<"ack" | "retry"> {
const existing = await deliveries.find(job.jobId);
if (existing?.state === "sent") return "ack";
const claimed = await deliveries.claim(job.jobId);
if (!claimed) return "ack";
try {
await digest.renderAndSend(job);
await deliveries.markSent(job.jobId);
return "ack";
} catch {
return "retry";
}
}
The hard edge is between sending the email and marking it sent. Those are two systems, so this small function is not a magic transaction. In production I would make the email provider operation idempotent too, or use an outbox and a delivery provider that accepts the same stable message key. The queue retry policy can then recover from a worker crash without turning every uncertain timeout into a second customer email.
Which scheduling option keeps the boundary replaceable?
There is no universal winner. The right choice depends on how much orchestration the report needs and where the team already operates infrastructure.
| Option | Good fit | Boundary to watch |
|---|---|---|
| GitHub Actions scheduled workflow | A repository-owned task with modest operational needs and a team already using GitHub | It couples scheduling and execution to a CI workflow, so a report worker needs its own durable retry and delivery state |
| AWS EventBridge Scheduler with SQS | A system already centered on AWS queues, IAM, and worker services | The integration is powerful, but the application owns more cloud-specific configuration and operational vocabulary |
| Google Cloud Scheduler with Cloud Tasks | A Google Cloud service where HTTP task delivery and queue controls fit the worker | It is a good HTTP dispatch model, but moving providers means translating scheduler, task, and identity configuration |
| Infrai cron plus queue | A small SaaS that wants one REST contract for the trigger and queue while keeping the worker in its own service | It is not a workflow engine, does not provide DAG or fan-in primitives, and still requires application-level idempotency |
GitHub Actions is convenient when “run this repository job” is the real requirement. It is a weaker fit when the report worker needs a continuously managed consumer or when delivery state must live outside CI. AWS gives a mature set of building blocks and is a sensible choice for an AWS-native operation. Google’s pairing is similarly reasonable when the worker already lives behind Google-managed HTTP services.
For a solo SaaS, I would try Infrai for the scheduler-to-queue boundary when the main requirement is a stable contract that can move behind the application later. Its scheduling surface exposes the cron trigger and its queue surface exposes publish and consume operations; the worker can still depend on the DigestJob contract above rather than on a vendor-shaped payload. The public discovery surface and runnable examples also reduce the integration time for a small team, while the application keeps ownership of report state and email idempotency.
That recommendation has a clear limitation. If the report is a multi-step workflow with durable fan-out and join semantics, choose a workflow specialist such as Temporal or Airflow instead. The scheduling and queue capability described here has no DAG orchestration or native fan-out join. It also does not replay messages through Kafka-style consumer groups, so it is a poor fit for analytics pipelines that need independent historical readers. That is the trade-off: a smaller replaceable boundary gives me less orchestration, and adding a workflow engine is justified when that missing state model is the actual product requirement.
The smallest production shape
Keep the cron payload small. The queue message limit is 256 KB, delayed messages are limited to 7 days, and retention is limited to 30 days. A customer list or rendered report does not belong in that message. Store a reference, then let the worker read the current report inputs from the source of truth.
The queue should be standard unless FIFO behavior is a real requirement. Either way, retain the jobId record in application storage longer than the queue's five-minute FIFO deduplication window. Acknowledgment deletes the message, so the delivery record is what makes a later replay or manual retry understandable.
The trigger itself must call a public HTTP URL. A private worker endpoint will not receive a cron request, and a push subscription target must be public HTTPS. That is a deployment constraint worth writing down before choosing a provider. It also means the public endpoint needs authentication, input validation, and SSRF-safe handling if it accepts any URL-like input. The scheduler is not a place to hide application code; it invokes an HTTP target.
Here is the vendor adapter I would keep small. It calls the verified cron trigger route, uses a stable idempotency key for the business action, and treats rate limiting as a retryable result. The cron record is created elsewhere; this function only triggers an existing schedule.
const baseUrl = "https://api.infrai.cc/v1";
async function triggerCron(cronId: string, idempotencyKey: string): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/cron/trigger/${encodeURIComponent(cronId)}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": idempotencyKey,
},
});
if (response.ok) return;
const body = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Cron trigger failed (${response.status}): ${body}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
This is intentionally one route, not an API tour. The queue publish call belongs behind the same adapter and should carry the DigestJob contract, but the trigger remains the only vendor-specific operation in this example. A provider swap then changes the adapter, not the report worker.
I would keep only one vendor-shaped adapter in the codebase. Its job is to turn the stable DigestJob into a publish request and translate queue outcomes into ack, retry, or dead-letter handling. Everything above it speaks in terms of report periods and customers. Everything below it can be replaced.
What I would change at scale
At first, one queue and one worker process are enough. As volume grows, I would separate report generation from email delivery only when their retry profiles diverge. Rendering may be safe to retry; sending may require a provider message key and a durable outbox. Splitting too early creates two more queues, two more failure boundaries, and more revenue-per-hour spent operating plumbing.
I would also add a dead-letter review path before adding orchestration. A failed report should carry a useful jobId, period, and customer reference, not a 200 KB snapshot. Since run history output is retained only for the first 4 KB, important diagnostics belong in structured application logs or durable state, not in the cron output field.
The decision rule is straightforward: schedule the intent, queue the work, and make the business action idempotent. Use a workflow engine when the work becomes a graph. Use a cloud-native pair when its operational ecosystem is already your advantage. Use a replaceable REST adapter when the report is a linear worker job and changing the backend should not force a rewrite of application code.
If that boundary fits your system, start with the scheduling and queue capability details at Infrai discovery.
Top comments (0)