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.

Century leap-year ruleDivisible-by-4-only logic wrongly makes 1900 a leap yearGetting the century rule wrong1900 treated as leap(÷4 only) → Feb 29 19001900 is common(÷100, not ÷400)A one-day drift in schedules, billing cycles, and iterators that assume Feb 29.

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.

Leap-year APIs and their roleWays to test a leap year and their guaranteesLeap-year APIs and their roleyear % 400 rulepure arithmetic, always correct if completeTemporal.PlainDate.inLeapYearcalendar-aware boolean propertyPlainDateTime.daysInMonth (Feb)28 or 29 without the rulenew Date(y,1,29).getDate()host-dependent trick — avoid

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.

Leap check with legacy DateThe Date Feb-29 rollover trick versus the arithmetic ruleLegacy DateModernnew Date(y, 1, 29)→ getDate() === 29 ?relies on Date rollover;opaque and host-sensitivey % 4 === 0 &&(y % 100 !== 0 || y % 400 === 0)explicit and portable

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)

Temporal leap-year checkPlainDate exposes the answer directlyTemporal leap-year checkTemporal.PlainDatefrom({y,2,1}).inLeapYearor .daysInMonthboolean /28|29

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.

Validated isLeapYear utilityGuard the input, apply the rule, agree with TemporalValidated isLeapYear utilityinput yearNumber.isIntegerguard÷4/÷100/÷400boolean

Edge cases

The century-year cases are the whole reason this topic is more than a one-liner. Years divisible by 100 but not by 400 — 1700, 1800, 1900, 2100 — are not leap years despite being divisible by 4, because the Gregorian reform drops those leap days to keep the calendar aligned with the solar year. A % 4-only check is wrong for exactly these years, and because they are rare and almost never appear in test fixtures, the bug can survive for years before a report spanning 1900 or a projection to 2100 surfaces it. Testing must include a century non-leap year and a % 400 leap year (2000) explicitly, or the most important cases go unexercised.

A separate edge case is the historical boundary. The Gregorian calendar was adopted at different times in different places (1582 in Catholic Europe, much later elsewhere), and ISO's proleptic Gregorian calendar extends the modern rule backward past those adoptions, so a "leap year" computed for a pre-adoption year is a mathematical projection, not what contemporaries observed. For most software this is irrelevant, but for genealogy, history, or astronomy it matters, and the honest approach is to be explicit about which calendar a historical year is expressed in rather than assuming the Gregorian rule held universally. And in non-Gregorian calendars "leap year" can mean an added month rather than a day, which no Gregorian predicate captures.

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.

Leap-year edge casesCentury years, proleptic years, and non-Gregorian calendarsLeap-year edge casesCentury years1700/1800/1900common; 2000 leapYear 0 / negativeproleptic Gregorianstill ÷400 ruleNon-GregorianHijri/Hebrew differuse the calendar

Gotchas & common pitfalls

Leap-year pitfallsDivisible-by-4 shortcuts and string coercion misfireWrongRighty % 4 === 01900 wrongfull ÷4/÷100/÷400 ruleevery century correct'2024' % 4 (string)works by luck, fragilecoerce to integer firstexplicit types

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

Leap-year test matrixAssert representative leap and common yearsAssertions that prove the edge case2000 (÷400)leap1900 (÷100)common2024 (÷4)leap2023common

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.

Which years are the tricky exceptions to the leap-year rule?

Century years divisible by 100 but not by 400 — such as 1700, 1800, 1900, and 2100 — are not leap years even though they are divisible by 4, because the Gregorian reform drops those leap days to stay aligned with the solar year. 2000, divisible by 400, is a leap year. These are exactly the cases a year % 4 === 0 check gets wrong, and they rarely appear in test data, so test them explicitly.

Does the leap-year rule apply to historical dates before 1582?

Not straightforwardly. The Gregorian calendar was adopted at different times in different places starting in 1582, and ISO's proleptic Gregorian calendar projects the modern rule backward past those adoptions, so a leap year computed for a pre-adoption year is a mathematical projection rather than what people then observed. For genealogy, history, or astronomy, be explicit about which calendar a historical year uses instead of assuming the Gregorian rule held everywhere.