DEV Community

CountMyDays
CountMyDays

Posted on

Counting days between two dates in JavaScript without off-by-one bugs

"How many days until 24 December?" sounds like a one-liner. In JavaScript it's a small trap with three jaws: time zones, daylight saving time and the way Date parses strings. I ran into all three while building a countdown app, so here's what goes wrong and the small set of helpers I ended up trusting.

All snippets below were run in Node 20. The Temporal one uses the @js-temporal/polyfill package.

Pitfall 1: a day is not always 86,400,000 ms

The classic approach:

const a = new Date(2026, 2, 28); // 28 March 2026, local midnight
const b = new Date(2026, 2, 30); // 30 March 2026, local midnight
console.log((b - a) / 86400000);
Enter fullscreen mode Exit fullscreen mode

In a European time zone (I ran it with TZ=Europe/Warsaw) this prints:

1.9583333333333333
Enter fullscreen mode Exit fullscreen mode

Clocks went forward on 29 March, so that local "day" was only 23 hours long. Depending on whether you then use Math.floor, Math.ceil or nothing, you get 1, 2 or a fraction. In autumn you get the opposite: a 25-hour day and 1.04-ish.

The underlying problem: you're measuring elapsed time, but the question was about calendar days. Those are different things.

Pitfall 2: new Date('2026-03-29') is UTC, but new Date('2026-03-29T00:00') is local

console.log(new Date('2026-03-29').toISOString());      // 2026-03-29T00:00:00.000Z
console.log(new Date('2026-03-29T00:00').getHours());   // 0 (local midnight)
Enter fullscreen mode Exit fullscreen mode

A date-only ISO string is parsed as UTC midnight. Add a time and it becomes local. So for a user in New York, new Date('2026-03-29').getDate() returns 28, because UTC midnight is still the previous evening there. If you mix the two forms in one app (say, one from an <input type="date"> value and one from a database timestamp), you'll get off-by-one errors that only show up for some users.

Fix: compare calendar days, not milliseconds

The trick is to throw away the time of day and the time zone before subtracting. Take the year, month and day the user sees, and build a UTC midnight from them. UTC has no DST, so every day in it is exactly 86,400,000 ms.

function toUTCDay(d) {
  return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
}

function daysBetween(start, end) {
  return Math.round((toUTCDay(end) - toUTCDay(start)) / 86400000);
}

// 23:30 on the 28th to 00:05 on the 30th, across the DST change:
console.log(daysBetween(new Date(2026, 2, 28, 23, 30), new Date(2026, 2, 30, 0, 5))); // 2
Enter fullscreen mode Exit fullscreen mode

Math.round is a belt-and-braces guard; with both sides at UTC midnight the division is already an integer.

If your dates arrive as YYYY-MM-DD strings (date inputs, a date column in Postgres), don't hand them to new Date() at all. Parse the parts yourself:

function parseDateOnly(str) {
  const [y, m, d] = str.split('-').map(Number);
  return Date.UTC(y, m - 1, d);
}

const diff = (parseDateOnly('2026-12-24') - parseDateOnly('2026-10-27')) / 86400000;
console.log(diff); // 58
Enter fullscreen mode Exit fullscreen mode

Treat date-only values as labels for a calendar day, not as instants. That one mental rule removes most of these bugs.

"Today" depends on where you are

Countdowns need "today", and "today" is a time-zone question. At 23:30 UTC on 26 October it's already the 27th in Tokyo. Intl.DateTimeFormat can tell you the calendar date in any zone without a library. The en-CA locale happens to format as YYYY-MM-DD, which plugs straight into parseDateOnly:

function todayIn(timeZone, now = new Date()) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(now);
}

const now = new Date(Date.UTC(2026, 9, 26, 23, 30));
console.log(todayIn('Atlantic/Reykjavik', now));  // 2026-10-26
console.log(todayIn('Asia/Tokyo', now));          // 2026-10-27
console.log(todayIn('America/Los_Angeles', now)); // 2026-10-26
Enter fullscreen mode Exit fullscreen mode

For anything shown to a user, compute "today" in their zone (the default when you omit timeZone), not the server's. A server-rendered "3 days left" from a server in UTC can easily be wrong for half your users.

"Days since": decide whether day one is 0 or 1

Counting up has its own off-by-one, and it isn't a bug in the code, it's a product decision. If I start a habit today, is that "0 days" or "day 1"?

function daysSince(startStr, todayStr) {
  return (parseDateOnly(todayStr) - parseDateOnly(startStr)) / 86400000;
}

console.log(daysSince('2026-10-26', '2026-10-26')); // 0
console.log(daysSince('2026-10-26', '2026-10-27')); // 1
Enter fullscreen mode Exit fullscreen mode

"Days since" (elapsed) naturally starts at 0. "Day N of a streak" starts at 1, so it's daysSince(...) + 1. Pick one, name the function after it, and don't let the + 1 drift around the codebase.

For display, Intl.RelativeTimeFormat handles the wording and pluralisation in any language:

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
console.log(rtf.format(-1, 'day')); // yesterday
console.log(rtf.format(58, 'day')); // in 58 days
Enter fullscreen mode Exit fullscreen mode

Temporal: the real fix (when you can use it)

The Temporal API was designed for exactly this. It has a PlainDate type, a calendar date with no time and no zone, so the whole class of bugs above can't happen:

import { Temporal } from '@js-temporal/polyfill';

const start = Temporal.PlainDate.from('2026-10-27');
const end = Temporal.PlainDate.from('2026-12-24');
console.log(start.until(end, { largestUnit: 'day' }).days); // 58

const today = Temporal.Now.plainDateISO('Atlantic/Reykjavik'); // today in that zone
Enter fullscreen mode Exit fullscreen mode

Native support is still arriving across browsers and runtimes, so check support for your targets or ship the polyfill. The toUTCDay approach above is a good bridge until then, and it maps cleanly onto PlainDate later.

Live countdowns are a different problem

Everything above is about whole days. A ticking "2d 4h 13m 09s" timer is about elapsed time, and there the millisecond difference is the right tool, as long as you always derive it from the clock instead of accumulating ticks. setInterval(tick, 1000) drifts and fires slightly late. Recomputing from Date.now() on each frame doesn't.

How CountMyDays handles it

CountMyDays is my side project for countdowns and "days since" trackers, built with React 19, Supabase and Vercel as an installable web app. A few of the choices in it come straight from the lessons above:

  • The timer is pure date math. Every frame it computes the difference from Date.now(), and a requestAnimationFrame loop only calls setState when the visible second changes. Because the value is always derived from the clock, there's no accumulated error. Background tabs pause rAF, so the hook switches to a 500 ms interval on visibilitychange and back again when the tab returns.
  • One function, four cases. When a countdown passes zero it flips to counting the time elapsed since the date, and a "days since" tracker with a future start date counts down until it starts. Keeping that in a single calculation function means the edge cases live in one place.
  • Normalise dates on the way in. Older data had targetDate, newer data target_date. The code accepts both and converts to ISO strings once, instead of letting two formats leak into the date math.

Cheat sheet

  • Days between dates: normalise both to UTC midnight (Date.UTC(y, m, d)), then divide.
  • Never pass YYYY-MM-DD to new Date() and then read local fields. Parse it yourself.
  • Compute "today" in the user's zone (Intl.DateTimeFormat with timeZone).
  • Decide whether "days since" starts at 0 or 1, and encode it in the function name.
  • Live timers: derive from the clock every frame, never count ticks.
  • Use Temporal.PlainDate where you can.

If you want to see these in practice, CountMyDays is free with no ads and no sign-up: countmydays.to. I'd love to hear about any date bugs that have bitten you in the comments.

Top comments (0)