The 8-second scan hiding in every refresh
Deep Cut Atlas has two main tabs. Both sometimes took ages to load, long enough that I’d switch to another app while waiting. This week I ran a performance review pass to find out why.
The problem
The app tracks which albums you already own so it can show you only new releases. To do that it scans your whole Apple Music library and builds a set of normalized album keys. I knew the scan wasn’t free. What I didn’t know was what it actually cost on a real library, or how often it ran.
So I measured it. I wired a temporary diagnostic into the app behind a launch argument, built it to my phone, and launched it from the terminal with console capture:
xcrun devicectl device process launch --console --terminate-existing \
<device-id> com.example.app --timing-diag
The numbers on my library (1,610 artists, 5,054 albums):
fetchLibraryArtists cold 533ms
fetchLibraryAlbumKeys cold 8215ms
fetchPlaylistContents 73ms
Eight seconds. And the code review found the Discover tab ran that scan on every batch: every cold start, every pull-to-refresh, every return from the background with stale data. The Playlist tab had learned this lesson months ago and cached the scan per session. The Discover tab never got the memo.
The fix
Move the cache down into the shared service, so every tab benefits and the scan runs once per session:
private var cachedLibraryAlbumKeys: Set<String>?
private var libraryAlbumKeysScan: Task<Set<String>, Error>?
func fetchLibraryAlbumKeys() async throws -> Set<String> {
if let cachedLibraryAlbumKeys { return cachedLibraryAlbumKeys }
let scan = libraryAlbumKeysScan ?? Task.detached(priority: .utility) { /* scan */ }
libraryAlbumKeysScan = scan
do {
let keys = try await scan.value
cachedLibraryAlbumKeys = keys
libraryAlbumKeysScan = nil
return keys
} catch {
if libraryAlbumKeysScan == scan { libraryAlbumKeysScan = nil }
throw error
}
}
The stored Task matters as much as the stored value. At app launch, two tabs request the scan within milliseconds of each other. With only a value cache, both would miss and both would scan. With the task cached, the second caller awaits the first caller’s scan. And on failure the task gets cleared so the next call retries, instead of replaying a cached error forever. Task is Equatable in Swift, which lets the failure path clear only its own task rather than clobbering a retry someone else already started.
Two more wins came out of the same review. The per-artist catalog lookup was making two requests per artist: one to re-resolve the artist by id, one to load its albums. The artist objects from the session scan can be reused directly, so now it’s one request. And the playlist tab was re-fetching its contents on every tab switch. It now checks a write-version counter on the service, so it only re-fetches when something actually changed. A 5-minute staleness window catches edits made in the Music app itself.
The measurement trap
My first timing comparison for the catalog lookup said the old path was 16x faster than my new path. That would have been a rough day, except it was nonsense: both passes fetched the same five artists, and the second pass was served from MusicKit’s HTTP cache. Ten network requests in 61ms is not a thing my wifi can do.
Re-running with disjoint artist samples, and with the old path measured first to bias against my change, gave the honest result: 231ms per artist before, 153ms after.
Results
Discover cold start went from roughly 18 seconds of work to about 6, and a warm pull-to-refresh no longer pays the 8-second scan at all. Playlist tab switches are instant unless something actually changed. All confirmed on device, since MusicKit returns nothing in the simulator.
The lesson I keep re-learning: measure on the device with real data before and after. The library scan “felt slow” for weeks, but it took one printed number to see it was 8 seconds and running far more often than it needed to.
Related reading
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 mirrored window lied about the phone being unlocked
A one-line grouping fix that was already written, and three walls between it and proof: actor isolation, log stream's Mac-only scope, and a mirrored session that looks unlocked when the device isn't.
Pre-release albums, and the fix I couldn't fully verify
Apple's 'Track N' placeholders rendered as real data. The one detection signal I couldn't confirm became the one signal I stopped depending on.