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

Counting the number of days between two dates seems like it should be "subtract and divide by 86,400,000," and that formula is exactly where daylight saving breaks it. When you subtract two Date timestamps and divide by the milliseconds in a day, you are counting absolute 24-hour blocks, but a calendar day that contains a spring-forward transition is only 23 hours long, and one containing a fall-back is 25. So across a DST boundary the millisecond difference between two midnights is not a whole multiple of 86,400,000, and the division yields something like 4.958 days, which rounds wrong and gives an off-by-one for any range spanning a transition.

The fix is to stop counting elapsed absolute time and start counting calendar-grid squares. "How many days between March 1 and March 5" is a question about civil dates, not about elapsed hours, so the correct approach normalizes both endpoints to a zone-agnostic date grid and counts the squares between them. The legacy idiom for this is Date.UTC(...), which discards the time-of-day and offset to leave a pure date, and the modern idiom is PlainDate arithmetic, which is civil by nature. Either way, the trick is to count dates, not durations.

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.

DST breaks millisecond day mathDays that cross DST are 23 or 25 hours, so dividing milliseconds by 86.4M is fractionalA DST day is not 24 hours(b - a) / 86 400 000assumes every day = 24hspring-forward day = 23h,fall-back day = 25hMillisecond division yields a fraction across a transition → wrong day count.

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);
}

Count calendar days, not millisecondsTemporal.PlainDate.until with largestUnit day returns whole daysCount calendar days, not millisecondsPlainDate.from(a)PlainDate.from(b).until(b,{largestUnit:'day'}).days→ integer

Full Production Version

With Temporal the calculation is direct and unambiguous: startDate.until(endDate, { largestUnit: 'day' }).days counts whole calendar days between two PlainDate values, and because PlainDate has no time or zone, no daylight-saving hour can perturb the count. There is no normalization step to remember and no division to round, because the type models exactly the civil grid the question is about. The result is identical whether the code runs in UTC, New York, or Kolkata, which is the property that a "days between" function must have.

If your inputs arrive as instants or zoned times, reduce them to PlainDate in the appropriate zone first, then count — and be deliberate about which zone, because "which calendar day" an instant falls on depends on it. For a user-facing "days until your subscription renews," reduce in the user's zone so the count matches what they perceive. The legacy Date.UTC-normalization approach reaches the same answer by projecting both endpoints onto the UTC date grid, which is fine as long as you apply it to both endpoints consistently and understand that it counts UTC calendar days specifically. Mixing a UTC-normalized endpoint with a locally-read one is the classic way to reintroduce the off-by-one.

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;
}

Validated day-difference utilityParse both endpoints as calendar dates, guard order, return integer daysValidated day-difference utilitytwo inputsto PlainDate+ validateuntil().daysMath.absinteger

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');

Day-count assertionsWhole days even across a spring-forward boundaryAssertions that prove the edge casedays('2026-03-07','2026-03-09')2across spring-forwardexactly 2days(a,a)0reversed orderabs = same

Common Pitfalls

The headline pitfall is subtracting timestamps and dividing by 86,400,000, which counts absolute 24-hour blocks and is off by one across any DST transition, because the transition day is 23 or 25 hours. Count calendar days on a civil type instead. The second is normalizing only one endpoint to the UTC grid and reading the other locally, which mixes two different date grids and produces a wrong difference; apply the same normalization to both. The third is forgetting to choose a zone when reducing instants to dates, so the count is computed on the UTC calendar when the user meant their local one, shifting the answer by a day near midnight.

A subtler mistake is using Math.round (or Math.floor) on a fractional day count to paper over the DST fractional result, which happens to work most of the time and fails exactly on transition-spanning ranges — the rounding hides the real bug rather than fixing it. Count whole days directly with until({ largestUnit: 'day' }) so there is no fraction to round. Finally, decide whether the range is inclusive or exclusive of the end date and apply it consistently; a "days between" that is end-exclusive tiles cleanly with adjacent ranges, while an inclusive one needs a deliberate +1 that should be named, not accidental.

(b.getTime() - a.getTime()) / 86_400_000; // wrong: 0.958 on spring-forward day
calculateCalendarDays(a, b);              // right: 1
Math.floor(-1.0000001); // wrong: -2
Math.round(-1.0000001); // right: -1
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());          // host-local day
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); // zone-agnostic

Day-diff pitfallsMillisecond division and time-bearing dates skew the countWrongRightMath.round((b-a)/86400000)off-by-one across DSTPlainDate.until(...).dayscalendar-correct integerdates carry a time componentpartial days leak instrip to date firstcompare dates, not instants

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.

How do I calculate the number of days between two dates ignoring DST?

Count calendar days on a zoneless type: startDate.until(endDate, { largestUnit: 'day' }).days with Temporal.PlainDate values. Because PlainDate has no time or zone, no daylight-saving hour affects the count and the result is the same in every time zone. Avoid subtracting timestamps and dividing by 86,400,000, which counts absolute hours and is off by one across a transition.

Why does subtracting timestamps give the wrong number of days across DST?

Because it counts absolute 24-hour blocks, but a calendar day containing a spring-forward transition is only 23 hours and one with a fall-back is 25. So the millisecond difference between two midnights across a transition is not a whole multiple of a day, and dividing by 86,400,000 yields a fractional result like 4.958 that rounds wrong. Counting calendar days on a civil type avoids the fractional error entirely.

Which time zone should I use when counting days between instants?

Reduce both instants to PlainDate in the zone that matches what the count means, then count — and use the same zone for both endpoints. For a user-facing 'days until' figure, reduce in the user's zone so the boundary at midnight matches their perception; for a UTC-calendar count, reduce in UTC. Mixing a UTC-normalized endpoint with a locally-read one reintroduces the off-by-one near midnight.