DEV Community

zhihu wu
zhihu wu

Posted on

A Day Is Not 86400 Seconds: The DST Bug in Your Date Math

Most "date bugs" I've had to chase in production weren't off by a timezone. They were off by exactly one day, and they only appeared twice a year.

The bug

// "tomorrow, same time"
const tomorrow = new Date(Date.now() + 86400 * 1000);
Enter fullscreen mode Exit fullscreen mode

That code is correct 363 days a year. On the other two it is off by an hour, and if you round the result down to a date it is off by a whole day.

The epoch is absolute; calendar units are not

A Unix timestamp is a fixed instant. 1758700800 is the same moment in Lagos, Lisbon and Lima - only the rendered local time differs. Arithmetic in seconds is therefore always exact in absolute time, and that is the trap: "one day later" is not an absolute duration. It is a calendar operation whose length depends on the timezone rules in effect at both moments.

A spring-forward day is 82,800 seconds long; a fall-back day is 90,000. Add exactly 86,400 seconds to local midnight and you land at 01:00 (already past the boundary you wanted) or at 23:00 of the day before.

Same bug, four languages

// adds exactly 86400 s - wrong across a DST boundary
Instant.now().plus(Duration.ofDays(1));
// calendar-aware - what you usually mean
ZonedDateTime.now(zone).plusDays(1);
Enter fullscreen mode Exit fullscreen mode
new Date(Date.now() + 86400 * 1000);              // +86400 s exactly
const d = new Date(); d.setDate(d.getDate() + 1); // one calendar day
Enter fullscreen mode Exit fullscreen mode

Go's t.Add(24 * time.Hour) is a duration; t.AddDate(0, 0, 1) is a calendar day. In SQL, date_add(d, interval 1 day) follows the zone while d + interval 86400 second does not. Python is the sneakiest: dt + timedelta(days=1) is wall-clock arithmetic on naive datetimes, but adding timedelta to an aware datetime in DST-aware zones can still surprise you.

What actually fixes it

  1. Store the absolute instant - epoch seconds or UTC ISO 8601. Timestamps do not have DST.
  2. Make "which calendar day is this?" decisions in the user's timezone (the zone, not an offset: an offset is a snapshot, a zone is a rule set with a history).
  3. For "yesterday's rows", compute the day boundaries as calendar days in that zone and convert those boundaries to instants - instead of subtracting 86400 from now.
  4. Put a DST date in your test suite. Twice a year is exactly often enough to forget.

When one of these bites, the first move is converting the raw epoch values in the logs to local time. I use the timestamp converter on CodeToolbox for that - it shows local and UTC side by side and auto-detects seconds vs milliseconds, which is the other half of this bug family, and it runs entirely in the browser so staging log values never leave the machine.

Top comments (0)