I once spent a weekend firefighting a billing system that double-charged customers in São Paulo. The root cause? A single line of code that compared timestamps without considering timezone offsets. The bug only manifested during Brazil's Daylight Saving Time transition—a three-week window that cost the company thousands in refunds.
Timezone date bugs are insidious because they often pass QA and unit tests. They surface only when users in specific regions hit edge cases like DST shifts, leap seconds, or database timezone mismatches. In this post, I'll walk through the exact debugging process I used, complete with code examples.
The Setup: A Billing Cycle at Midnight
Our system calculated subscription renewal dates by adding 30 days to the original purchase timestamp. The original timestamp was stored in UTC, but the business logic used local midnight in the customer's timezone to trigger the charge. Here's the simplified code:
// Stored as UTC timestamp
const purchaseDate = new Date('2023-10-01T03:00:00Z'); // Oct 1, 2023 03:00 UTC
// Business logic: charge at local midnight of renewal date
const renewalDate = new Date(purchaseDate.getTime() + 30 * 24 * 60 * 60 * 1000);
const localMidnight = new Date(renewalDate.getFullYear(), renewalDate.getMonth(), renewalDate.getDate());
// Schedule job at localMidnightThe bug: `new Date(year, month, day)` interprets the input in local time. If the system's timezone is America/Sao_Paulo, the resulting timestamp changes depending on DST.
Reproducing the Bug
To reproduce, I set my machine's timezone to America/Sao_Paulo and simulated a purchase on October 1, 2023. The renewal date was October 31, 2023—the day Brazil's DST ended (clocks moved back from 03:00 to 02:00).
The local midnight on October 31 in São Paulo is 2023-10-31 00:00 BRT (UTC-3). But because the `Date` constructor used local time, the actual UTC timestamp for that local midnight was 2023-10-31 03:00 UTC. Wait—that's the same as the original purchase time? No, the offset changed due to DST.
Double Charge at DST Fallback
- 00:00Server runs billing job for renewal at local midnight BRT
- 02:00DST ends, clocks fall back to 01:00 BRT
- 02:30Another job runs due to ambiguous time—charges customer again
Lesson
Never use local time for scheduling. Always convert to UTC before comparison.
The Fix: Timezone-Aware Scheduling
The fix involved three changes: store all timestamps in UTC, compute renewal dates in UTC, and use a timezone-aware library to determine the correct local midnight in the customer's timezone. Here's the corrected version using date-fns-tz:
import { addDays, startOfDay, utcToZonedTime, format } from 'date-fns-tz';
const purchaseDate = new Date('2023-10-01T03:00:00Z');
const renewalDate = addDays(purchaseDate, 30); // UTC
// Get local midnight in customer's timezone
const timeZone = 'America/Sao_Paulo';
const localMidnight = utcToZonedTime(startOfDay(utcToZonedTime(renewalDate, timeZone)), timeZone);
// Schedule job at localMidnight (still in UTC internally)Note that `startOfDay` now operates on the local date, not the UTC date. This ensures that if DST shifts the offset, the local midnight is computed correctly.
Common Pitfalls
- arrow_rightUsing `Date.parse()` without specifying timezone—it defaults to local time.
- arrow_rightStoring timestamps as strings in database without offset information.
- arrow_rightAssuming all timezones have DST—some don't (e.g., Arizona, parts of Australia).
- arrow_rightUsing integer timestamps (milliseconds since epoch) but forgetting they are UTC-based—converting to local time for display but then comparing as integers.
Pro tip: Log timestamps in ISO 8601 with timezone offset, e.g., "2023-10-31T00:00:00-03:00". This makes debugging across systems trivial.
Debugging Steps I Follow
- 1Check the timezone of the system running the code (e.g., `timedatectl` on Linux).
- 2Verify the timezone of the database connection (often set in connection string).
- 3Insert debug logs showing the timestamp in both UTC and local time at each transformation step.
- 4Reproduce the bug by setting the system timezone to the affected zone and running a script that iterates over DST transition dates.
# Check system timezone
$ timedatectl
Time zone: America/Sao_Paulo (BRT, -0300)
# Set timezone for debugging
$ sudo timedatectl set-timezone America/Sao_PauloThe most expensive bug I've ever fixed was a single line that used `new Date()` instead of `Date.UTC()`.
Wrapping Up
Timezone bugs are a rite of passage for backend engineers. The good news is they are preventable with discipline: always store UTC, always use timezone-aware libraries, and always test DST boundaries. That double-charge incident led us to add a test suite that runs for every timezone in our system, covering the entire year. We haven't had a recurrence since.
If you have your own timezone horror story, I'd love to hear it. Share it in the comments below.
Frequently asked questions
Why does my JavaScript Date object behave differently across timezones?
JavaScript's Date object is based on the client's local timezone when constructed from strings like '2023-03-12'. Always parse with Date.UTC() or use a library to avoid implicit local time interpretation.
How do I handle DST transitions in scheduled jobs?
Use UTC for scheduling and convert to local time only when displaying. For cron jobs, set the system timezone to UTC or use libraries that handle DST correctly.
What is the best practice for storing timestamps in a database?
Store timestamps in UTC as TIMESTAMP WITH TIME ZONE (PostgreSQL) or DATETIME in UTC (MySQL). Avoid storing local time without offset.
How can I reproduce a timezone bug that only happens at 2 AM?
Mock the system timezone or use time travel libraries like time-machine (Python) or sinon.useFakeTimers (JavaScript). Set the timezone to a DST-affected zone and test the transition.