Why timezone bugs keep sneaking into your app (and how I finally stopped them)
A pragmatic, India‑aware guide to understanding and preventing timezone bugs — tactics I used to stop off‑by‑one‑day errors, broken cron jobs, and confusing timestamps.
Written by: Rohan Deshpande
I used to think timezone issues were “edge cases” — until a client in Mumbai received a payment reminder a day late and blamed our app. The data in our DB looked right, but the reminder cron had been scheduled in UTC and converted poorly to IST at midnight. That one outage cost us hours of debugging, a tight apology email, and an uncomfortable team retro.
If you ship software that touches dates — calendars, billing, reports, scheduled jobs, or even simple activity timestamps — you will meet timezone bugs. They’re not exotic; they’re a predictable cost. Here’s a practical approach I adopted that reduced these bugs from weekly to rare, with the real tradeoffs you should expect.
What’s actually going wrong
- Display mismatch: timestamps stored in UTC are shown to users without converting to their timezone, so “Today” becomes “Yesterday” around midnight.
- Scheduling mismatch: cron and scheduler systems running in UTC interpret a “9 AM IST” run at the wrong local hour (or skip/duplicate runs around DST transitions).
- Boundary errors: A query like SELECT * WHERE DATE(created_at) = ‘2025-12-22’ can miss rows if created_at is in UTC and the user’s day spans a different UTC range.
- Library/format confusion: mixing ISO strings, epoch ms, and naive Date objects makes behavior inconsistent across browsers and servers.
Main principle: store an unambiguous truth, present a local truth
- Store timestamps in UTC (or as epoch seconds). This is the canonical source and avoids ambiguity when multiple systems interact.
- Store the user’s timezone (IANA tz name like “Asia/Kolkata”) as a separate, explicit field if your app shows local times or schedules by local hour. Main keyword reminder: centralizing time representation like this prevents many timezone bugs by separating canonical storage from local presentation.
Concrete fixes that helped us
-
Use IANA timezones end-to-end Don’t rely on numeric offsets like +5:30. Save and use zone names (Asia/Kolkata, America/Los_Angeles). This matters especially around DST and when clients or employees are outside India.
-
Make your scheduler timezone-aware When you let users schedule tasks for local times, convert that local time to UTC for storage and scheduling. In Kubernetes, run cronjobs with UTC and use a conversion layer (server-side) that reads the user’s timezone and computes the next UTC run.
-
Fix tests: freeze time and test across zones Add tests that run in at least two timezones (UTC and Asia/Kolkata) and include edge cases around midnight and DST transitions in other regions. Freeze time in unit tests so date-dependent logic is deterministic. We used libraries that let us set timezone in test runs, and it caught two logic bugs immediately.
-
Rely on robust libraries, not string parsing magic Moment.js is deprecated; pick modern tools: date-fns + date-fns-tz in Node, Luxon where you need richer APIs, zoneinfo in Python 3.9+, and java.time in Java. These handle conversions and IANA updates better than hand-rolled parsing.
-
Avoid comparing local DATE() values in the DB Queries like WHERE DATE(created_at) = ‘2025-12-22’ assume created_at is in local time. Instead convert the local day range to UTC bounds and query with created_at BETWEEN start_utc AND end_utc. If you need fast lookups by local date, add a computed column for the user’s local date (and index it). Tradeoff: extra storage and maintenance, but far fewer surprises.
India-specific notes
- India doesn’t have DST, but you still need to handle users in the US/EU. A meeting scheduled by a Mumbai user to recur “every first Monday” can behave strangely for colleagues abroad during DST shifts.
- Many Indian teams use servers in the cloud with default UTC; embrace UTC on servers and do conversions at the edge.
- Billing cycles, payroll runs, and tax filings are often local-date dependent — test these in your local timezone during launch windows.
Realistic tradeoffs and constraints
- Performance: converting timezones per row in large queries is expensive. Precompute and index where you need speed (e.g., daily report dashboards).
- Complexity: storing timezone names and doing conversions adds surface area for bugs if front-end and back-end disagree on formats. You must standardize one representation for APIs (we chose ISO 8601 with Z for UTC and included a separate tz field).
- Maintenance: the IANA timezone database updates occasionally. Your runtime or OS needs to be updated to pick up zone changes for edge territories; plan for patching cycles.
What I stopped doing (and you should, too)
- Never rely on the client’s browser to normalize server timestamps without an explicit contract. Send epoch+tz and let the client format it intentionally.
- Avoid “local midnight” logic in crons. If something must run at local midnight, compute its UTC equivalent at schedule time rather than guessing or setting server-local crons.
A quick checklist to adopt today
- Store all timestamps in UTC.
- Persist user timezone as IANA name.
- Convert local scheduling requests to UTC before storing.
- Add timezone-aware tests (UTC + Asia/Kolkata).
- Use modern timezone-aware libraries and keep tzdb patched.
If your team treats time like another field to be cast around, you’ll keep chasing timezone bugs. Treat time as a cross-cutting concern: make it explicit, test it across zones, and accept the tradeoffs (storage, conversions, ops). After we adopted these rules, the number of incidents due to time-related confusion dropped dramatically — and the trust emails from product owners became notably less urgent.
Wrap-up Timezone bugs are a predictable pain, not a mystical one. A little upfront discipline — UTC storage, explicit tz fields, timezone-aware scheduling, and targeted tests — buys a lot of reliability. You’ll still hit edge cases (IANA updates, daylight-saving weirdness in other countries), but those are manageable operational problems, not silent correctness failures.
If you’re shipping a feature that touches dates this quarter, add one timezone test, store one timezone field, and schedule one cron in UTC. Small moves, big reduction in middle-of-the-night debugging.