Reminder cron fired at the wrong hour: timezone, missed runs, no backfill
Diagnosing a reminder schedule that runs at the wrong time or not at all — silent UTC fallback, DST, day-of-week OR day-of-month, and why a skipped minute never returns.
Three things account for almost every reminder that arrives at the wrong hour, and they’re worth checking in this order: the timezone string on the job isn’t the zone you think it is, daylight saving moved the wall clock under a fixed expression, or the run simply never happened and nothing replays it. On Infrai each of those is answerable from two GET calls, because the job’s stored configuration and its run history are both readable.
The second one is the only one people expect. The first is the one that bites, and Infrai — like crontab itself — won’t warn you about it.
Start with what the scheduler stored, not what you meant to send
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/cron/get/cron_ZfdiRoFSuQ6KbKrg2ExEKOWB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"job_id": "cron_ZfdiRoFSuQ6KbKrg2ExEKOWB",
"name": "reminder-sweep-eu",
"cron_expr": "0 9 * * *",
"timezone": "Europe/Paris",
"overlap_policy": "skip",
"enabled": true,
"status": "active",
"next_run_at": null,
"last_run_at": null,
"last_run_status": null
}
}
Two fields do all the diagnostic work here. timezone tells you which clock the expression is matched against, and enabled tells you whether anything is matching at all. Ignore next_run_at on a recurring job — it stays null unless the job was created as a one-shot with run_at, so an empty value proves nothing either way.
The silent fallback that produces a two-hour offset
Send "timezone": "CEST" or "timezone": "EST5EDT-bogus" and the create call returns 200 with your string stored verbatim. Nothing validates it. At match time the scheduler resolves the name through the IANA database, fails, and falls back to UTC — so a job you believed was pinned to Paris fires at 09:00 UTC, which is 11:00 in Paris in July and 10:00 in January.
That’s the shape of the bug: not random, not drifting, just consistently wrong by the zone’s current offset.
You can price the damage in one script before you argue about it:
from datetime import datetime
from zoneinfo import ZoneInfo
zone = ZoneInfo("Europe/Paris")
for month, day in ((1, 15), (7, 15)):
local = datetime(2026, month, day, 9, 0, tzinfo=zone)
print(f"09:00 Paris on {local:%d %b} is {local.astimezone(ZoneInfo('UTC')):%H:%M} UTC")
| Symptom | Likely cause | What to check | Fix |
|---|---|---|---|
| Off by a fixed 1–2 hours, all year | zone name not in the IANA database | timezone in cron.get | send a real Region/City name |
| Correct in winter, an hour off in summer | UTC schedule for a DST zone | the same field | pin the IANA zone, not an offset |
| Right hour, wrong days | day-of-month and day-of-week both set | cron_expr | leave one of them * |
| No run at all for one window | pause, or a missed tick | cron.runs.list | nothing to fix — see below |
Validate the string before it ever reaches the API. Node has the whole zone list built in:
import process from "node:process";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");
const zone = process.argv[2] ?? "Europe/Paris";
if (!Intl.supportedValuesOf("timeZone").includes(zone)) {
throw new Error(`${zone} is not an IANA zone — the API will accept it and then ignore it`);
}
const res = await fetch("https://api.infrai.cc/v1/cron/create", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: `reminder-sweep-${zone.toLowerCase().replace(/[^a-z]+/g, "-")}`,
cron_expr: "0 9 * * *",
timezone: zone,
task_type: "http_url",
task: "https://app.example.com/internal/reminders/sweep",
payload: { window_minutes: 60 },
overlap_policy: "skip",
}),
});
const parsed = await res.json();
if (parsed.ok !== true) throw new Error(`${parsed.error?.code}: ${parsed.error?.message}`);
console.log(`${parsed.data.job_id} pinned to ${parsed.data.timezone}`);
Roughly one line of guard for a class of bug that otherwise shows up as a support ticket from a user in another country.
Day-of-month OR day-of-week
0 9 1 * 1 doesn’t mean “the first of the month, if it’s a Monday”. Standard crontab semantics — which Infrai follows, and which Quartz sidesteps by making you write ? in one of the two fields — say that when both the day-of-month and day-of-week fields are restricted, a run happens if either matches. So that expression fires on the 1st and on every Monday.
Leave one of them as * unless you genuinely want the union.
Sunday is 0, and 7 is accepted as Sunday too. Seconds are optional: a six-field expression like 30 */5 * * * * gives you second-level placement, which is the cheap way to stop ten thousand reminder jobs all landing on :00. Jitter the second, not the minute, and your downstream API sees a smear instead of a spike.
A missed run is gone
The scheduler fires a job at most once per (job, minute). There’s no catch-up queue, no --catchup flag, and no backfill after a pause: if the 09:00 window passes while the job is paused or while a tick is missed, that occurrence does not run later. POST /v1/cron/pause/{id} is a mute button, not a hold.
For reminders this matters more than it does for a cleanup sweep, because a reminder that arrives six hours late is often worse than one that never arrives. Design the handler to work from a due-time column — “send everything due since the last successful run” — and a missed tick becomes a slightly larger batch instead of a hole.
Run history is where you confirm which it was:
curl -sS "https://api.infrai.cc/v1/cron/runs/list/cron_ZfdiRoFSuQ6KbKrg2ExEKOWB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"run_id": "cronrun_f3Jr8IFSZmTKQTTUi6hzW23N",
"status": "skipped",
"fired_at": "2026-07-25T07:00:00.000000Z",
"skipped_reason": "overlap",
"http_status": null,
"duration_ms": null,
"error_code": "CRON_OVERLAP_SKIPPED"
}
],
"next_cursor": null
}
}
A skipped row with overlap means the previous run was still in flight and overlap_policy: "skip" dropped this one — a missed reminder caused by your handler being slow, not by the schedule. Switching that job to queue makes the runs stack up instead of vanishing.
Correcting the zone without recreating the job
curl -sS -X PATCH "https://api.infrai.cc/v1/cron/update/cron_ZfdiRoFSuQ6KbKrg2ExEKOWB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"timezone":"Europe/Paris","cron_expr":"0 9 * * *"}'
The job id survives, so anything you’ve stored against it stays valid.
The limitation worth knowing before you build on this
One job carries one timezone. Per-user local-time reminders across Europe and the US therefore aren’t “one cron job per user with their zone” — that’s a job table you’ll be paging through by 2026 standards of user count. Run a single hourly sweep in UTC and let your own query decide who is due in their own zone; the schedule stays boring and the timezone logic lives in one place, next to the data that knows each user’s zone.
If you want the scheduler itself to reason about per-user local time, retries and human-readable run state, a workflow product like Inngest or a hosted scheduler like QStash covers more of that ground than a cron trigger does. Infrai’s cron is deliberately a trigger: it POSTs to your URL on a schedule, records the outcome, and stops there. The trade-off is that everything above is your endpoint’s problem — in exchange, the sweep, the queue it fans out to and the email it eventually sends all sit behind one key and one bill.