Why daylight saving time silently corrupts your date math (and the one-line fix)
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:
- The “spring forward” day in March (US) or late March (EU) — only 23 hours long. Local time jumps from 02:00 to 03:00.
- The “fall back” day in November (US) or late October (EU) — 25 hours long. Local time falls from 02:00 to 01:00.
If your two dates straddle one of these days, your day count is off by
- 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:
- If both dates are local-midnight on different sides of DST, their UTC representations differ by 23 or 25 hours per crossed boundary.
- Dividing by 24h × ms gives you a fractional day count.
Math.roundcovers it for one boundary, but two boundaries (March- November in the same range) compound the error.
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
- DST makes two days per year non-24-hour.
(end - start) / 86_400_000lies on those days.- Fix: do day math in UTC. Always.
- The date calculator on this site is the UTC-only version. Test it on the DST boundary dates above; the count is exact.
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.
Related across the network
- date.tooljo.com — UTC-anchored days-between calculator (the one that doesn’t have this bug).
- epoch.tooljo.com/blog/timestamp-formats-that-bite — the Unix-timestamp companion to this DST piece. Different format, same flavour of “which side of the boundary are we on.”
- date.tooljo.com/date-format-reference — every common format with its DST and timezone semantics.