Time Zones Will Break Your App: A Field Guide for Developers
Practical rules for storing, converting, and displaying timestamps so your app survives time zones, DST, and future-dated events.
Every team hits it eventually: a report that's off by one day, a reminder that fires at 3 a.m., a "daily" job that runs twice. Time zone bugs are rarely dramatic — they're quiet, intermittent, and discovered by annoyed users. The good news: a handful of rules prevents almost all of them.
Rule 1: Store moments in UTC
A moment is something that happened or will happen at a specific instant — an order placed, a payment captured, a message sent. Store these as UTC timestamps (TIMESTAMPTZ in Postgres, epoch milliseconds, or ISO 8601 with offset). Convert to local time only at the edge, when rendering for a human.
Why at the edge? Because the same moment is "Tuesday evening" in Jakarta and "Tuesday morning" in New York. If you bake a local time into storage, you've frozen one interpretation and lost the ability to serve the others.
- Server clocks run on UTC. No exceptions, including cron.
- APIs exchange ISO 8601 with explicit offsets (
2026-10-26T09:30:00+07:00), never bare strings like"10/26 9:30". - The database column type matters. A naive
TIMESTAMPwithout zone info is a bug waiting for its first cross-region user.
Rule 2: Civil time is not a moment
Here's where "always use UTC" breaks down. A doctor's appointment at "9:00 a.m. on March 14" or a store that opens "daily at 8:00" is civil time — a wall-clock agreement, not an instant. If you convert it to UTC at write time and the region's offset rules change (governments adjust DST and zones more often than you'd think), your stored instant is now wrong.
For future events tied to a wall clock, store the local date-time plus the IANA zone name (
Asia/Jakarta, not+07:00), and resolve to an instant as late as possible.
Offsets describe a moment; zone names describe the rules for a place. +07:00 can't tell you what 8 a.m. means next year. Asia/Jakarta can.
Rule 3: "A day" depends on who's asking
Daily revenue reports are a classic trap. "Sales for October 26" means midnight-to-midnight in the merchant's zone — but if your query groups by the UTC date, every transaction after 5 p.m. Jakarta time lands on tomorrow's report. The numbers look plausible and reconcile to nothing.
- Group by date after converting to the reporting zone, in the query itself.
- Decide explicitly which zone defines the business day, and write it down.
- Watch for the same bug in retention cutoffs, "today's orders" filters, and birthday logic.
Rule 4: Test the ugly cases
Time zone code looks correct for eleven months a year. Make your tests cover the other cases on purpose:
- A DST transition (an hour that occurs twice, and one that doesn't exist).
- A user whose local date is already tomorrow relative to the server.
- Month boundaries crossed by conversion — Jakarta's November 1st starts while UTC is still on Halloween.
- Parsing input with no offset at all. Decide what it means; don't guess silently.
The takeaway
Two kinds of time, two strategies: instants in UTC, civil times with an IANA zone name. Convert at the edges, define the business day explicitly, and test the boundaries. None of this requires a library beyond what your language ships — just the discipline to decide, once, what every timestamp in your system actually means.
Build with Abati Technology
We build software that ships — WhatsApp API, developer tools, POS, and mobile apps. Let's talk about your project.
Get in Touch →