DEV Community

MortimerNilsson7694
MortimerNilsson7694

Posted on

Minimal Node.js SaaS Cron Scheduling for Idempotent Daily Report Emails

A rate-limited media worker pool changes the answer. The simplest SaaS cron scheduler is the one that starts one daily run, records a durable idempotency key, and leaves retries to a worker that can prove the email was already sent. A calendar UI does not solve duplicate reports. The goal is automating a few daily tasks with minimal setup, not building another control plane.

TL;DR: use one daily schedule, a small enqueue endpoint, a durable job record, and a retry policy with explicit ownership. Keep the scheduler boring. Put correctness in the job boundary.

Keep it boring.

What makes a daily email scheduler simple?

The schedule is the easy part. A daily report email has four separate operations: decide that a run is due, enqueue work, render the report, and deliver it. Treating those operations as one request creates a bad failure mode. A timeout after delivery can look like a failed job, so the next attempt sends the same report again.

For a media system, key the run by the report date and recipient group, not by the scheduler's attempt number. daily-report:2026-10-23:regional-editors stays stable across retries. The date should come from an agreed timezone and be stored with the run. Do not derive it from the machine's local clock halfway through processing.

The smallest useful contract is clear. I prefer this small boundary because every extra scheduling feature adds setup, another clock, or another retry policy:

  1. The scheduler asks for a run once per day.
  2. The enqueue service inserts the idempotency key with a uniqueness constraint.
  3. A worker claims the job and retries only errors that may recover.
  4. The delivery record decides whether another send is allowed.

That is enough for a first build. Every failure has a place to live.

Build log: the smallest working implementation

The example uses a generic HTTP endpoint and a TypeScript worker. A managed cron service can call the endpoint, but the endpoint remains the application-owned boundary. The jobs table needs a unique index on idempotency_key; an in-memory set is not durable enough for a process restart.

type ReportJob = {
  idempotencyKey: string;
  reportDate: string;
  audience: string;
};

type EnqueueResult =
  | { status: "accepted"; jobId: string }
  | { status: "duplicate"; jobId: string };

async function enqueueDailyReport(job: ReportJob): Promise<EnqueueResult> {
  const response = await fetch("https://app.example.test/internal/report-runs", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.SCHEDULER_TOKEN}`,
    },
    body: JSON.stringify(job),
  });

  if (!response.ok) {
    throw new Error(`enqueue failed with status ${response.status}`);
  }

  return (await response.json()) as EnqueueResult;
}

await enqueueDailyReport({
  idempotencyKey: "daily-report:2026-10-23:regional-editors",
  reportDate: "2026-10-23",
  audience: "regional-editors",
});
Enter fullscreen mode Exit fullscreen mode

The endpoint should use an insert-or-return-existing operation. If two scheduler attempts arrive together, one creates the job and the other receives the existing job ID. Both responses are successful from the scheduler's point of view. Duplicate delivery is a data problem, not proof that the scheduler failed. In practice, this means the uniqueness constraint is part of the API contract, not just a database optimization: the handler must distinguish “new work accepted” from “this exact work already exists,” return the same logical job identity, and avoid resetting a completed delivery when the duplicate arrives. That one choice covers scheduler overlap, manual replay, and a worker process that reconnects after a timeout.

The token also deserves a real boundary. Keep it outside source control, rotate it through the deployment system, and give it only the permission required to enqueue this job. OWASP's key management guidance treats key lifecycle, storage, rotation, and compromise response as part of the design. A secret in a cron URL is still a secret.

How should retries and idempotency interact?

A connection reset before the application receives the request is usually retryable. A validation error is not. A timeout after the mail provider accepted the message is ambiguous, so blindly retrying is unsafe unless delivery accepts the same idempotency key or the application records a provider-side message identity.

Use a short retry budget first. Three attempts with increasing delays is a reasonable starting test, followed by a visible dead-letter state. The exact delay depends on the provider and rate limit; the important part is that the budget is explicit and observable. More retries do not make a non-idempotent operation safe.

I initially treated sent as a boolean. That was too coarse. A run needs states that explain what happened: queued, running, delivered, failed, and unknown. unknown is uncomfortable but honest. It stops an operator from confusing “the worker crashed” with “the message was never accepted.”

For a rate-limited pool, claim work with a lease and renew it while generating the report. A lease timeout should make the job eligible for another worker, but the email send still needs the same delivery key. Otherwise, two workers can finish after a slow report query and both send. The scheduler's once-a-day guarantee cannot prevent that race.

What should be measured before choosing a service?

Measure the path that creates user-visible risk, not just scheduler latency. Record schedule-to-enqueue delay, queue wait time, report generation duration, delivery duration, retry count, and the age of the oldest pending job. Keep a counter for duplicate-key responses. A rising duplicate count can mean the schedule is invoked twice, or that a deployment is replaying requests. Either interpretation deserves attention.

Test Expected result
Two identical enqueue requests One job, two safe responses
Worker crash before delivery Lease expires; job can be retried
Timeout after provider acceptance No second message with the same delivery key
Rate-limit response Delayed retry, no hot loop
Invalid report date Permanent failure with an alert

Run these tests against a fake mail transport before putting a real mailbox in the loop. Include a controllable clock. A daily schedule is difficult to test when “tomorrow” depends on wall-clock time and daylight-saving rules.

What I would change at scale

At higher volume, separate scheduling from admission control. The scheduler creates at most one logical run; a queue controls concurrency; a rate limiter controls calls to the report data source and mail transport. Store a content hash for the rendered report too. It does not replace an idempotency key, but it helps explain why two runs with different keys produced the same or different content.

This is the scale change I would make first: keep the scheduler thin and make the job store richer. A second scheduler would make setup look easy while spreading correctness across more components.

The cron expression is not the hard part.

I would not add a second scheduling layer until metrics show a real need. Each layer adds a clock, a retry policy, and another place to investigate. A managed cron service may remove infrastructure work, while a self-hosted scheduler may offer more control over placement and failure recovery. Those are operational trade-offs, not a reason to move correctness into either scheduler.

The decision rule is straightforward: choose the smallest scheduler that can invoke a protected endpoint on the required cadence, then verify that your own job store makes duplicate invocations harmless. For daily report emails, retry and idempotency are the product requirements. The cron expression is just configuration.

References

Top comments (0)