scheduling time zones

"Send it at 5 PM" is not a time

“5 PM” is not an instant until you say where, and the answer can change after you store it. The three different things people mean by “send at 9am”.

· 4 min read · Notibase

Somebody types 5 PM into your scheduler. Your frontend does the obvious thing, turns it into an ISO instant, and posts it. Six weeks later the campaign goes out at 4 PM and nobody can work out why.

The bug is not in the conversion. It is that the conversion happened at all.

Intent and instant

"5 PM" is not an instant. It becomes one only once you say where, and the answer can change after you have stored it — because governments move clocks, and they do it with a few months' notice and no regard for your database.

If you resolve 5 PM in the browser at composition time, you have baked in two things you did not mean to say:

  1. Whose clock. The browser's. If the campaign was composed in a hotel in another country, it has quietly moved to that country's clock. It still says 5 PM on the screen you typed it into, so nothing looks wrong.
  2. Which offset. The one in effect today. Schedule something for 5 PM three weeks out across a daylight-saving boundary and it will fire at 4 or 6. A recurring digest drifts by an hour twice a year, forever.

The instant is derivable from the intent. The intent is not derivable from the instant. So store the intent:

POST /v1/messages
{
  "content": { "title": "Doors open" },
  "send_at_local": "2026-08-25T17:00",
  "timezone": "Asia/Kathmandu"
}

A naive wall-clock time — no Z, no offset — plus a zone. Resolve it at send time, and if a government moves the clock in between, the send moves with it, which is what "5 PM" meant when it was typed.

If you genuinely have an instant already — a countdown, a job queue that computed it — send the instant. Nothing above applies to you.

Three different things people mean by "9am"

Worth separating explicitly, because teams argue about this without noticing they are answering different questions.

"9am my time." One instant, everyone gets it simultaneously. Correct for anything tied to a real-world event: a sale opening, a match starting, a deploy. Use an absolute instant or a wall-clock time in your zone.

"9am their time." As many instants as there are zones — the campaign runs for up to 26 hours, from the first zone to the last. Correct for a habit-forming digest. Expensive to reason about: your "sent today" number is meaningless until the whole thing has drained, and a bug found four hours in has already reached a third of your audience.

"9am in the zone we mostly care about." What most people actually want when they say the second one, and much simpler to operate.

Pick deliberately. The second is the only one that needs per-recipient scheduling, and it costs real complexity for a benefit that is sometimes real and sometimes cargo-cult.

Zones are names, not offsets

Store Asia/Kathmandu, never +05:45. An offset is a fact about one instant; a zone is a rule that produces offsets. Europe/London is +00:00 in January and +01:00 in July, and only the name knows that.

This matters most at the boundary. Store an offset and you have frozen one side of a rule that is about to change — which is the same bug as resolving in the browser, one layer down.

The one that has no good answer

A recipient whose zone you do not know.

You cannot guess it from their IP at send time, because you are not in a request when a scheduler fires. You cannot guess it from their locale — en-GB is spoken in a lot of places. And you cannot leave them out, because "we did not know their timezone" is not a reason a person accepts for not getting the thing they signed up for.

The least-bad answer is an explicit fallback zone you choose consciously and write down, applied to everyone unknown, with the count visible before you send. Not because it is right for each of them, but because a known approximation you can see is better than a silent one. Whatever you do, do not let unknown-zone recipients fall to UTC by accident: UTC is nobody's local time, and midnight UTC is 5:45am in Kathmandu.

Quiet hours are a different mechanism

Worth saying because they get conflated. Scheduling decides when a campaign runs. Quiet hours decide what happens to a recipient the campaign reaches at a bad local hour — held until morning, or dropped. They compose: a campaign scheduled for 9am in one zone can still reach somebody at 2am in another, and only the second mechanism catches that.

And quiet hours must not apply to transactional messages. A password reset held until 8am is a support ticket, and possibly a lockout.


We build Notibase. It stores the intent rather than the instant for exactly the reasons above, and re-resolves when a government moves a clock. The full picture, including what per-recipient local time costs to operate, is in the scheduling and time zones documentation.