Fixing History's load path, then catching myself over-commenting the fix
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
Third perf pass this week on Discoverer, this time on the History tab. The same review that caught the Discover tab’s issues flagged three problems here too, all in the code that loads and paginates your recently-played tracks.
The first one was familiar by now: three fetches that should have been independent were running one after another. Playlist contents, then a full scan of library album keys, then the first page of recently-played tracks. All sequential, all blocking the screen from showing anything. The twist this time was that one of those three fetches, the library key scan, wasn’t even needed to render the list. It only mattered later, for gating a button in a detail sheet that opens when you tap a row. It was loading up front purely out of habit, not because the screen needed it.
The fix had two parts. First, kick off the playlist fetch and the first page fetch at the same time using a plain unstructured task, the same workaround from a few days ago since Swift’s cleaner async let syntax doesn’t work against this kind of main-actor service. Second, move the library key scan to run lazily, once per session, after the screen is already showing something. There was already a working example of this exact pattern elsewhere in the app, on the Playlist tab, so I copied its shape instead of inventing a new one.
The other two problems were smaller but showed the same shape of mistake: doing repeated work that could have been done once. One spot recomputed a filtered list every time a row scrolled into view, instead of once per screen draw. Another recomputed a running count from scratch on every page of results instead of just keeping a running tally. Neither is dramatic on a small list. Both turn into real, felt lag as a list grows, because the cost scales with the square of the list size instead of linearly.
The part I didn’t expect to be writing about
Partway through, my one-line fixes had grown a small essay of “why” comments attached to each one, explaining the reasoning behind every change. It felt responsible at the time. Then the file tripped a line-count linter rule and I had to go back and trim nearly everything I’d just written, function by function, just to get the file back under the limit. That’s when it landed: I wasn’t writing comments because the code needed them. I was writing them because I’d just finished reasoning through something tricky, and typing it out felt like proof of the work. The code didn’t get harder to read without them. If anything, some of the trimmed versions read cleaner, because a comment that just restates “we do X because Y” often means X and Y should have been a better name in the first place.
I ended up writing myself a stricter rule about it before moving on: before adding any comment, check whether removing it actually loses information a reader can’t get any other way, whether it’s covering a genuine exception rather than routine logic, and whether a better name would have made it unnecessary. If it doesn’t clear all three, don’t write it. Small thing, but it’s already changing how the next diff looks.
Related reading
The 8-second scan hiding in every refresh
Measuring on device found a whole-library scan running on every Discover batch - and a 16x measurement trap where the HTTP cache flattered the wrong path.
The whole-library scan I thought I'd already fixed
A pattern doesn't get fixed once. One more main-actor library scan hiding on a write path, and the batch API that was only wired in one direction.
The buttons weren't broken: the main thread was busy
118 green tests, a clean simulator pass, and dead buttons on a real phone. The bug was a @MainActor service doing synchronous work between awaits.