The hard part of recurring user reminders is recovery, not writing a cron expression. Short answer: keep an occurrence ledger, make delivery idempotent, and treat timezone conversion as data; choose a scheduler that exposes those controls instead of hiding them behind a calendar UI.
Picture a support system sending a shipment update to 8,000 subscribers. Before the change, one delayed worker retries the whole batch and customers receive duplicates. After the change, each scheduled occurrence has an immutable key, a bounded retry record, and a replay command that can resume from the last confirmed subscriber. The schedule is still small. The recovery path is the product.
Replayable.
A weekly reminder is not “run every seven days.” It means “run at 09:00 in the subscriber’s civil timezone, on the selected weekday, even when daylight-saving rules change.” A monthly reminder adds an awkward question: what happens to a date such as the 31st in a month without one? Decide that policy before storing the rule: skip, clamp to the last day, or move to a business day. Record the policy beside the schedule so an operator can explain a surprising notification.
Store the IANA timezone name, never a numeric offset. America/Los_Angeles can move from UTC-8 to UTC-7; -08:00 cannot tell you that. Your scheduler should calculate the next occurrence from the timezone database at execution time, then persist the resolved instant and the original local intent. I’m not sure every hosted scheduler makes that distinction visible, so verify it with a DST transition test before committing.
The occurrence ledger is the durable boundary between scheduling and delivery. A minimal record might contain schedule_id, occurrence_id, planned_local, planned_at, status, attempt_count, and last_error_code. A unique constraint on (schedule_id, occurrence_id) turns a worker crash into a harmless retry instead of a second logical run.
How should recurring reminders handle weekly monthly timezones and public webhooks?
Use a two-stage flow. The scheduler creates one occurrence and places a message on a queue. A delivery worker reads that message, expands the subscriber set, and sends one signed webhook per subscriber. The webhook receiver acknowledges quickly, stores the event ID, and processes the shipment update asynchronously. This keeps a slow customer endpoint from blocking the clock.
Here is a compact Node.js shape. It uses a generic queue and HTTP endpoint so the recovery behavior stays portable:
import crypto from "node:crypto";
export function signature(body: string, secret: string): string {
return crypto.createHmac("sha256", secret).update(body).digest("hex");
}
export async function deliver(
endpoint: string,
eventId: string,
payload: unknown,
secret: string,
): Promise<void> {
const body = JSON.stringify({ eventId, payload });
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-event-id": eventId,
"x-signature": signature(body, secret),
},
body,
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) throw new Error(`delivery_http_${response.status}`);
}
The receiver must compare the signature over the exact bytes it received, then atomically insert eventId before doing side effects. HMAC construction and verification are specified in RFC 2104. Keep the endpoint on public HTTPS, validate DNS and redirects, and cap payload size; a “public webhook” is an exposure boundary, not a trust boundary.
Measure recovery, not just schedule latency
A green cron metric can coexist with a silent delivery failure. Track planned-versus-started occurrences, fan-out count, success rate by attempt number, age of the oldest pending occurrence, and replay volume. Emit a structured log with schedule_id, occurrence_id, subscriber_id, attempt, and a stable error class. Alert on an old pending age and on a rising duplicate-suppression count; both reveal recovery pressure earlier than a generic worker-down alarm.
Run a recovery drill in staging: stop a worker after it claims an occurrence, advance a test clock across a DST boundary, return 429 from one subscriber, and replay the occurrence. The expected result is one accepted event per subscriber, with later attempts carrying the same event ID. I log the exact timeline for this drill: claim at 08:59, first attempt at 09:00, a 429 at 09:00:02, retry at 09:00:17, and final acknowledgement at 09:00:18. Then I query the ledger by occurrence ID and verify that the duplicate suppression counter moved once, while the subscriber-side effect happened once. If the replay creates a second shipment message, the bug is in the idempotency boundary, not in the calendar rule. That test is more useful than a hundred successful “hello world” cron runs.
A queue service such as Google Cloud Pub/Sub provides acknowledgement and redelivery semantics, but your ledger still owns business idempotency. Temporal offers durable workflow history, while BullMQ keeps a Redis-centered job model. Those are different operational envelopes: a workflow engine can simplify long compensation chains; a Redis queue may be easier to run for a modest fan-out. Compare recovery controls, retention, regional behavior, and on-call tooling, not feature-count screenshots.
| Option | Useful default | Main trade-off |
|---|---|---|
| Managed queue | Acknowledgement and redelivery | Business deduplication remains your responsibility |
| Workflow engine | Durable history for long chains | More operational concepts for simple fan-out |
| Redis job queue | Familiar worker model | Recovery and retention depend on your Redis setup |
The trade-offs you should write down
| Decision | Useful default | Catch |
|---|---|---|
| Retry timing | Exponential backoff with jitter | Long backoff delays customer-visible repair |
| Monthly day 31 | Explicit clamp or skip policy | Different teams may expect different calendars |
| Delivery timeout | 8 seconds per attempt | Some partners need an asynchronous receipt |
| Replay scope | One occurrence, selected subscribers | Broad replays can create a notification storm |
| Retention | Keep ledger through the support window | Storage and privacy costs grow over time |
The catch is that a single scheduler rarely covers subscriber preference storage, signature rotation, audit retention, and operator replay in one coherent model. It is not suitable when you need multi-step compensation across payments, inventory, and messaging; stick with a workflow engine in that case. A plain queue plus ledger is a better fit for fan-out reminders with clear, bounded side effects.
Start with the smallest design that can answer five questions during an incident: Which local time did we promise? Which occurrence was created? Which subscribers were attempted? Which acknowledgements are durable? Can we replay safely? If a candidate API cannot expose those answers, its monthly and weekly syntax is beside the point.
Keep schedule intent immutable, derive instants from current timezone data, sign every public webhook, and make event IDs the unit of deduplication. Ship the recovery drill with the first reminder, then let production metrics tell you when the design needs a larger workflow system.
Top comments (0)