Short answer: a recurring user reminders API for property management should use cron to find due lease renewals, then hand an idempotent occurrence to a worker through a public webhook or queue; don't make the scheduler responsible for proving that a message was delivered. That boundary gives the team a place to measure scheduling lag separately from delivery lag, which is the difference between a missed calendar rule and a slow notification provider.
I design the example around a renewal reminder due at 09:00 in the tenant's IANA time zone. A weekly inspection reminder and a monthly renewal reminder look similar in a UI, but their failure costs differ: a duplicate inspection nudge is annoying, while a reminder that arrives after a business deadline may trigger a dispute. The system therefore stores the intended local occurrence, its time zone, and a durable delivery record before it asks a worker to send anything.
That is the invariant.
The trigger is permission to look for work, not evidence that work finished.
How should recurring reminders handle weekly and monthly time-zone schedules?
Store a calendar rule and an IANA time-zone identifier, not a fixed UTC offset. The offset changes across daylight-saving transitions; the user's requested 09:00 local time is the business meaning that must survive that change. A monthly rule also needs an explicit policy for dates that do not exist in every month. “The 31st” can mean skip, clamp to the last day, or use a different contractual date. Pick one and persist it as part of the schedule rather than letting a library choose silently.
For each sweep, calculate a bounded interval in the user's time zone and produce an occurrence key such as renewal-1842:2026-08-31T09:00:00+08:00. That key is more useful than the invocation timestamp: retries and two overlapping sweeps can identify the same intended reminder. Keep the schedule's original rule beside the materialized occurrence so an operator can answer what the application believed it was doing.
Cron is a reasonable way to wake the sweep, but ordinary cron is a coarse trigger. It can run at second-level jitter, and a paused schedule does not magically reconstruct every missed invocation. On resume, the application should scan the bounded due window and apply a documented catch-up policy. A policy might send one current reminder, send each missed occurrence, or mark old occurrences expired; the right choice belongs to the lease workflow, not to the cron expression.
The small details matter. A weekly rule should specify its week start and local weekday. A monthly rule should specify its day-of-month behavior. A daylight-saving gap needs a policy for a local time that does not occur, and a repeated clock hour needs a policy for a local time that occurs twice. These are product decisions with operational consequences, so they belong in validation and tests rather than in an on-call runbook written after the first incident. I would make the API reject an ambiguous rule before it reaches production, because a schedule that looks valid in a form can still be impossible to explain when a tenant asks why a renewal reminder arrived on the wrong local date; the audit record should preserve the submitted rule, the resolved occurrence, and the time-zone database version used to calculate it.
The public webhook is an admission boundary
A public endpoint should authenticate the caller, validate a small payload, persist or enqueue the occurrence, and return. It should not render an email, call an SMS provider, or wait for a downstream retry loop while holding the request open. A short handler makes a transient network retry safe to reason about, provided the occurrence ledger is durable and unique on the occurrence key.
Here is a minimal Go boundary. It uses an in-process map only to keep the example readable; production code should put the uniqueness constraint and queue handoff in durable infrastructure. The important behavior is the order: validate, reserve the occurrence, enqueue, and release the reservation if enqueueing cannot happen.
package main
import (
"crypto/subtle"
"encoding/json"
"net/http"
"os"
"sync"
)
type occurrence struct {
ReminderID string `json:"reminder_id"`
DueAt string `json:"due_at"`
}
var (
work = make(chan occurrence, 128)
seen = map[string]struct{}{}
mu sync.Mutex
)
func main() {
secret := os.Getenv("WEBHOOK_SECRET")
if secret == "" {
panic("WEBHOOK_SECRET is required")
}
http.HandleFunc("/reminder-sweep", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
expected := "Bearer " + secret
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(expected)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var item occurrence
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&item); err != nil || item.ReminderID == "" || item.DueAt == "" {
http.Error(w, "invalid occurrence", http.StatusBadRequest)
return
}
key := item.ReminderID + ":" + item.DueAt
mu.Lock()
if _, exists := seen[key]; exists {
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
return
}
seen[key] = struct{}{}
mu.Unlock()
select {
case work <- item:
w.WriteHeader(http.StatusAccepted)
default:
mu.Lock()
delete(seen, key)
mu.Unlock()
http.Error(w, "queue unavailable", http.StatusTooManyRequests)
}
})
_ = http.ListenAndServe(":8080", nil)
}
In a real service, the reservation and enqueue operation needs a failure model that the team can observe. If the process records seen and dies before handing off work, the occurrence disappears; if it enqueues first and records later, a retry can duplicate it. A transactional outbox, or a queue operation paired with a database uniqueness constraint, makes that trade-off explicit. HMAC is a standard option when a shared secret must authenticate a request; RFC 2104 describes the construction and its keyed-hash purpose.
What delivery guarantees should the reminder API expose?
Write the contract in operational language. “At least once into the worker queue” is meaningful. “Exactly once reminder” is usually a claim about the application ledger and provider behavior, not a property a cron trigger can provide. A worker should acknowledge only after it has recorded a terminal outcome, and it should treat a repeated occurrence key as a normal no-op. A downstream provider retry must not create a second lease reminder merely because the first response was lost. I'm not sure a team can promise more than that without controlling the final channel, so I would put the 202 admission result and the eventual delivery result in separate fields rather than collapsing them into one green check.
I use separate measures for each boundary: schedule evaluation lag, webhook acceptance rate, enqueue latency, queue age, worker attempt count, and final notification outcome. The target is a delivery SLO for the business deadline, not a green cron dashboard. For example, if a reminder must arrive before 09:00, the service needs enough margin for schedule jitter, queue drain time, provider latency, and an honest retry budget. Your mileage may vary because the deadline and notification channel define different error budgets.
A public webhook also needs request authentication, replay protection, bounded body size, rate limits, and an audit trail. A timestamped signature can reduce replay risk, while a stable occurrence key makes legitimate retries harmless. Never put a tenant's full message or private lease data in a trigger payload when an identifier lets the worker load the current record under normal authorization checks.
Capacity planning belongs before the cron expression
The dangerous estimate is the daily average. If 40,000 leases share a first-of-month rule, the relevant number is the number of occurrences in the busiest evaluation window and the worker capacity available while a notification provider is slow. Size the queue for that burst, then set an alert on queue age rather than on queue depth alone; a shallow queue with a stalled consumer is still a missed deadline.
Keep the sweep's work bounded. Partition by time-zone or tenant range, use an indexed due-occurrence query, and make the sweep resumable. The callback can enqueue identifiers and intended times, while the worker loads the current content. This keeps retries small and avoids treating the queue as a permanent archive. Retain a delivery ledger long enough to explain a customer-facing dispute, even if the message system has a shorter retention policy.
Test the calendar before testing the network. Include February, month ends, daylight-saving gaps and repeated hours, leap days, a paused scheduler, an overlapping sweep, a lost webhook response, a worker restart, and a provider timeout. Then run a load test where many tenants share the same local deadline. The test should assert the occurrence count and idempotency result, not just that an HTTP handler returned 202.
Buy or build: choose the ownership boundary
There is no universal best scheduler. A managed scheduler can remove a control plane from a small platform team's on-call rotation. A self-hosted scheduler can offer more control over persistence, calendar semantics, and deployment, but the team now owns upgrades, monitoring, failover, and recovery drills. The decision should follow the delivery guarantee and staff capacity.
| Choice | Fits when | The catch |
|---|---|---|
| Managed scheduler plus queue | The job is a bounded sweep and the team wants to operate the reminder domain, not a scheduler cluster | Calendar edge cases and the delivery ledger still belong to the application |
| Self-hosted scheduler | The organization needs deployment control and has capacity for scheduler storage and failover | The on-call burden grows, and a scheduler outage becomes your incident |
| Workflow engine | A reminder has approvals, dependencies, compensation, or fan-out and join semantics | Its workers and state model are not suitable for a simple periodic callback |
| Direct send from the trigger | Only for low-consequence, best-effort notifications | A slow provider couples trigger health to delivery and makes retries harder to contain |
The recommendation is intentionally narrow: use the simplest boundary that can meet the business deadline, then spend engineering effort on calendar semantics, idempotency, observability, and recovery. Stick with a richer workflow engine when the reminder is really a multi-step business process. A plain webhook schedule is not an adequate replacement for that state machine. My rule is to treat a clean trigger metric as incomplete evidence: the reminder is successful only after the worker's terminal record satisfies the lease team's delivery SLO.
Top comments (0)