Run local-time jobs on a UTC-only cron
I wanted a small daily email for a side project to land at 7:20 in the morning, Central time, Monday through Saturday. That sounds like one line of cron. It isn’t, because the scheduler I was using (GitHub Actions) only understands UTC, and Central time is UTC-5 for part of the year and UTC-6 for the rest.
Pick 20 12 * * 1-6 and the email arrives at 7:20 all summer, then 6:20 all winter. Pick
20 13 * * 1-6 and you get the reverse. Either way, twice a year someone gets their
morning email an hour off, and you probably find out from them.
The pattern I’ve settled on has three parts: fire at every UTC time that could be the right local time, decide in code, and make the job idempotent so a second run is a no-op.
Fire at both candidate times
Two cron entries in one schedule cover both offsets:
on:
schedule:
# 12:20 UTC = 7:20 CDT (summer), 13:20 UTC = 7:20 CST (winter)
- cron: '20 12,13 * * 1-6'
The scheduler is now dumb on purpose. It wakes the job up near the right moment and has no opinion about whether this is the right run. That decision moves into code, where you have real time zone data instead of a fixed offset.
Two small habits while you’re in the cron line:
- Stay off the top of the hour. Hosted schedulers get busy at
:00, and GitHub documents that scheduled runs can be delayed or dropped under load.:20is a cheap way to dodge the crowd. - Read the day-of-week docs for your exact scheduler. Standard cron uses 0–6 with
Sunday as 0 (7 is often accepted too). At least one serverless platform I use numbers days
1–7 with Sunday as 1, and it simply rejected my
0. My weekly trigger there was never live until I switched to the nameSUN, which every dialect I’ve tried agrees on. Day names are the portable choice.
Decide in code, in the user’s zone
The job’s first step answers one question: given the real current instant, what time is it where the people reading this live, and should I run?
const TZ = 'America/Chicago';
// Wall-clock parts for an instant, in the zone the humans live in.
function localParts(date, timeZone = TZ) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
})
.formatToParts(date)
.map((p) => [p.type, p.value]),
);
return {
ymd: `${parts.year}-${parts.month}-${parts.day}`,
weekday: parts.weekday, // 'Mon', 'Tue', ...
hour: Number(parts.hour),
};
}
function shouldRun(now, { force = false } = {}) {
const { ymd, weekday, hour } = localParts(now);
if (weekday === 'Sun') return { run: false, reason: 'Sunday gets the weekly email instead' };
if (!force && (hour < 7 || hour >= 11)) {
return { run: false, reason: `${hour}:xx local is outside the 7-11 window` };
}
return { run: true, key: `daily-digest:${ymd}` };
}
Intl.DateTimeFormat with a named zone does the daylight saving math for you, using the
time zone database that ships with the runtime. No library, no hand-maintained offset
table. hourCycle: 'h23' matters more than it looks: without it, some locales format
midnight as 24, and your comparison quietly breaks once a day.
Notice the date in the key comes from the local parts too. At 05:30 UTC on a Tuesday it is still Monday night in Chicago. If you build the dedupe key from the UTC date, a late run can think it’s tomorrow’s email.
I also log the reason on every skip. When a run does nothing, the log says exactly why
(“Sunday”, “6:xx local is outside the window”), which beats wondering whether the job is
broken.
Make the second run harmless
Here’s the part that’s easy to miss. With a 7-to-11 window, in winter the 12:20 UTC run lands at 6:20 local and skips, and the 13:20 run proceeds. In summer, both runs land inside the window (7:20 and 8:20). The gate isn’t what stops a double send. Idempotency is.
Before the job creates anything, it checks whether today’s email already exists, using a deterministic key:
const decision = shouldRun(new Date());
if (!decision.run) return log(`Skipped: ${decision.reason}`);
const existing = await listCampaigns();
if (existing.some((c) => c.description === decision.key)) {
return log(`Already created ${decision.key}; nothing to do`);
}
await createCampaign({ description: decision.key /* ...content... */ });
I store the key in a field the email provider already gives me (a free-text description on each campaign), so there’s no extra database just to remember what was sent. If your provider supports an idempotency key or external id on create, that’s even better, because the check and the write become one step.
Once the job is idempotent, a lot of other things get safe for free. A second scheduler that triggers the same workflow as a backup can’t double-send. A manual re-run after a flaky API call can’t double-send. A run that GitHub delays by forty minutes still sends, because the window is wide enough to absorb it.
Anchor the content window, not just the send time
One more thing bit me in testing: “stories from the last 24 hours” means something
different at 7:20 than at 8:20. If the window is now - 24h, a retry or a late run picks a
slightly different set of items than the first attempt would have.
So the window is fixed to the calendar: from 7:00 local yesterday to 7:00 local today, however late the job actually starts. Every run on a given day computes the same window, selects the same items, and produces the same email. That’s what makes “already sent today” a meaningful check rather than an approximation.
Tradeoffs I accepted
- A wide window over a precise one. Gating on
hour === 7would make the runs mutually exclusive, but a delayed run at 8:05 would then silently skip the day. I’d rather send a little late than not at all, and let idempotency handle the overlap. - Twice the scheduled runs. Half of them do nothing and exit in a second or two. On a hosted runner that’s noise, not cost.
- Twenty-four hours isn’t always one day. On the days the clocks change, “7:00 yesterday to 7:00 today” is 23 or 25 hours. I anchor to local wall-clock times, so the window follows the calendar, which is what readers expect.
- A
forceflag for manual runs. It skips the hour check so I can test at 3 p.m., but never the Sunday rule. Manual runs also default to a dry run that writes previews and touches nothing.
The checklist
- Assume your scheduler is UTC-only and list every UTC time that could be your local target.
- Gate in code with a named time zone, not a fixed offset.
- Derive dates and dedupe keys from local time, not the UTC date.
- Make the job idempotent with a deterministic key per day (or per whatever period).
- Fix content windows to the calendar so retries produce identical output.
- Use day names in cron and log why every skipped run skipped.
None of this is specific to email. Reports, reminders, billing runs, anything a human expects “at 8 every morning” has the same shape. The scheduler gets you close; the code decides; the key keeps it from happening twice.