Getting Leap Years Right, and Listing All 27 Leap Seconds Ever Inserted
"Every four years" is the leap year rule most developers remember. It's wrong on century boundaries. 1900 was not a leap year, 2000 was, 2100 won't be. Writing a tiny tool to check this led me to a nearby rabbit hole: leap seconds — a completely separate concept, and a historical curiosity with exactly 27 entries.
Everyone thinks they know the leap year rule. "Every four years." Then we discover 2100 isn't a leap year and 2000 is, and remember the Y2K-era discussions dimly. The actual rule is short, well-defined, and easy to test — and while I was writing it, I fell into the adjacent topic of leap seconds, which are a historical curiosity with a hard stop coming in 2035.
🔗 Live demo: https://sen.ltd/portfolio/leap-years/
📦 GitHub: https://github.com/sen-ltd/leap-years
Check any year, list all leap years in a range, browse the 27-entry leap second insertion history from 1972 to 2016. Vanilla JS, zero deps, no build, 14 tests.
isLeapYear in 3 lines, in the right order
The rule:
- Divisible by 4 → leap year
- Exception: divisible by 100 → not a leap year
- Exception to the exception: divisible by 400 → is a leap year
So 1900 is not, 2000 is, 2100 is not, 2400 is.
export function isLeapYear(year) {
if (!Number.isInteger(year)) return false
if (year % 400 === 0) return true
if (year % 100 === 0) return false
return year % 4 === 0
}
Order matters — check 400 before 100 before 4. Get the order wrong and 2000 falls into the 100-exception clause and gets reported as not a leap year.
A one-expression version is possible:
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
It's correct, but the nested negations make the intent opaque. Three ifs read like the spec, and the spec is what you want the code to match.
Listing leap years in a range
export function leapYearsIn(startYear, endYear) {
const out = []
for (let y = startYear; y <= endYear; y++) {
if (isLeapYear(y)) out.push(y)
}
return out
}
Nothing clever. The visual payoff of using this function is seeing 1900→2100: 1904, 1908, ..., 1996, 2000, 2004, ..., 2096 — no 1900, no 2100, yes 2000. That one asymmetry in the output is exactly what the 400 exception looks like when visualized.
Leap seconds — a completely separate concept
Leap seconds are not related to leap years. They're a correction mechanism for the tiny wobble in Earth's rotation vs. atomic time (TAI). When the gap grows close to 1 second, a second is inserted into UTC at either midnight of June 30 or December 31. For that one minute, 23:59:60 UTC exists — a valid 61-second minute.
Twenty-seven of these have happened in total, between 1972 and 2016:
export const LEAP_SECONDS = [
{ date: '1972-06-30', delta: 1 },
{ date: '1972-12-31', delta: 1 },
{ date: '1973-12-31', delta: 1 },
// ...
{ date: '2016-12-31', delta: 1 },
]
Since 2017 there have been none, and at the 27th General Conference on Weights and Measures (CGPM) in 2022, it was formally decided that leap seconds will be phased out by 2035. So this list is a nearly-complete historical artifact. It may get a handful of new entries over the next decade, and then it's frozen forever.
Why {date, delta} and not just {date}
Every leap second that has ever happened has been +1. The spec allows -1 (removing a second) but the Earth's rotation trends slow, so the correction has always been additive. Why include a delta field that's always 1?
Because the concept supports both directions. If future history included a -1, you'd want the data model to already handle it. Baking delta: 1 into code shape is cheap insurance that costs nothing now and preserves optionality. Good data modeling often means naming the axes you might never use.
Next leap year helper
export function nextLeapYear(year) {
let y = year
while (!isLeapYear(y)) y++
return y
}
Naive linear scan. Worst case is 4 iterations because the longest gap between leap years (excluding the 100-exception cases) is 4 years. Even counting century skips, the longest gap is 8 years (1896 → 1904). Fast enough that I don't care about optimizing this.
Tests
14 cases on node --test, with the critical boundary coverage:
test('2024 is a leap year', () => {
assert.equal(isLeapYear(2024), true)
})
test('2100 is NOT a leap year (100 exception)', () => {
assert.equal(isLeapYear(2100), false)
})
test('2000 IS a leap year (400 exception to the 100 exception)', () => {
assert.equal(isLeapYear(2000), true)
})
test('1900 is NOT a leap year', () => {
assert.equal(isLeapYear(1900), false)
})
test('2400 IS a leap year', () => {
assert.equal(isLeapYear(2400), true)
})
test('leapYearsIn 1990-2020 contains 2000', () => {
const list = leapYearsIn(1990, 2020)
assert.ok(list.includes(2000))
assert.ok(list.includes(1996))
assert.ok(list.includes(2020))
})
test('leapYearsIn 1800-2100 excludes 1800, 1900, 2100', () => {
const list = leapYearsIn(1800, 2100)
assert.ok(!list.includes(1800))
assert.ok(!list.includes(1900))
assert.ok(!list.includes(2100))
assert.ok(list.includes(2000))
})
test('leap seconds list has 27 entries', () => {
assert.equal(LEAP_SECONDS.length, 27)
})
Testing 1900, 2000, 2100, and 2400 all explicitly is non-negotiable. Any reordering of the three-if chain breaks at least one of those, and you want the broken test to be loud about which one. A single "is 2020 a leap year" test would pass with a completely wrong implementation.
Series
This is entry #18 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/leap-years
- 🌐 Live: https://sen.ltd/portfolio/leap-years/
- 🏢 Company: https://sen.ltd/
After 2035, the leap second list stops growing forever. This tool will become a historical artifact of an abandoned timekeeping convention.

Top comments (1)
Other than the fact I'd be 122 years old!, I wouldn't want to be a developer in 2100 for sure. There is going to be at least one system that thinks the day after 28th February 2100 is the 29th. We've got the 2038 problem to fix first.