The calendar that scrolled itself back to 1600
Open GameKal’s calendar in Safari on an iPhone, set the phone down, and watch. The month title shows the current month. The dates underneath it change, then change again, about 17 times a second, while the phone gets warm and every other app starts to stutter. Nobody is touching the screen.
It’s fixed now. On the web, the month grid no longer scrolls at all. It’s three months side by side, moved by a swipe gesture, and a five-second check with nobody touching it now counts 0 scroll events instead of 87. The iPhone and Android apps were never affected and kept their native list.
This post covers how a calendar ended up 1.9 million pixels wide, the five-second measurement that proved what was happening, and the fix. It also covers a second phone-web bug on the same screen: vertical swipes that wouldn’t scroll the page.
A list that never runs out
GameKal’s calendar pages sideways, one month per swipe. The simplest way to make that feel native is a horizontal list where each item is a month, with paging turned on so a swipe lands exactly on the next one.
The list held every month from 1600 to 2400. That’s 9,612 months, chosen so paging would never run out in normal use. Only three months are ever drawn, the previous, current and next, because drawing five once froze the phone’s JavaScript thread for about 1.5 seconds. Everything else is empty space the list knows the size of.
On a phone, each month is 370 px wide. A month in 2026 sits around position 5,100 in that list, so the calendar opens about 1.9 million pixels to the right. The list’s total width is 3,556,440 px. Keep that number in mind.
Several things set the scroll position. A starting offset, a starting index, a scroll right after the first layout, a fallback that scrolls again if jumping to an index fails, and snap-to-page scrolling. On iOS and Android these are the native list’s job. On the web, each one becomes an instruction to a browser scroll container.
Each choice made sense on its own. Chrome settles all of them in a frame. Safari didn’t.
Nobody had looked in Safari
The report came from my own phone: the calendar “loads a ton of calendars over and over” and lags the whole device. It happened in Safari and in DuckDuckGo. That’s one clue already: on an iPhone, every browser runs Safari’s engine, WebKit.
Every check I’d run on the calendar that week was in Chrome-based tools, and the iPhone app scrolls natively. Mobile Safari was the one place nobody had tested the month grid. It also reproduced on a Mac, in Safari’s Responsive Design Mode, which made it far easier to measure.
Five seconds, hands off
The question was simple: is the page scrolling itself? I pasted a small watcher into Safari’s console. It finds every element wider than a million pixels, adds a scroll listener to each, waits five seconds without anyone touching the page, then reports how many scroll events fired and the last positions it saw.
(() => {
const wide = [...document.querySelectorAll('div')].filter((d) => d.scrollWidth > 1e6);
let events = 0;
const last = [];
wide.forEach((el) =>
el.addEventListener('scroll', () => {
events++;
last.push(Math.round(el.scrollLeft));
if (last.length > 15) last.shift();
}, { passive: true }),
);
setTimeout(() => console.log('scroll events in 5s:', events, last.join(', ')), 5000);
})();
It only listens. A reload removes it.
It found the list. Six wide elements, and every width was an exact multiple of 370: 9,612, 5,035 and 4,573 pages. Those are the month list’s scroller and its inner content, not separate lists.
87 scroll events in five seconds, untouched. Each one moved the grid back exactly one page:
1665370, 1665000, 1664630, 1664260, 1663890, ... 1660560, 1660190
That’s 370 px per step, about 17 months a second, heading for index 0 and the year 1600. Every step drew a new month, which is why the phone heated up. Left alone, it would have kept going for about four more minutes.
Scroll events with nobody touching the calendar
Count over 5 seconds after opening /calendar, untouched. Lower is better.
Why Safari kept stepping back
Here’s the loop as best I can tell. The measured part is the steps: one page back, over and over, with no input. The mechanism is inferred, because I didn’t trace WebKit itself.
The loop, as inferred
Not to scale. Each turn moved the grid one month back.
nearest month it can find
its three months
is swapped out
Snap scrolling tells the browser to keep the scroll position on a page boundary. The list keeps only three months mounted and swaps them as the position changes. When the month Safari just snapped to gets swapped out, Safari looks for the nearest snap point that still exists, one page back, and scrolls there. That scroll makes the list swap months again.
Chrome handles snap targets that disappear differently, which is why the same page sat perfectly still there.
The fix: stop scrolling
I could have tried to tame the race: fewer position writes, different snapping, a guard against reacting to our own scrolls. Each would leave a 3.5-million-pixel scroller for some future browser to disagree about.
On the web, the month grid isn’t a scroller anymore. It’s three months in a row, previous, current and next, moved with a transform by the same horizontal pan gesture the rest of the app uses. A sideways drag past 10 px moves the row, and a vertical drag past 10 px is left to the page. On release, the row slides the rest of the way, the three months shift by one, and the transform resets before that frame paints. A trackpad’s sideways scroll pages one month per gesture.
With no scroll container and no snapping, there’s nothing for Safari to re-snap. Only three months exist, instead of a list with room for 9,612.
Everything around it behaves as before. The arrows, picking a month, the title, the per-month height, the loading skeleton and the week scrub follow the same rules. Native iOS and Android kept their list, which never had the problem.
After the fix, the five-second count is 0 at phone width, there’s no element wider than a million pixels on the page, and the layout shift score is 0. Then the real test: my phone, Safari, the calendar left alone. It stayed on the current month.
The second bug: swipes that wouldn’t scroll
With the calendar sitting still, a quieter problem showed up. On phone web, a vertical swipe that started on the calendar grid didn’t scroll the page. Home had the same problem on its Today slider, which covers about 80% of the screen. To scroll, you had to find a small strip where no swipe handler lived. The iPhone app was fine.
The cause was a default. On the web, the gesture library writes touch-action: none on any element it watches unless you tell it otherwise. That tells the browser “don’t scroll from here, the script will handle everything”. The pager inside the calendar was already marked pan-y. But the week-scrub gesture wrapped the title, the weekday row and the whole grid with no setting, and an ancestor’s none wins over a child’s pan-y.
The fix was pan-y on every swipe surface. That tells the browser vertical scrolling is its job, and sideways movement belongs to the script. The week scrub was the one place that needed more care: you hold on a week for half a second, then drag across weeks, including up and down. While a scrub is active, a non-passive touchmove listener stops the page from scrolling. It’s added when the hold starts and removed when the scrub ends, so ordinary scrolling never waits on it.
What I learned
Looking back, three lessons stand out.
Test where your users are, not where your tools are. Every automated check that week ran in a Chrome-based browser. Every iPhone browser runs WebKit. The bug was in the gap.
Count before you theorize. “It’s loading over and over” could have meant a fetch loop, a render loop or a crash and reload. Five seconds of counting scroll events turned that into a direction, a step size and a rate.
A list that never runs out is still a list. Making the data effectively infinite moved the problem into the browser’s scroll machinery. The pager that replaced it holds three months and doesn’t need to know about the year 1600.
What’s next?
The pager is web only. The native list on iOS and Android still holds 9,612 months, and it behaves. If it ever doesn’t, the same three-page approach is ready to move over.
The calendar’s month title showed the current month the whole time the grid was walking back to 1600. It’s a fair description of how the bug hid: everything you’d glance at looked fine.