Skip to main content
← Blog

Scroll Won't Wait — A Day on Calendar Performance

VauDium·

A sticky title that wobbled, and a month grid that showed blank rows on a fast fling. I swapped the list library, widened the window, painted a grid behind the list — and reverted all of it. What remained was one thing: make the row light.

Scroll Won’t Wait — A Day on Calendar Performance

Today was all calendar performance on fecit mobile. Two symptoms. In the daily timetable, the sticky title that keeps a block’s name at the top of the screen wasn’t smooth. In the month calendar, a fast fling showed rows being filled in. Neither was “slow” exactly — “rough” is closer — which is the kind of problem where you don’t even know what to measure first.

The short version: one got solved by changing the approach, the other didn’t get solved at all. The unsolved one taught me more.

1. Why the sticky title wobbled

When a long block scrolls off the top, its title should stay pinned at the top edge. The first implementation was the obvious one: read the scroll offset and push the title down by the same amount. It ran on the UI thread via reanimated, so in principle it should have been smooth.

On a real device the title trailed the scroll by half a beat and wobbled. Rounding to device pixels didn’t help. Delivering scroll events every frame didn’t help.

The cause was the approach itself. The scroll offset is delivered after the native scroll view has already drawn the content. Anything that reads that value and moves something is one frame late, no matter how fast it is. “Read the scroll, then move” is late by construction.

The fix: don’t move it

So the title doesn’t move anymore. There’s an overlay layer outside the scroll content, fixed to the top of the viewport. The moment a block’s top edge crosses the top of the screen, an identical title lights up there. While it’s pinned, the overlay never moves, so there’s nowhere for a delay to show. The scroll value only decides whether to show it, never where.

Two more rounds were needed.

  • Passing the show/hide decision through JS and React meant it appeared a couple of frames late. Now the decision and the opacity are settled on the UI thread; only tap-ability goes through JS.
  • I wanted the title to be pushed out when the block’s bottom arrives, like a section header. Pushing the overlay meant tracking scroll again — the wobble came back. The answer was a hand-off: in that phase the overlay turns off and the block’s own title, inside the scroll content, is pinned to the block’s bottom. Being part of the scroll content, it moves with native scroll perfectly and clips at the viewport edge naturally. Both sides compute the hand-off point with the same formula, because a one-pixel mismatch flickered on a fast scroll.

The whole thing reduces to one line: leave motion that must be smooth to native scroll; let JS only pick the moment that motion is needed.

2. Why the month grid showed blank rows

The month calendar is a virtualized list of week rows. It renders rows near the viewport and draws new ones as you scroll. Fling faster than it can draw, and rows that don’t exist yet come into view as white gaps.

Here’s what I tried, in order. Every one was reverted.

  • Widen the window. Pre-rendering more rows reduces gaps, but every scroll step then has to draw that many more. At 24 weeks the verdict was “way too slow.”
  • Render rows in two passes. Grid first, tasks next frame. The grid showed up fast, but each row now cost an extra commit and throughput dropped.
  • Paint a grid behind the list. So unrendered gaps show grid lines instead of white. It worked, but it was pretending, and I didn’t like it.
  • Switch to FlashList. Cell recycling should save view creation. The difference was barely perceptible.
  • Increase deceleration. Shorter flings mean fewer gaps, but going far takes more swipes. Not for a calendar.

Why FlashList didn’t help was the diagnosis of the day. Recycling saves view creation and destruction. But calendar rows share a structure while their content differs entirely — seven date numbers, seven backgrounds, every bar’s title and color. Recycled or not, nearly every view in the row gets a property update, so a heavy row stays heavy. A calendar with about a hundred uniform-height rows was never a place where virtualization cost much, so there was nothing for a different list to win.

So I made the row light

The real bottleneck was the cost of drawing one row. Taking it apart:

  • Each date cell created four derived atoms and five subscriptions just to know whether it was selected, today, past, or a holiday. Thirty-five per row, torn down and rebuilt every time a row was recycled or drawn. Now the row subscribes once and passes booleans to its cells. Four per row.
  • Every time a row was drawn, it re-filtered that week’s tasks by day and re-packed the bars into lines. The result is now cached per week; same data, no recomputation.
  • A cell had two touch targets and one wrapper view around them. The outer view is now the touch target itself — two fewer views per cell.
  • The surprise was routine ghosts, the virtual occurrences of repeating routines. Every time scrolling widened the query range, they were all rebuilt as new objects. With a single daily routine, that marked every week’s task array as “changed,” and every row re-rendered. This was what looked like bars being recomputed on every scroll. Same routine, same day now reuses the previous object.

The list settings went back to their original values — window size, batching, all of it. What remains from today is that the row got lighter, and that holds whatever the list is. The gaps on an extreme fling don’t fully disappear. That’s the nature of virtualization, and removing it means giving up virtualization, a price I decided not to pay.

Lessons

When the symptom is “rough,” the first question is “what is doing work every frame?” The sticky title had JS moving a position every frame; the calendar had entire rows redrawing on every scroll. Neither was a library or a config value. Both were about who works, and when.

A library doesn’t make heavy things light. Recycling, window size — if a row’s cost stays the same, you’re only moving it around. I only saw that after reverting five attempts.

Reverting is work too. The commit that survived touches four files. More than half of what I touched today was undone, and the undoing is what made the remaining change legible: what it’s for, and what it isn’t.