Leap Year Calculation Algorithms in JavaScript

How to detect a leap year correctly in JavaScript, why the four-year rule is wrong on its own, and which implementation to trust in production. Part of JavaScript Date Fundamentals.

What actually breaks

Leap year bugs are quiet until February 29 arrives. A scheduler that assumes every fourth year has a leap day will silently skip 2100 (divisible by 4, but not a leap year), shifting recurring billing or payroll dates by a day. A signup flow that lets a user enter 2023-02-29 and then persists it through a naive new Date() will store 2023-03-01 instead โ€” an invalid-date corruption that surfaces months later as a failed reconciliation. And anything that validates Feb 29 against the local clock instead of the calendar can flip the answer depending on the server's timezone. The fix is to treat "is this a leap year" as pure integer arithmetic on the year, and to treat "is this a valid date" as a calendar question handled by a type that refuses to silently overflow.

The Gregorian rule as a decision flow

The Gregorian calendar adds February 29 by a divisibility cascade with two exceptions stacked on top of each other. The order matters: you check รท4 first, then carve out the รท100 century years, then add the รท400 years back in. The diagram below traces three years that exercise every branch โ€” 2024 (ordinary leap), 1900 (century, not a leap year), and 2000 (century divisible by 400, a leap year).

Gregorian leap year decision flow A flowchart: test divisible by 4, then divisible by 100, then divisible by 400, classifying 2024, 1900 and 2000 as leap or common years. year % 4 === 0 ? year % 100 === 0 ? no LEAP YEAR 2024 stops here yes year % 400 === 0 ? no COMMON YEAR 1900 ends here yes LEAP YEAR 2000 ends here 2024 ÷4 yes, ÷100 no → leap 1900 ÷100 yes, ÷400 no → common     2000 ÷400 yes → leap

The single expression most engineers reach for collapses that flow into one line. It is true when the year is divisible by 4 but not by 100, or divisible by 400 โ€” which is exactly the diagram.

/**
 * O(1) Gregorian leap year test. No object allocation, no timezone exposure.
 * @param year - Proleptic Gregorian year, e.g. 2024.
 */
export function isLeapYear(year: number): boolean {
  // รท4 AND NOT รท100 covers ordinary leap years; รท400 adds the century exceptions back.
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

console.log(isLeapYear(2024)); // true  โ€” divisible by 4, not by 100
console.log(isLeapYear(1900)); // false โ€” divisible by 100, not by 400
console.log(isLeapYear(2000)); // true  โ€” divisible by 400
console.log(isLeapYear(2023)); // false

For a step-by-step walkthrough of this function โ€” including TypeScript typing and input guards โ€” see check if a year is a leap year in JavaScript.

API reference

Method / property Signature Returns Notes
year % 4 (modulo) (year: number) => boolean boolean Pure arithmetic; no timezone exposure, no allocation.
new Date(y, 1, 29).getMonth() () => number 1 if Feb 29 valid, else 2 Interprets args in the host timezone; allocates a Date.
Temporal.PlainDate.from(...) (fields, { overflow }) => PlainDate throws RangeError on Feb 29 in a common year Calendar-only; immune to DST and offsets.
Temporal.PlainYearMonth#inLeapYear PlainYearMonth.inLeapYear boolean Calendar-aware; correct for Hebrew, Islamic, etc.

Approach A: legacy Date

A long-standing trick exploits the Date constructor's silent overflow: ask for February 29 and read back the month. If the year has a leap day, the month index stays 1 (February); if not, day 29 spills into March and the index becomes 2.

/**
 * Legacy Date-overflow technique.
 * Limitation: the Date constructor reads year/month/day in the HOST timezone,
 * so a midnight-boundary value can drift across a DST transition in rare cases.
 * It also allocates a full Date object the modulo test never needs.
 */
export function isLeapYearLegacy(year: number): boolean {
  // Month index 1 = February. Overflow pushes an invalid Feb 29 to Mar 1 (index 2).
  return new Date(year, 1, 29).getMonth() === 1;
}

It works, but it is strictly worse than the modulo test: slower, allocates, and routes a pure-arithmetic question through the timezone-sensitive Date constructor. The same local-time coercion is why understanding UTC vs local time in JS matters even for a question that has nothing to do with clocks. Keep this pattern only for environments without Temporal where you also want the modulo test inlined elsewhere โ€” and prefer the modulo function in all cases.

Approach B: Temporal

Temporal.PlainDate represents wall-clock calendar coordinates with no offset and no DST. Constructing February 29 with overflow: 'reject' throws a RangeError in a common year and succeeds in a leap year, so the validity of the date is the leap-year answer.

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

/**
 * Temporal-based leap year test. Calendar-only, so no timezone can affect it.
 */
export function isLeapYearTemporal(year: number): boolean {
  try {
    // overflow: 'reject' refuses to clamp Feb 29 โ†’ Feb 28; it throws RangeError instead.
    Temporal.PlainDate.from({ year, month: 2, day: 29 }, { overflow: 'reject' });
    return true;
  } catch {
    return false; // RangeError โ‡’ Feb 29 does not exist โ‡’ common year.
  }
}

console.log(isLeapYearTemporal(2024)); // true
console.log(isLeapYearTemporal(2023)); // false

The overflow policy is the load-bearing detail. The default 'constrain' would silently clamp Feb 29 to Feb 28 and report true for every year โ€” the same overflow trap that bites date arithmetic, covered in date arithmetic without mutations. For a plain Gregorian check, the modulo function is still preferred; reach for Temporal when you already hold a PlainDate/PlainYearMonth and want its own calendar to answer.

Non-Gregorian calendars

The modulo rule is Gregorian-only. The Hebrew calendar adds an entire leap month (Adar II), and Islamic calendars use intercalary years on a different cycle โ€” applying % 4 to those year numbers is meaningless. Temporal.PlainYearMonth.inLeapYear reads the calendar's own definition. Era-aware and multi-calendar handling is the focus of calendar systems and era handling.

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

/**
 * Leap-year test for any CLDR calendar. 'iso8601' is Gregorian (the default).
 * Other ids: 'hebrew', 'islamic-umalqura', 'japanese', 'chinese', ...
 */
export function isLeapYearInCalendar(year: number, calendarId: string): boolean {
  // inLeapYear reflects the CALENDAR's own rule, not the Gregorian modulo cascade.
  return Temporal.PlainYearMonth.from({ year, month: 1, calendar: calendarId }).inLeapYear;
}

console.log(isLeapYearInCalendar(2024, 'iso8601')); // true
console.log(isLeapYearInCalendar(5784, 'hebrew'));  // true โ€” Hebrew leap year (13 months)

Production implementation

A single utility should validate its input, expose the Gregorian fast path, and offer a calendar-aware variant โ€” so callers never reach for Date overflow or hand-rolled modulo again.

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

/** Reject anything that is not a finite, non-negative integer year. */
function assertYear(year: number): void {
  if (!Number.isInteger(year) || year < 0) {
    // Fractional or negative years would give a "mathematically correct" but meaningless answer.
    throw new RangeError(`Expected a non-negative integer year, received: ${year}`);
  }
}

/** Gregorian (proleptic) leap-year test โ€” the default fast path. */
export function isLeapYear(year: number): boolean {
  assertYear(year);
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

/** Calendar-aware variant for i18n / multi-calendar systems. */
export function isLeapYearForCalendar(year: number, calendarId = 'iso8601'): boolean {
  assertYear(year);
  if (calendarId === 'iso8601') return isLeapYear(year); // skip the allocation for Gregorian.
  return Temporal.PlainYearMonth.from({ year, month: 1, calendar: calendarId }).inLeapYear;
}

/** Days in February for a Gregorian year โ€” common downstream use. */
export function februaryDays(year: number): 28 | 29 {
  return isLeapYear(year) ? 29 : 28;
}

On the server or in serverless functions this code is fully deterministic: it never reads Date.now(), process.env.TZ, or the host locale, so it returns identical results in every region and during SSR. That immunity is the whole point of keeping the leap-year question off the Date object.

Edge cases

Century years (the รท100 trap)

1900, 2100, 2200, and 2300 are divisible by 4 but are common years. A bare year % 4 === 0 check marks all four as leap years and drifts long-horizon schedules by a day. Only 1600, 2000, and 2400 (รท400) are leap centuries.

Year zero and the proleptic Gregorian calendar

ISO 8601 / Temporal use a proleptic Gregorian calendar where year 0 exists and is a leap year (it is divisible by 400). Historical-record systems that use BCE/CE numbering without a year zero must offset before testing โ€” do not feed an astronomical year and a historical year through the same function.

Feb 29 from user input

new Date('2023-02-29') does not throw โ€” it yields 2023-03-01 (overflow) or Invalid Date depending on the engine. Validate the date with Temporal.PlainDate.from(str, { overflow: 'reject' }) and surface the RangeError to the user instead of persisting a silently shifted day.

The leap day itself crossing a timezone

2024-02-29T23:30 in Asia/Tokyo is still 2024-02-29T14:30Z โ€” the same instant, but if you store the wall-clock day from local time you can land on the wrong calendar day. Keep day-level identity in UTC or a PlainDate; never derive "is this the leap day" from a host-local Date.

Gotchas & common pitfalls

Testing checklist

Scenario Input Expected
Ordinary leap year 2024 true
Common year 2023 false
Century, not รท400 1900 false
Century รท400 2000 true
Far-future century trap 2100 false
Year zero (proleptic) 0 true
Invalid fractional year 2024.5 throws RangeError
Hebrew leap year 5784, 'hebrew' true

Because the algorithm is timezone-independent, the test matrix matters most for any date-construction code sitting next to it. Run the suite under several zones to prove the leap-day boundary code does not drift:

# Run the same tests under three zones; results must be identical.
for tz in UTC America/New_York Pacific/Kiritimati; do TZ=$tz npm test; done
import { isLeapYear, isLeapYearForCalendar } from './leap-year';

console.assert(isLeapYear(2000) === true, '2000 is a leap year (รท400)');
console.assert(isLeapYear(1900) === false, '1900 is not a leap year (รท100, not รท400)');
console.assert(isLeapYear(2100) === false, '2100 is not a leap year');
console.assert(isLeapYearForCalendar(5784, 'hebrew') === true, 'Hebrew leap year');

Frequently Asked Questions

Why is the modulo algorithm preferred over Date or Temporal for leap year checks?

It is O(1), allocates nothing, needs no library, and never touches the host clock, so it returns the same result in every timezone and during SSR. The Date-overflow trick allocates and routes a pure-arithmetic question through timezone-sensitive parsing; the Temporal try/catch is best when you already hold a Temporal object.

Does timezone offset affect leap year calculation?

Not for the modulo algorithm or for Temporal.PlainDate, both of which operate on calendar coordinates only. Offsets bite only when you derive the answer from a host-local Date whose midnight boundary can drift across a DST transition.

Why is 1900 not a leap year but 2000 is?

Both are divisible by 4 and by 100. The rule removes century years from the leap set, then adds back the ones divisible by 400. 1900 is not divisible by 400, so it is common; 2000 is, so it is a leap year.

How do I handle leap years in non-Gregorian calendars?

Use Temporal.PlainYearMonth.from({ year, month: 1, calendar: 'id' }).inLeapYear. Each calendar defines its own rule โ€” the Hebrew calendar inserts a whole month, so applying Gregorian % 4 math to a Hebrew year number is meaningless.

Is the modulo algorithm sufficient for production billing systems?

For Gregorian-only systems, yes โ€” pair it with input validation and explicit UTC handling for any date construction around it. For multi-calendar systems, use the inLeapYear variant.