Short answer: store each reminder's IANA time zone and wall-clock rule, evaluate that rule from a durable UTC minute cursor, and send a deterministic occurrence key through the queue, reconciliation ledger, and payment-provider call. That design makes daily and weekly local-time scheduling explicit about DST while keeping retries idempotent. Cron should wake the scheduler; it should not be the source of truth.
For a fintech example, consider a nightly payment-provider reconciliation followed by a user-facing exception reminder. The scheduler decides which local occurrence is due, a queue absorbs delivery pressure, and a worker performs reconciliation. The hard boundary is between a local calendar promise such as Monday at 02:30 and the UTC instant at which code runs. Treating those as the same value creates duplicate work in autumn and missing work in spring.
How should Node.js queue workers deliver daily and weekly reminders in local time?
Keep the recurrence rule as data: timeZone, local hour and minute, frequency, and weekday when applicable. Keep execution state separately. A once-per-minute cron pulse advances a persisted UTC cursor one minute at a time, including missed ticks after a deploy. For every cursor instant, Node.js projects that instant into each schedule's zone and compares the resulting local fields with the rule. A match creates an occurrence; it doesn't perform payment work inline.
This reverses a common mental model. The code isn't trying to convert an imaginary local timestamp into UTC, an operation that needs a policy when the clock skips or repeats. It starts with a real instant and asks what local label it has. A spring-forward label that never exists therefore never matches. A repeated autumn label can match twice, but both matches produce the same key, so only one survives. For reconciliation reminders, I prefer documenting that as a skip-on-gap, once-per-wall-clock-label policy. If the business promises "run at the next valid instant" instead, that is a different rule and needs its own test cases.
The cron expression is deliberately boring: every minute.
The obligation ledger is the control plane
A payment reminder is not merely a message to enqueue. It represents a dated obligation: reconcile account A for local business date B under schedule revision C, then notify if the result calls for it. Put that tuple in a ledger with pending, claimed, and done states. The schedule can change tomorrow without rewriting yesterday's record, and an auditor can ask which obligation existed before looking at queue logs.
This model also fixes the ownership question. The cron pulse owns no business state. The queue owns no business state. They can both repeat. The ledger owns the obligation, while the provider idempotency key protects the external financial effect. That division is useful during an incident because "was Monday created?" and "is Monday waiting for a worker?" are separate, answerable questions.
Code from a UTC cursor, not a calendar guess
The following TypeScript keeps storage and queue details behind small interfaces. The scheduler can replay UTC minutes after downtime, while the worker uses the occurrence key as the idempotency key accepted by the payment-provider boundary. A database unique constraint on that key is the final guard; queue deduplication alone is not a durable business ledger.
import { createHash } from "node:crypto";
type Frequency = "daily" | "weekly";
type Schedule = {
id: string;
accountId: string;
timeZone: string;
hour: number;
minute: number;
frequency: Frequency;
weekday?: "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun";
};
type LocalMinute = {
date: string;
time: string;
weekday: Schedule["weekday"];
};
type ReconciliationJob = {
occurrenceKey: string;
accountId: string;
localOccurrence: string;
};
interface Queue {
enqueue(job: ReconciliationJob, options: { jobId: string }): Promise<void>;
}
interface Ledger {
claim(key: string): Promise<"claimed" | "done" | "busy">;
markDone(key: string): Promise<void>;
release(key: string): Promise<void>;
}
interface PaymentProvider {
reconcile(input: { accountId: string; idempotencyKey: string }): Promise<void>;
}
function localMinuteAt(instant: Date, timeZone: string): LocalMinute {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
weekday: "short"
});
const parts = Object.fromEntries(
formatter.formatToParts(instant).map((part) => [part.type, part.value])
);
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
time: `${parts.hour}:${parts.minute}`,
weekday: parts.weekday as LocalMinute["weekday"]
};
}
function due(schedule: Schedule, instant: Date): LocalMinute | null {
const local = localMinuteAt(instant, schedule.timeZone);
const target = `${String(schedule.hour).padStart(2, "0")}:${String(
schedule.minute
).padStart(2, "0")}`;
if (local.time !== target) return null;
if (schedule.frequency === "weekly" && local.weekday !== schedule.weekday) {
return null;
}
return local;
}
function occurrenceKey(schedule: Schedule, local: LocalMinute): string {
const label = `${schedule.id}|${local.date}T${local.time}`;
return createHash("sha256").update(label).digest("hex");
}
export async function scheduleTick(
instant: Date,
schedules: Schedule[],
queue: Queue
): Promise<void> {
for (const schedule of schedules) {
const local = due(schedule, instant);
if (!local) continue;
const key = occurrenceKey(schedule, local);
await queue.enqueue(
{
occurrenceKey: key,
accountId: schedule.accountId,
localOccurrence: `${local.date}T${local.time}[${schedule.timeZone}]`
},
{ jobId: key }
);
}
}
export async function work(
job: ReconciliationJob,
ledger: Ledger,
provider: PaymentProvider
): Promise<void> {
const claim = await ledger.claim(job.occurrenceKey);
if (claim === "done" || claim === "busy") return;
try {
await provider.reconcile({
accountId: job.accountId,
idempotencyKey: job.occurrenceKey
});
await ledger.markDone(job.occurrenceKey);
} catch (error) {
await ledger.release(job.occurrenceKey);
throw error;
}
}
There is an important contract hidden in work: the provider must honor the supplied idempotency key for the reconciliation operation. If the process exits after the provider accepts the request but before markDone, the queue will retry. Reusing the same key lets the provider recognize the same logical operation. Without that external idempotency contract, no local queue setting can promise exactly-once effects. Don't bury this assumption in an adapter.
The busy state also needs a lease expiry in a real ledger so a terminated worker cannot hold a claim forever. The exact lease should exceed an ordinary reconciliation duration and be renewed by active work. I'm not sure what that duration should be for your provider without its latency distribution; production traces, including the slow tail, should decide it.
Record the calendar policy beside every result
Test the calendar policy with fixed instants, not by changing the machine clock. Include a normal daily run, a weekly weekday mismatch, the spring gap around a configured 02:30, both UTC instants that map to a repeated autumn 01:30, a scheduler restart with several cursor minutes to replay, two workers claiming the same key, and a retry after the provider accepted a keyed request but before the ledger recorded completion. Use zones from both the US and EU in the suite because transition dates and local rules are data, not assumptions that should leak into application code.
| Observed condition | Recorded policy | Ledger evidence |
|---|---|---|
| Spring gap has no 02:30 label | Skip that reminder occurrence | No matching local label was generated |
| Autumn overlap has two 01:30 instants | Run once for the wall-clock label | Both candidates share one occurrence key |
| Cron pulse was missed | Replay each UTC cursor minute | Cursor range is stored with enqueue intent |
| Worker receives a redelivery | Reuse the original operation | Attempt points to the same occurrence key |
DST is a product decision wearing an infrastructure jacket. Skipping a nonexistent 02:30 may be reasonable for a reminder, but a nightly financial reconciliation may have a stronger promise: one completed run per local business date. In that case, create the business-date obligation first and let the local-time rule set its earliest eligible instant. A recovery query can then find any date without a completed result. Store the policy revision on the record so a later settings change cannot make historical behavior ambiguous.
Be precise about retries. A visibility timeout prevents another consumer from receiving a message only for a configured period; the worker may need to extend it while long work continues, and an unacknowledged message becomes available again. This is why the handler must expect redelivery. Use bounded exponential backoff with jitter for transient failures, cap concurrency to respect the payment provider, and move repeatedly failing jobs into an inspectable terminal state rather than retrying them forever. The catch is that a queue adds operational state, delayed retries, and another place to observe. For a single low-volume process whose work is quick and recoverable from a database ledger, an in-process worker can be enough. Keep the durable occurrence table either way.
A hosted cron trigger can supply the minute pulse, but it should not encode every user's zone. Scheduled workflow triggers and queued-message visibility behavior are transport concerns; neither changes the application-level requirement to persist a cursor and idempotency state. This separation also keeps the scheduling core portable.
Ship the tests first.
Deploy with shadow accounting and an audit query
Before deployment, I would verify that schedule creation rejects invalid zone identifiers and impossible field ranges, that the cursor update and enqueue intent can recover from a crash between them, and that the occurrence table has a unique index on the deterministic key. The audit query should return schedule revision, local occurrence, zone, UTC tick, attempt count, final state, and provider idempotency key together. Those fields let an operator distinguish "the obligation never existed" from "the obligation exists and is still retrying" without guessing from timestamps.
Then I would run a shadow period in which due occurrences and reconciliation inputs are recorded but user notifications are suppressed. Compare local business dates, not raw UTC dates. Alert on cursor lag, oldest queued job, expired worker leases, and business dates lacking a completed reconciliation. Cost matters to a solo team, so bound scans by the cursor window and partition active schedules instead of reading every account every minute; measure before adding a more elaborate scheduler.
This design is not suitable when recurrence rules include broad calendar expressions, human-edited holiday calendars, or workflows that wait for months while carrying complex state. Use a dedicated workflow or calendar engine in that case, but retain the same explicit gap/overlap policy and end-to-end idempotency key. For daily and weekly payment reminders, the small recurrence core is easier to test because every decision is visible: UTC cursor in, local label out, one ledger key per promised occurrence.
The decision rule is plain: cron creates opportunities to run, the calendar rule creates obligations, the queue retries transport, and the ledger plus provider key protect the financial effect. Mixing those responsibilities is what makes timezone bugs expensive.
Top comments (0)