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).
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
- Four-year shortcut โ
year % 4 === 0alone is wrong for century years. Fix: use the full(รท4 && !รท100) || รท400expression. Date-overflow check without a UTC anchor โ host-timezone coercion can shift the boundary. Fix: use the modulo test, which never touches a clock.Temporalwith default overflow โ'constrain'clampsFeb 29 โ Feb 28and reports every year as leap. Fix: pass{ overflow: 'reject' }.- No input validation โ fractional or negative years return a clean but meaningless boolean. Fix: assert
Number.isInteger(year) && year >= 0. new Temporal.Calendar('id')โ theTemporal.Calendarconstructor was removed from the final spec. Fix: passcalendar: 'id'as a string field to.from().
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.
Related
- JavaScript Date Fundamentals & Core Concepts โ the parent guide for this topic.
- Check if a year is a leap year in JavaScript โ the focused, copy-paste implementation.
- Understanding UTC vs local time in JS โ why the
Date-overflow check can drift. - Date arithmetic without mutations โ the overflow policies behind
Temporaldate math. - Calendar systems and era handling โ leap rules beyond the Gregorian calendar.