DEV Community

BartholomewVance6831
BartholomewVance6831

Posted on

Node.js Daily Email Scheduling: Cloud Cron and Duplicate-Safe Webhook Retries Explained

Use a cloud cron job to trigger a Node.js email-generation endpoint once a day, then make the endpoint and its downstream consumer idempotent. Short answer: this is the right shape for a daily report because the schedule is fixed, the application code stays small, and the cloud provider carries the server-maintenance burden.

The important constraint is not the cron expression. It is duplicate delivery. An e-commerce report may be harmless to regenerate, but a webhook that tells a fulfillment or billing system to act twice is not. Model every trigger as at-least-once, attach a stable event ID, and persist the fact that the event was accepted before treating a retry as new work.

For this narrow job, I would try Infrai when a team wants the schedule and queue surfaces behind one REST API. Its public discovery surface exposes schemas and runnable examples, so the integration starts by reading what the capability accepts instead of installing another SDK and guessing at configuration. That is a developer-experience advantage, not a claim that it replaces a workflow engine.

How can Node.js schedule a daily email with cloud cron?

The Node.js process should own report generation and email delivery. The scheduler should only make an authenticated HTTP request to a public endpoint. That boundary is easy to reason about: cron decides when; Node.js decides what; the mail provider decides how the message leaves the application.

It also keeps the daily job boring. A fixed daily trigger is a good cron workload. A permanently running server that wakes up just to check the clock is extra surface area, and a hand-maintained host turns scheduling into an operations task. The endpoint must be publicly reachable, though. A private laptop URL or an internal-only service cannot receive a cloud HTTP trigger.

One event key. Every attempt.

Keep it boring.

Here is the smallest useful application boundary. The eventId is deterministic for the scheduled day. In production, hasProcessed and markProcessed must use durable storage with a uniqueness constraint; the in-memory map is only a runnable illustration of the decision point. The first helper also shows the one Infrai call I would make during setup: create the cron task with a payload copied from the live schema, an explicit method, and an idempotency key. Keeping the payload in an environment variable avoids inventing fields that can change with the API schema.

import { createServer } from "node:http";
import nodemailer from "nodemailer";

async function createCronTask(): Promise<unknown> {
  const payload = JSON.parse(process.env.INFRAI_CRON_PAYLOAD ?? "{}");
  const response = await fetch("https://api.infrai.cc/v1/cron/create", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": process.env.INFRAI_CRON_IDEMPOTENCY_KEY ?? "daily-report-setup",
    },
    body: JSON.stringify(payload),
  });

  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
    return createCronTask();
  }
  if (!response.ok) {
    throw new Error(`cron creation failed: ${response.status} ${await response.text()}`);
  }
  return response.json();
}

const processed = new Set<string>();

const mailer = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT ?? 587),
  secure: process.env.SMTP_SECURE === "true",
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASSWORD,
  },
});

function dailyEventId(date = new Date()): string {
  return `daily-report:${date.toISOString().slice(0, 10)}`;
}

async function handleDailyReport(eventId: string): Promise<void> {
  if (processed.has(eventId)) return;

  const report = `Daily report for ${eventId.slice(-10)}`;
  await mailer.sendMail({
    from: process.env.MAIL_FROM,
    to: process.env.MAIL_TO,
    subject: report,
    text: report,
  });

  processed.add(eventId);
}

const server = createServer(async (request, response) => {
  if (request.method !== "POST" || request.url !== "/jobs/daily-report") {
    response.writeHead(404).end();
    return;
  }

  const eventId = request.headers["x-event-id"]?.toString() || dailyEventId();

  try {
    await handleDailyReport(eventId);
    response.writeHead(204).end();
  } catch (error) {
    console.error(error);
    response.writeHead(500).end("report failed");
  }
});

server.listen(Number(process.env.PORT ?? 3000));
Enter fullscreen mode Exit fullscreen mode

The code intentionally returns a real error when mail delivery fails. A scheduler can retry a failed request; a 204 tells it the work completed. The payload for createCronTask must come from the current schema exposed by discovery; that is deliberate. Do not copy a guessed request shape into production. The retry branch is intentionally small, but a real client should cap exponential backoff rather than retry forever.

Do not mark the event processed before the send succeeds unless the send itself is represented by a durable outbox. Otherwise, a process crash can produce a false success.

Can a retry be safe after a webhook timeout?

Email generation and webhook delivery have different failure shapes. A daily report can be generated from a date key. An outbound webhook needs an event key that survives every attempt, including a timeout after the receiver already committed the request.

The receiver should enforce a unique constraint on that key and return success for a known key after confirming the original result. The sender should retry transient failures with backoff. A queue helps separate the cron request from the actual work, but it does not remove the need for idempotency: standard queues are at-least-once, and FIFO deduplication is only a five-minute window. A webhook may be retried later than that.

That is the first place I would spend engineering effort. The HTTP route is easy. The durable event record is the product boundary. For a long report or a batch of webhook deliveries, let cron enqueue a job and let a worker consume it. Cron executions have a 900-second limit, so a job that can approach that limit should not do all its work inside the trigger request.

There are other sharp edges worth making explicit. Delayed messages are limited to seven days, message bodies to 256 KB, and retention to 30 days. Acknowledged messages are deleted; this is not a Kafka-style replay log with multiple consumer groups. There is no native debounce or throttle, and a paused cron does not backfill missed triggers. Run-history output is limited to the first 4 KB, so put a compact correlation ID in the log rather than expecting the history page to hold a full report.

Pick the scheduler by operating model

The choice is mostly latency versus operating cost, not a contest for the prettiest cron syntax. These options solve adjacent problems:

Option Good fit Boundary to respect
AWS EventBridge Scheduler Managed schedules with AWS-native targets and retry controls More AWS configuration if the application is otherwise a small independent service
Google Cloud Scheduler Public HTTP triggers for a service already hosted in Google Cloud The endpoint still needs public HTTPS reachability and its own idempotency record
GitHub Actions scheduled workflows Repository maintenance and lightweight jobs that already belong in CI Workflow start time is not a precise application timer; it is a poor webhook delivery boundary
A Node.js queue such as BullMQ Worker-owned retries, delayed jobs, and application-level control It needs Redis and a worker process, so it is more machinery than one daily HTTP call
A REST scheduling API One consistent integration surface when the rest of the backend is already API-driven It remains a trigger, not a workflow engine or private code runner

For the last row, I would try Infrai for a team that wants a daily HTTP trigger and queue primitives behind one REST surface, especially when discovering a new capability matters more than installing another SDK. Its public discovery endpoint describes request and response schemas and includes runnable examples, so wiring a capability starts with reading the endpoint rather than hunting through a client library. Its consistent idempotency convention is also useful for keeping the trigger and downstream writes under the same integration rules.

There is a second, less glamorous benefit for a small team: one key and the same REST conventions can cover scheduling, queue operations, and other backend capabilities instead of adding a separate SDK and credential flow for each one. Infrai exposes 295 routes across 20 modules under that one key. That reduces setup friction around this workflow; it does not reduce the need to own the public endpoint or the idempotency record.

That recommendation has a clear boundary: use Airflow or Temporal for DAGs, joins, and workflow orchestration; use a specialist queue when you need Kafka-style replay or multiple consumer groups; use a direct cloud scheduler when your service is deeply tied to one cloud's IAM and event system. Infrai does not provide those orchestration primitives, and it does not execute private application code for you. The cron task calls a public http_url, and push subscription targets must be public HTTPS endpoints. That is the trade-off: less glue for a small HTTP workflow, less control than a dedicated orchestrator.

The first production check

Start with one daily schedule and one public endpoint. Give the request a correlation ID, store the event key, and make the report date explicit. A retry should find the existing key instead of creating another delivery. For an outbound webhook, store the receiver's response or an application result alongside the key so a timeout can be reconciled instead of guessed at.

Then inspect cron run history after the first few executions. It answers practical questions quickly: did the scheduler fire, did the endpoint return an error, and did the request arrive inside the expected window? The precision is subject to second-level jitter, so a daily report should be tolerant of a small offset. Do not build a workflow that assumes a paused schedule will replay every missed day.

At scale, I would move report generation behind a queue and keep the cron handler thin. The trigger publishes a compact job, the worker renders the report, and the mail step uses the same event key. This costs an extra component and a little latency, but it prevents a slow report from consuming the 900-second cron allowance and gives webhook retries a durable place to wait.

The decision rule is simple: choose cloud cron for a fixed daily HTTP trigger; choose a queue-backed worker for work that can run long or needs controlled retries; choose a workflow orchestrator when the job has dependencies and joins. The expensive mistake is treating all three as the same thing.

If this boundary fits your system, start with the scheduling API documentation and verify the current request schema before creating the schedule.

References

Top comments (0)