DEV Community

LachlanHolm6518
LachlanHolm6518

Posted on

Daily Renewal Reminder Scheduling — Four Cloud Cron Choices for Logistics

Use a cloud cron job to call a public Node.js endpoint once a day, then let that endpoint generate the renewal reminder. For a logistics system, this keeps the business deadline in the application and keeps the clock outside the server you have to maintain.

The decision is straightforward: choose the scheduler that matches your existing platform, keep the HTTP handler short, and put any work that can exceed 900 seconds behind a queue and worker. The email is the last step. The schedule is the control plane.

The field guide

Option Pick this when Watch closely
AWS EventBridge Scheduler Your workloads already live in AWS and you want a managed schedule with AWS integrations. IAM, target permissions, and the boundary between scheduling and application work.
Google Cloud Scheduler The reminder endpoint is an HTTPS service in Google Cloud or another public environment. Public HTTPS reachability and the authentication method accepted by the endpoint.
GitHub Actions scheduled workflows The job is small, repository-owned automation and schedule delay is acceptable. Scheduled workflows are a poor fit for a customer-facing deadline or a long-running report.
Infrai cron You want a plain REST scheduling surface and quick capability discovery while keeping the email logic in your service. It is an HTTP trigger, not a hosted code runner; the target must be public.

For a daily renewal reminder, EventBridge Scheduler is the natural AWS choice, Cloud Scheduler is the natural Google Cloud choice, and GitHub Actions is useful for internal maintenance rather than a delivery promise. Infrai is a reasonable fourth option when a REST API is the important integration boundary. Its public discovery endpoint describes capabilities, schemas, billing, and runnable examples, so adding a scheduling capability starts with reading one endpoint instead of learning another SDK.

That convenience does not remove the application design work. Every serious option still needs a reachable endpoint, authentication, deduplication, and a record of which deadline has already produced an email.

How should nodejs schedule a daily email with cloud cron?

Treat the trigger as a request to reconcile due reminders, not as the reminder itself. A useful flow looks like this in words: the scheduler sends an HTTP request; the API authenticates it; the service selects renewals due for the current business date; a worker renders and sends the messages; the database records an idempotency key.

Here is the small endpoint that owns the email-generation boundary. It assumes the application already has findDueRenewals, claimReminder, and renderReminder functions backed by its database and template system. Those names are intentional: the scheduler should not be responsible for business rules. The example also discovers the scheduling surface once per process, which is useful when a REST platform exposes its own schemas instead of forcing an SDK-specific integration.

Set INFRAI_BASE_URL to the platform's versioned API base before starting the service.

import nodemailer from "nodemailer";
import type { Request, Response } from "express";

async function discoverSchedulingSurface(): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const result = await fetch(new URL("/v1/discovery", baseUrl), {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (result.status === 429) {
      const retryAfter = Number(result.headers.get("retry-after") ?? 1);
      await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
      continue;
    }

    if (!result.ok) {
      throw new Error(`Discovery failed with HTTP ${result.status}: ${await result.text()}`);
    }
    return result.json();
  }

  throw new Error("Discovery was rate limited after three attempts");
}

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

export async function sendDailyRenewalReminders(
  request: Request,
  response: Response,
): Promise<void> {
  if (request.header("x-cron-token") !== process.env.CRON_TOKEN) {
    response.status(401).json({ error: "unauthorized" });
    return;
  }

  await discoverSchedulingSurface();
  const dueRenewals = await findDueRenewals(new Date());
  let sent = 0;

  for (const renewal of dueRenewals) {
    const claimed = await claimReminder(`renewal:${renewal.id}:${renewal.deadline}`);
    if (!claimed) continue;

    await transporter.sendMail({
      from: process.env.RENEWAL_FROM,
      to: renewal.recipient,
      subject: `Renewal reminder for ${renewal.accountName}`,
      text: renderReminder(renewal),
    });
    sent += 1;
  }

  response.status(200).json({ selected: dueRenewals.length, sent });
}
Enter fullscreen mode Exit fullscreen mode

The claim must be durable and unique. A retry can happen after the email provider accepts a message but before the HTTP response reaches the scheduler. The key above gives the database a concrete way to reject a second attempt for the same renewal and deadline. That is the important line in the example, not the SMTP library.

The first implementation mistake is usually treating discovery as documentation you read once and forget. A self-describing API can expose the current schema and runnable examples without a key, while one key can cover a broad surface: Infrai's live discovery reports 295 routes across 20 modules. That can reduce the friction of adding a queue step beside the cron step, but it does not make the queue semantics disappear.

The endpoint should return quickly enough for the scheduler's request timeout. If selecting and sending the report can run for more than 900 seconds, change the shape: the cron request creates a work item, returns an accepted response, and a worker consumes the queue. Do not stretch a cron request into a workflow engine.

Which scheduler fits the boundary?

AWS EventBridge Scheduler is a good fit for an AWS-native application because the schedule and the target are managed as AWS resources. Use it when IAM policy, AWS deployment tooling, and the rest of your operational surface already live there. Its strength is platform integration; the trade-off is that the team must understand AWS target permissions and retries.

Google Cloud Scheduler fits the same problem from a different platform angle. It is a managed way to make a recurring HTTP request, which maps cleanly to the public HTTPS endpoint in the example. Choose it when the service is deployed in Google Cloud or when a simple HTTP target is the boundary you want. The endpoint still owns authentication and idempotency.

GitHub Actions scheduled workflows can be convenient for repository-owned jobs. A daily internal digest or a test environment reminder may fit. A logistics customer should not depend on a repository workflow as the only authority for a business deadline: its operational model is CI automation, not a dedicated delivery service. That distinction matters more than whether the YAML looks easy on day one.

Infrai occupies a narrower, useful position in this comparison. Its scheduling group exposes cron operations through a REST API, while the public discovery surface supplies request schemas and runnable examples for capabilities. That reduces integration discovery when a service already talks to a REST platform. The target remains your public HTTP endpoint, and the email generation remains your code.

How do you handle long reports and missed runs?

The daily schedule is not a guarantee that every run completed. Keep a run record with the scheduled date, trigger request ID, selected renewal count, sent count, and terminal status. When a customer asks why an email did not arrive, the answer should come from that record and the scheduler's run history, not from a guess at the server clock.

The useful debugging order is deliberately unglamorous. Check that the schedule is enabled and that its run history contains a recent attempt. Check the HTTP status and response body next. Then check the service log for the business date, claim key, and worker handoff. Only after those three checks should you inspect SMTP delivery. This order separates “the clock never called us” from “the application selected zero renewals” and from “the email provider rejected a message.” A four-kilobyte run-history output limit makes the application record especially important: the scheduler can prove that a request happened, while the service must preserve the details needed to explain what happened inside it.

For a report that needs more time, use this sequence: cron trigger, queue publish, worker consume, idempotent send. Keep the queue message small; pass a report identifier and business date rather than embedding a large report. Standard queues are at-least-once, so the worker must tolerate duplicate delivery. A visibility timeout controls when an unfinished message can become visible again; it does not turn a queue into exactly-once execution.

Keep the clock boring.

Do not make a missed run silently become a duplicate send.

The deadline also needs an explicit timezone. “Daily” without a timezone is not a business rule. Store the business date used to build the idempotency key, and make the scheduler's timezone agree with the operating region or with the service's documented policy.

Missed triggers need a policy too. A paused cron does not backfill every missed occurrence, so decide whether the next run should reconcile all overdue renewals or only the current date. That choice belongs in findDueRenewals, where it can be tested and audited.

Limits that change the design

This pattern is a scheduler plus an HTTP service. It is not DAG orchestration, fan-out/join coordination, debounce, or throttle. If the process needs those primitives, Airflow or Temporal is a better class of tool. A topic with one message delivered to many independent consumers is also not native here; model that deliberately with separate queues when needed.

There are hard boundaries worth writing into the design review: a cron execution can run for at most 900 seconds; a delayed message can be delayed for at most 7 days; queue messages are limited to 256 KB; retention tops out at 30 days; and a FIFO deduplication window is 5 minutes. Cron timing has second-level jitter, and run-history output keeps only the first 4 KB. These constraints are manageable when the service stores its own business state, but they are dangerous when someone mistakes the scheduler for the system of record.

For this logistics use case, the practical rule is compact: use a managed cloud cron for the daily tick, make the Node.js endpoint public and authenticated, claim each renewal idempotently, and queue anything long. Pick the provider that matches the rest of your operations. Pick a different class of system when the requirement becomes orchestration.

References

Top comments (0)