Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

Why daylight saving time silently corrupts your date math (and the one-line fix)

5 min read #date #dst #javascript #edge cases

If you’ve ever written code like this:

const days = Math.round((endDate - startDate) / (1000 * 60 * 60 * 24));

…you have a bug that fires twice a year.

What goes wrong

Two specific dates per year have non-24-hour days in any timezone that observes daylight saving time:

If your two dates straddle one of these days, your day count is off by

  1. The 24-hour assumption fails.

For example, in America/New_York:

const a = new Date("2026-03-07T00:00:00-05:00");  // EST
const b = new Date("2026-03-09T00:00:00-04:00");  // EDT
(b - a) / (1000 * 60 * 60 * 24)
// 1.9583... — not 2!

Two midnights have passed (March 7 → 8, March 8 → 9), but only 47 hours elapsed because of the DST shift. Math.round masks this most of the time, but on edge cases — like the spring-forward day plus a few seconds of float drift — you get 1 instead of 2. Or 2 instead of 3.

Why the JS Date object is sneaky here

new Date("2026-03-08") is parsed as UTC midnight, but new Date(2026, 2, 8) is parsed as local midnight. The result of endDate - startDate is always milliseconds in UTC, regardless of how you constructed them. So:

The fix: do day math in UTC

function daysBetween(a, b) {
  const aUtc = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
  const bUtc = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.round((bUtc - aUtc) / 86_400_000);
}

By extracting just the year/month/day and reconstructing as UTC midnight, you remove the timezone entirely. Every UTC day is exactly 86,400,000 ms — there is no DST in UTC.

This is what the date calculator on this site does. It parses the input as new Date(Date.UTC(y, m, d)) and never touches local-zone arithmetic.

Other places this bites

1. Recurring schedules

“Run this job at 02:30 every day” in cron / setInterval will fire 0 times on spring-forward day (the local time skipped) and twice on fall-back day (the local time repeated). Same DST root cause.

Fix: schedule in UTC, or use a scheduler that’s DST-aware (most modern ones are; raw setInterval is not).

2. Sleep duration tracking

A fitness app tracking “you slept from 11 PM to 7 AM” gets 8 hours most of the year, 9 hours on fall-back day, 7 hours on spring-forward day. Most apps just report whatever the math says, leading to user complaints about “fitness app thinks I slept 9 hours.”

Fix: label DST nights as approximations, or use UTC for the math and convert for display.

3. “Days until subscription renews”

If you subscribe on November 1 with a “renew in 30 days” contract, the renewal date depends on whether your math uses UTC or local. Off by 1 hour usually doesn’t matter, but if your billing system rounds — say, “if it’s after midnight local on day 30, charge” — you can charge a day early or late.

Fix: same as before. Anchor the contract to UTC dates.

Testing this

Three test cases I keep in any date library:

// 1. Plain interval (sanity)
daysBetween("2026-01-01", "2026-01-08") === 7

// 2. Spans spring-forward day (DST starts in US: 2026-03-08)
daysBetween("2026-03-07", "2026-03-09") === 2

// 3. Spans fall-back day (DST ends in US: 2026-11-01)
daysBetween("2026-10-31", "2026-11-02") === 2

Run those three. If your date library returns 1.96, 2.04, or anything non-integer, you’ve got the bug.

TL;DR

If you found yourself patching this with Math.floor or Math.ceil, that’s a code smell — you’re masking the underlying timezone bug. Strip the local-time conversion at the boundary instead.