DEV Community

HoratioFox1281
HoratioFox1281

Posted on

How to Guarantee Timezone-Aware Recurring User Reminder Delivery in Node.js

A marketplace reminder is a timezone-aware recurring user promise, and a rate-limited worker pool may be delayed, restarted, or deliberately drained. TL;DR: store the user's local-time rule and IANA time zone, derive one UTC occurrence, then hand that occurrence to an idempotent queue job. That preserves daily and weekly reminders through daylight-saving changes while giving workers a recoverable delivery boundary.

This is a delivery guarantee, not a timer.

Three fields carry the contract: an IANA zone such as America/New_York, a local wall-clock time, and a cadence. The queue carries one derived occurrence. Small model. Big difference.

How Should Node.js Handle Timezone-Aware Recurring User Reminders?

A UTC timestamp is an instant. A recurring reminder is a civil-time rule. Those are different things.

Before the change, a person in America/Los_Angeles might receive a daily reminder at 09:00 from one UTC instant; after the offset changes, the same 09:00 maps to another instant. Reusing yesterday's UTC hour moves the reminder by an hour for that person. Repeating an interval of 24 hours creates the same error.

There is a second trap. During a spring transition, some local times do not exist. During a fall transition, some occur twice. A scheduler needs an explicit policy for both cases, plus a recorded occurrence key so a retry does not turn one promise into two messages.

The example chooses the compatible rule: move a nonexistent wall time forward to a real instant and choose the earlier instant for a repeated wall time. That is a deliberate trade-off, because the product needs a deterministic answer even where a local clock has two or zero matching instants. Another domain can select a different policy, but it should encode and test that choice rather than inherit server-time behavior by accident.

Build the Next Local Occurrence First

The example uses the Temporal polyfill so the time-zone arithmetic stays in the application model. A weekday uses ISO values 1 through 7, where 1 is Monday. The loop is deliberately bounded at nine calendar dates: daily rules return immediately or on the next date, and weekly rules find their requested day within seven days.

import { Temporal } from '@js-temporal/polyfill';

type ReminderRule = {
  id: string;
  userId: string;
  timeZone: string;
  localTime: string;
  cadence: 'daily' | 'weekly';
  weekday?: number; // ISO: Monday is 1, Sunday is 7
};

export function nextOccurrence(
  rule: ReminderRule,
  now: Temporal.Instant = Temporal.Now.instant(),
): Temporal.Instant {
  if (rule.cadence === 'weekly' && (!rule.weekday || rule.weekday < 1 || rule.weekday > 7)) {
    throw new Error('Weekly reminders need an ISO weekday from 1 to 7');
  }

  const [hour, minute] = rule.localTime.split(':').map(Number);
  if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
    throw new Error('localTime must be HH:MM');
  }

  let date = now.toZonedDateTimeISO(rule.timeZone).toPlainDate();
  for (let daysAhead = 0; daysAhead <= 8; daysAhead += 1) {
    const matches = rule.cadence === 'daily' || date.dayOfWeek === rule.weekday;
    if (matches) {
      const candidate = Temporal.ZonedDateTime.from(
        { timeZone: rule.timeZone, year: date.year, month: date.month, day: date.day, hour, minute },
        { disambiguation: 'compatible' },
      ).toInstant();

      if (Temporal.Instant.compare(candidate, now) > 0) return candidate;
    }
    date = date.add({ days: 1 });
  }

  throw new Error('No occurrence found inside the recurrence window');
}
Enter fullscreen mode Exit fullscreen mode

Do not persist only the returned instant. Persist the rule too, including the zone identifier supplied by the user. Time-zone rules change over time, and rebuilding future occurrences from the civil-time promise prevents old derived data from becoming the source of truth.

Turn an occurrence into one idempotent job. Before, the scheduler owns an unstable side effect: it finds a due reminder and sends it. After, it owns a durable fact: this reminder has one occurrence at this instant. A separate worker owns delivery.

type ReminderJob = {
  key: string;
  reminderId: string;
  userId: string;
  scheduledFor: string;
};

type JobStore = {
  insertIfAbsent(job: ReminderJob): Promise<boolean>;
};

export async function enqueueOccurrence(
  store: JobStore,
  rule: ReminderRule,
  now: Temporal.Instant,
): Promise<boolean> {
  const scheduledFor = nextOccurrence(rule, now).toString();
  const job: ReminderJob = {
    key: rule.id + ':' + scheduledFor,
    reminderId: rule.id,
    userId: rule.userId,
    scheduledFor,
  };

  return store.insertIfAbsent(job);
}
Enter fullscreen mode Exit fullscreen mode

The handoff needs observability. Count occurrences_discovered, jobs_inserted, and duplicate-key conflicts separately. A growing gap between the first two can be normal during scheduler retries. A widening delay from scheduledFor to worker start is a capacity or upstream-rate-limit signal. Alert on the delay distribution and on the age of the oldest unfinished job, not on a single failed attempt.

How Should a Rate-Limited Pool Recover?

Drain the queue in bounded leases. The lease must exceed the worker's normal processing time, and the worker must extend it for unusually slow downstream work. A visibility timeout has the same purpose in queue systems that support it: it temporarily hides a received message while it is being processed. If the worker dies, the job becomes eligible again.

type LeasedJob = ReminderJob & { leaseId: string };

type WorkerQueue = {
  claim(limit: number, leaseSeconds: number): Promise<LeasedJob[]>;
  complete(leaseId: string): Promise<void>;
  release(leaseId: string, retryAfterSeconds: number): Promise<void>;
};

export async function drainOnce(queue: WorkerQueue, send: (job: ReminderJob) => Promise<void>) {
  const jobs = await queue.claim(25, 90);

  await Promise.all(jobs.map(async (job) => {
    try {
      await send(job);
      await queue.complete(job.leaseId);
    } catch {
      await queue.release(job.leaseId, 30);
    }
  }));
}
Enter fullscreen mode Exit fullscreen mode

Those numbers are examples, not a throughput claim. Start with a batch of 25, a 90-second lease, and a 30-second retry delay only after measuring the downstream service and the pool's concurrency. The operational trade-off is concrete: a long lease slows recovery from a dead worker; a short lease can make a still-running delivery visible twice.

Keep the delivery side idempotent as well. The queue's at-least-once behavior means a job can return after a lease expires or a completion acknowledgement is lost. Pass the occurrence key through to the delivery boundary, record its terminal state, and make a second attempt a no-op when the first one already completed.

Do not use a hosted CI scheduler as the recurrence engine for this workload. Scheduled workflows are useful for maintenance triggers, but their schedules are expressed in UTC and their execution timing is not the local-time contract a reminder system needs. A tiny periodic scanner that claims rules with a database lease gives the team control over missed scans, backfill, and deployment overlap.

Answer the Objections Before They Become Incidents

Can everything be calculated in UTC once? Only when the product promise is an absolute instant. For a reminder chosen as every Tuesday at 08:30 local time, the local rule is authoritative. Derive UTC occurrences near enough to execution that updated zone rules and a changed user preference are reflected, while retaining the immutable occurrence key for audit.

Will downtime flood the pool? It can, so recovery needs a decision rule. Claim jobs in small batches, cap concurrent delivery, and measure backlog age. Send time-sensitive reminders first when the domain permits it; defer stale, non-critical reminders according to an explicit product policy rather than silently pretending they were on time.

Test the boundary dates, not only an ordinary Wednesday: one spring gap, one fall overlap, a weekly rule on each side of the change, a worker crash after send but before completion, and two schedulers discovering the same rule. The last two tests often find the expensive mistake.

Local time stays meaningful to the recipient, the scheduler can recover without guessing, and the worker pool has a measured way to catch up.

Sources

References:

Top comments (0)