If you are building a feature where a user picks a time and a job fires later, you have a timezone bug waiting for you. It does not show up in testing. It shows up twice a year, on the two nights the clocks change.
Here is the problem. A scheduled post carries two different pieces of information. One is the exact instant the job must fire. The other is the human intent behind that instant โ "9am my time." Most schemas store only one of these and quietly lose the other.
๐ง๐ต๐ฒ ๐ป๐ฎ๐ถ๐๐ฒ ๐๐ฒ๐ฟ๐๐ถ๐ผ๐ป
scheduled_posts (
id UUID,
content TEXT,
fire_at TIMESTAMPTZ -- stored as UTC
)
This looks correct. You convert the user's local pick to UTC once, at creation time, and store that. The job scheduler just checks fire_at <= now(). Clean.
It breaks the moment the user's offset changes. Say someone in New York picks 9am and you store 2026-11-01T13:00:00Z. That is correct โ until November 3rd, when the US falls back and 9am Eastern is now 14:00 UTC, not 13:00. Your stored instant is stable. The user's intent is not. The post fires at what is now 8am for them, not 9am. Nothing crashed. Nothing logged an error. The time is just wrong.
๐ง๐ต๐ฒ ๐ณ๐ถ๐
๐ถ๐ ๐๐ผ ๐๐๐ผ๐ฟ๐ฒ ๐ฏ๐ผ๐๐ต
scheduled_posts (
id UUID,
content TEXT,
local_time TIME, -- '09:00'
local_date DATE, -- '2026-11-01'
timezone TEXT -- 'America/New_York', IANA identifier
)
Now you resolve fire_at at read time, not write time, using the stored IANA identifier plus the current tz database. This preserves intent correctly across DST changes. But it introduces its own edge case: during the hour a DST transition repeats or skips, a wall-clock time is ambiguous or invalid. On November 1st in the US, 1:30am happens twice. On March 8th, 2:30am does not happen at all. If your resolution logic just calls the local time API and takes whatever it returns, you will silently pick one of two valid instants, or crash on an invalid one, depending on the library.
๐ง๐ต๐ฒ ๐ฑ๐ฒ๐ฐ๐ถ๐๐ถ๐ผ๐ป
Keep both fields. Treat the timezone identifier as part of the permanent record, set once, at the moment the user makes the choice. Never re-derive it later from the user's current browser timezone, because a traveling user or a stale session will hand you a different value than the one they meant when they scheduled the post. The identifier is data, not a runtime lookup.
๐ช๐ต๐ฎ๐ ๐๐ผ ๐ฎ๐ฐ๐๐๐ฎ๐น๐น๐ ๐๐ฒ๐๐
Do not just test the happy path where a schedule fires on a normal Tuesday. Pick the two DST transition dates for your target region this year and write explicit test cases for the ambiguous hour (fall back) and the skipped hour (spring forward). Assert what your scheduler does with a job set to fire inside that window. Most tz libraries have a documented but non-obvious default (usually "earlier" or "later" occurrence) โ know what yours picks, because your users will hit it whether you tested for it or not.
Cadencz schedules and publishes posts across live platforms, which means every queued post carries this same fire-at-instant-versus-local-intent problem underneath it.
If you're building anything with recurring jobs โ cron-style schedulers, reminder systems, billing cycles โ this is worth fixing before your first DST transition, not after someone reports a post that fired an hour off.
Top comments (0)