Calculate Days Between Two Dates Ignoring DST in JavaScript
To count whole calendar days between two dates without daylight-saving distortion, normalize both to UTC midnight before subtracting, or use Temporal.PlainDate.since(). Part of Timezone Offset Math Explained, this page gives the shortest correct solution, a production helper, and a verification block for the DST edge cases.
Why This Scenario Is Tricky
The intuitive formula (endDate - startDate) / 86400000 divides raw UTC milliseconds by a fixed 24-hour constant. That constant is wrong on any day that contains a DST transition. During spring-forward a local day is only 23 hours, so the division yields 0.958 instead of 1. During fall-back a local day is 25 hours, so it yields 1.041. Round those and you can land on the wrong integer right at the boundary.
The deeper issue is a category error: calendar-day counting is a question about date components, not about elapsed absolute time. The number of days between March 9 and March 11 is 2 regardless of how many hours the clock actually advanced. To get a stable answer you must strip the time-of-day and offset entirely — either by forcing both endpoints onto a UTC midnight grid, or by working with a type that has no concept of time of day at all. This is the same offset volatility described in the parent guide on timezone offset math, viewed from the calendar side.
Minimal Working Solution
Date.UTC() builds a timestamp from discrete year/month/day numbers, ignoring local offsets and DST completely. Subtracting two such timestamps always sits on a clean 24-hour grid.
/**
* Calendar days between two dates using UTC midnight normalization.
* Reads LOCAL components; pass getUTC* values if input must be zone-agnostic.
*/
function getDaysBetween(start: Date, end: Date): number {
// Date.UTC discards time-of-day and offset, leaving a pure date grid
const s = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
const e = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());
// Math.round absorbs floating drift; the grid is already exact 24h steps
return Math.round((e - s) / 86_400_000);
}
Full Production Version
Inputs in real systems arrive as Date objects or ISO strings, ranges can be reversed, and bad input must fail loudly. Temporal.PlainDate is purpose-built here: it is calendar-only and has no timezone, so DST cannot touch it.
import { Temporal } from '@js-temporal/polyfill';
type DateInput = Date | string;
/** Coerce a Date or ISO string to a calendar-only PlainDate. */
function toPlainDate(input: DateInput): Temporal.PlainDate {
if (input instanceof Date) {
if (Number.isNaN(input.getTime())) {
throw new TypeError('Invalid Date object');
}
// Use UTC components so the calendar date is independent of host zone
return new Temporal.PlainDate(
input.getUTCFullYear(),
input.getUTCMonth() + 1, // Temporal months are 1-based, Date months 0-based
input.getUTCDate(),
);
}
// PlainDate.from rejects malformed strings instead of producing NaN
return Temporal.PlainDate.from(input);
}
/**
* Calendar days between two dates, DST-agnostic.
* Negative when start is after end; throws on invalid input.
*/
export function calculateCalendarDays(startInput: DateInput, endInput: DateInput): number {
const start = toPlainDate(startInput);
const end = toPlainDate(endInput);
// largestUnit:'days' forces the Duration to express the gap in whole days
return end.since(start, { largestUnit: 'days' }).days;
}
Verification
These assertions pin the behaviour across both DST transitions, a leap February, a year boundary, and a reversed range. Run them under multiple host zones with TZ=America/New_York npx jest, TZ=Europe/London npx jest, TZ=Asia/Tokyo npx jest.
// Spring-forward week: still exactly 2 calendar days despite the 23-hour day
console.assert(calculateCalendarDays('2024-03-09', '2024-03-11') === 2, 'spring-forward');
// Fall-back week: still exactly 2 despite the 25-hour day
console.assert(calculateCalendarDays('2024-11-02', '2024-11-04') === 2, 'fall-back');
// Leap year: Feb 28 -> Mar 1 spans Feb 29
console.assert(calculateCalendarDays('2024-02-28', '2024-03-01') === 2, 'leap day counted');
// Year boundary
console.assert(calculateCalendarDays('2023-12-31', '2024-01-01') === 1, 'cross-year');
// Reversed range returns a signed negative
console.assert(calculateCalendarDays('2024-03-11', '2024-03-09') === -2, 'reversed');
Common Pitfalls
- Raw timestamp division. Dividing the millisecond difference by
86_400_000without normalizing yields fractional days on transition weeks.
(b.getTime() - a.getTime()) / 86_400_000; // wrong: 0.958 on spring-forward day
calculateCalendarDays(a, b); // right: 1
Math.floor()on a signed result. Floor truncates toward negative infinity, breaking reversed ranges.
Math.floor(-1.0000001); // wrong: -2
Math.round(-1.0000001); // right: -1
- Reading local components for zone-agnostic input.
getDate()returns the host-local day, which differs from the user's day on a server in another zone.
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); // host-local day
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); // zone-agnostic
- Bridging Temporal back to Date carelessly. Passing a
PlainDatetonew Date()producesInvalid Date. Convert through an instant:plain.toZonedDateTime(tz).toInstant().epochMilliseconds.
Frequently Asked Questions
Why does subtracting two JavaScript dates give fractional days during DST?
Date stores UTC milliseconds, but a local DST day is 23 or 25 hours. Dividing the millisecond difference by 86,400,000 produces a non-integer on those days. Normalizing both endpoints to UTC midnight restores a strict 24-hour grid and a clean integer.
Should I use Temporal or legacy Date for production day counting?
Prefer Temporal.PlainDate.since() with the polyfill: it is explicit, leap-year correct, and needs no normalization tricks. The Date.UTC() approach is a correct, zero-dependency fallback when you cannot add the polyfill.
Does this method account for leap years?
Yes. Both Date.UTC() and Temporal.PlainDate use the Gregorian calendar, so February 29 is counted automatically. 2024-02-28 to 2024-03-01 returns 2.
Related
- Timezone Offset Math Explained — the parent guide on offsets and DST.
- JavaScript Date Fundamentals & Core Concepts — the foundational guide.
- Understanding UTC vs Local Time in JS — why local components drift across servers.