JavaScript's Date Overflow Trap: The 31st That Skips February

2026-05-12

This function is supposed to return the same calendar day across count consecutive months — useful for billing schedules, subscription renewals, and recurring reminders.

// Returns the same day-of-month across N consecutive months.
// For Jan 31, 2025 with count=4, we expect:
//   [Jan 31, Feb 28, Mar 31, Apr 30]
function getMonthlySchedule(start, count) {
  const dates = [];
  const d = new Date(start);
  for (let i = 0; i < count; i++) {
    dates.push(new Date(d));
    d.setMonth(d.getMonth() + 1);
  }
  return dates;
}

const schedule = getMonthlySchedule(new Date(2025, 0, 31), 4);
console.log(schedule.map(x => x.toDateString()));
// Actual output:
// ['Fri Jan 31 2025', 'Mon Mar 03 2025',
//  'Thu Apr 03 2025', 'Sat May 03 2025']

The Bug

February silently vanishes from the schedule. The user gets billed on the 3rd of the month for the rest of the year, and customer support gets an earful.

The culprit is setMonth's overflow semantics. Setting the month to February while the day-of-month is still 31 doesn't clamp to Feb 28 — it constructs the date "February 31, 2025" and lets it roll over: 28 days into February, then 3 more into March. So setMonth(1) on Jan 31, 2025 produces March 3, 2025.

The damage compounds. Once we've shifted to March 3, the next setMonth calls operate on day 3, not day 31. The "January 31" anchor is lost forever after a single overflow. Every subsequent month is now the 3rd.

This is the same family of bug that haunts Java's Calendar, Python's relativedelta in older versions, and SQL's DATEADD in some dialects. The behavior is technically documented but violates the intuitive contract: "go to the same day next month."

The Fix

Don't mutate a running Date. Compute each target from the original anchor, then clamp the day to the target month's actual length:

function getMonthlySchedule(start, count) {
  const dates = [];
  const anchorDay = start.getDate();
  const year = start.getFullYear();
  const month = start.getMonth();
  for (let i = 0; i < count; i++) {
    // Day 0 of month (m+i+1) is the last day of month (m+i)
    const lastDay = new Date(year, month + i + 1, 0).getDate();
    const day = Math.min(anchorDay, lastDay);
    dates.push(new Date(year, month + i, day));
  }
  return dates;
}

Two things changed. First, we recompute each date from the original anchor day, so a clamp in February doesn't poison March. Second, we explicitly clamp using new Date(y, m+1, 0) — a well-known trick that exploits day-zero rollover in the opposite direction to discover the last valid day of a month.

The general lesson: any API that accepts out-of-range values has to pick a behavior — reject, clamp, or roll over. JavaScript's Date rolls over, and the rollover is silent. Whenever you do month or year arithmetic, ask: "what happens on the 29th, 30th, and 31st?" If you don't know, you have a latent bug waiting for a long-tail customer.

Key Takeaway: Date.setMonth doesn't clamp the day to the new month — it overflows, so adding one month to January 31 silently produces March 3 and erases February from your schedule.

All newsletters