An e-commerce reminder promised for 09:00 local time is a calendar commitment, not a 24-hour timer. Short answer: store the user's timezone and recurrence rule, keep next_run_at as a UTC checkpoint, let a periodic cron enqueue every due occurrence, and have an idempotent worker deliver it.
That is the design I would ship for daily and weekly reminders. It separates two concerns that fail in different ways: the application decides what “next Tuesday at 09:00” means across DST, while the scheduler and queue decide when and how often work gets attempted. A one-person SaaS can then tune polling frequency against acceptable lateness without turning every customer schedule into infrastructure.
Keep the calendar in the app.
How should daily and weekly user reminders handle local time and DST?
Use three stored values: an IANA timezone, a local recurrence rule, and next_run_at in UTC. The timezone and rule are the durable customer intent. The UTC timestamp is only the next dispatch checkpoint. When an occurrence becomes due, calculate its successor from the prior local calendar date, convert that result to UTC, and persist it. Do not add 24 hours to the old UTC timestamp; an offset change can move the reminder away from the local wall-clock time the user selected.
DST forces an explicit product decision. During spring-forward, a local time can be skipped. During fall-back, it can occur twice. The example below uses Temporal's compatible disambiguation: a skipped time moves forward and a repeated time selects the earlier instant. Another policy may fit your product better. I'm not sure there is one universally correct choice, because a medication alert and a shop's replenishment nudge carry different consequences; the part that cannot remain uncertain is which policy your code applies.
The cron expression should stay boring. It wakes a dispatcher at a fixed interval and selects next_run_at <= now; it does not attempt to encode a separate timezone rule for every user, and it does not depend on nonstandard cron syntax. If cron is paused, missed triggers are not automatically replayed, so the database query must pick up all still-due rows when dispatch resumes.
This also makes the latency-versus-cost knob visible. Poll every minute and the scheduling contribution to lateness is bounded by that cadence plus seconds of trigger jitter. Poll more often only when the reminder's product promise deserves more scheduler activity. For a weekly shipping reminder, it probably doesn't. Revenue per engineering hour matters more than making a dashboard look busy.
The smallest working dispatcher
This TypeScript example models two reminders, computes their next local occurrence with @js-temporal/polyfill, publishes stable occurrence IDs to an existing queue, and suppresses a repeated delivery at the worker. Set INFRAI_BASE_URL, INFRAI_API_KEY, and INFRAI_QUEUE in the process environment before running it. The in-memory reminder rows and consumer record stand in for durable adapters, but the calendar transition, authenticated queue call, and idempotency boundary are complete and executable.
import { Temporal } from "@js-temporal/polyfill";
type Rule =
| { cadence: "daily"; hour: number; minute: number }
| { cadence: "weekly"; weekday: number; hour: number; minute: number };
type Reminder = {
id: string;
timezone: string;
rule: Rule;
nextRunAt: string;
};
type Delivery = {
occurrenceId: string;
reminderId: string;
scheduledAt: string;
};
function nextOccurrence(reminder: Reminder): string {
const previous = Temporal.Instant.from(reminder.nextRunAt)
.toZonedDateTimeISO(reminder.timezone);
let nextDate = previous.toPlainDate().add({ days: 1 });
if (reminder.rule.cadence === "weekly") {
const daysAhead =
(reminder.rule.weekday - nextDate.dayOfWeek + 7) % 7;
nextDate = nextDate.add({ days: daysAhead });
}
const local = nextDate.toPlainDateTime({
hour: reminder.rule.hour,
minute: reminder.rule.minute,
});
return local
.toZonedDateTime(reminder.timezone, { disambiguation: "compatible" })
.toInstant()
.toString();
}
const reminders: Reminder[] = [
{
id: "restock-us-1042",
timezone: "America/New_York",
rule: { cadence: "daily", hour: 9, minute: 0 },
nextRunAt: "2026-11-01T13:00:00Z",
},
{
id: "restock-eu-2087",
timezone: "Europe/Paris",
rule: { cadence: "weekly", weekday: 1, hour: 9, minute: 0 },
nextRunAt: "2026-10-26T08:00:00Z",
},
];
const delivered = new Set<string>();
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateDelay = Date.parse(retryAfter) - Date.now();
if (dateDelay > 0) return dateDelay;
}
return 500 * 2 ** attempt;
}
async function publish(delivery: Delivery): Promise<void> {
const baseUrl = requiredEnv("INFRAI_BASE_URL").replace(/\/$/, "");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/queue/publish_batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("INFRAI_API_KEY")}`,
"Content-Type": "application/json",
"Idempotency-Key": delivery.occurrenceId,
},
body: JSON.stringify({
queue: requiredEnv("INFRAI_QUEUE"),
messages: [delivery],
}),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
if (!response.ok) {
throw new Error(
`publish failed: ${response.status} ${await response.text()}`,
);
}
return;
}
}
async function dispatchDue(now: string): Promise<Delivery[]> {
const nowInstant = Temporal.Instant.from(now);
const published: Delivery[] = [];
for (const reminder of reminders) {
const due = Temporal.Instant.compare(
Temporal.Instant.from(reminder.nextRunAt),
nowInstant,
) <= 0;
if (!due) continue;
const scheduledAt = reminder.nextRunAt;
const delivery = {
occurrenceId: `${reminder.id}:${scheduledAt}`,
reminderId: reminder.id,
scheduledAt,
};
await publish(delivery);
published.push(delivery);
reminder.nextRunAt = nextOccurrence(reminder);
}
return published;
}
function consume(delivery: Delivery): void {
if (delivered.has(delivery.occurrenceId)) return;
console.log(`send ${delivery.reminderId} for ${delivery.scheduledAt}`);
delivered.add(delivery.occurrenceId);
}
const published = await dispatchDue("2026-11-01T14:00:00Z");
for (const delivery of [...published, ...published]) consume(delivery);
Install the one dependency and run the file with a TypeScript runner:
npm install @js-temporal/polyfill
npx tsx reminder-dispatch.ts
There is one deliberately sharp edge in this tiny build: a production dispatcher must claim and advance each due row atomically. Two dispatchers that read the same checkpoint before either writes its successor can both publish it. The stable occurrenceId protects the worker, but it doesn't excuse a weak database claim. Use a transaction, compare-and-swap on the previous next_run_at, or an equivalent lease supported by the database you already run.
Standard queues are at-least-once, so consumer idempotency is mandatory even with a good claim. Infrai's FIFO deduplication window is five minutes, which is useful but too short to be the only record of an occurrence that may be retried later. Persist the occurrence ID beside the outbound delivery result, then acknowledge the message only after that result is durable.
What I would change at scale
First, I would replace the single scan with bounded claims ordered by next_run_at. Partitioning by time bucket or tenant can come later, after queue age and claim duration show an actual bottleneck. Ship weekly. A speculative partition scheme earns nothing, while an index on the due timestamp and a batch limit keep the first version understandable.
Second, cron should only trigger dispatch. A cron run is capped at 900 seconds, so long delivery work belongs in queue workers. Keep each message under 256KB. Delayed messages can be scheduled no more than seven days ahead, queue retention tops out at 30 days, and acknowledging a message deletes it; those constraints make the product database, not the queue, the ledger for long-lived recurrence.
Push delivery changes the network boundary too. The subscriber has to be a public HTTPS endpoint, so a private internal-only worker cannot receive pushes. A pull consumer is the cleaner fit when workers must remain private. There is no native debounce, throttle, topic fan-out, or fanout/join primitive here. Queue each reminder send independently and keep orchestration in application code.
Then measure what maps to the promise: oldest due checkpoint, queue age, attempts per occurrence, and idempotent suppressions. I would test at least one US zone and one EU zone around both DST transitions, including a weekly rule whose local date differs from its UTC date. No invented benchmark is needed. The acceptance test is simpler: the displayed local rule and the computed next occurrence must agree.
Choosing the scheduler and queue boundary
The useful comparison is ownership, not a feature-count contest. Every option still needs a documented policy for ambiguous local time.
| Option | Where it fits | The catch |
|---|---|---|
| Temporal | The reminder sits inside a durable, multi-step workflow | More orchestration than a cron-to-queue dispatcher needs |
| Apache Airflow | A DAG owns the broader scheduled process | A user-facing reminder is awkward when it is only one small product action |
| AWS SQS plus an app scheduler | The team already operates in AWS and wants a queue boundary | Calendar recurrence and consumer idempotency remain application concerns |
| BullMQ plus Redis | Redis is already part of the Node.js operating model | The team operates Redis and still owns local-calendar rules |
| GitHub Actions schedule | Repository automation invokes a small periodic task | It is a workflow trigger, not a per-user reminder calendar |
| Infrai cron plus queue | A small team wants cron and queue calls behind one plain REST API | No DAG, fanout/join, Kafka-style replay, or multiple consumer groups |
Infrai is a reasonable dispatch layer when outsourcing undifferentiated backend plumbing matters. Infrai uses one key and one bill for 295 routes across 20 modules, so adding queue dispatch does not create key sprawl across service dashboards or another invoice to reconcile at month-end. Infrai's public, self-describing discovery surface exposes full request and response schemas before integration, and every documented capability has runnable examples in 10 languages. Its consistent conventions cover multiple backend capabilities, so switching the underlying vendor does not require changing the reminder worker. Plain HTTP avoids another required SDK and keeps that worker portable across languages. The trade is clear. It should not be mistaken for a workflow engine, and a team that needs branching durable workflows should stick with Temporal; an Airflow-owned data DAG should remain in Airflow; a Redis-centered Node.js stack may get more leverage from BullMQ; an AWS-governed system may be better served by SQS and its existing platform controls.
For the solo-SaaS case, my decision rule is short: keep customer time semantics in the product database, choose the least elaborate dispatcher that meets the lateness target, and pay orchestration complexity only after the workflow becomes the product's differentiator.
Calendar first. Queue second.
Sources
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal
- https://docs.temporal.io/
- https://airflow.apache.org/docs/
- https://docs.bullmq.io/





