Skip to content
Development

The buttons weren't broken: the main thread was busy

By Victor Da Luz
iosswiftswiftuiperformancedev-logdeep-cut-atlas

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.

I redesigned the History tab in my Apple Music app to open a per-track sheet: play the song, add its album to a playlist, mark it “not interested,” and see more from the same artist. It passed 118 unit tests. It passed a simulator UI-automation run where I tapped through every control. Then I ran it on my phone and the sheet’s buttons didn’t respond for a second or two after it opened.

I spent too long guessing. My first theory was a pagination bug. My second was that mapping a few hundred models was slow. Both were wrong, and a reviewer kept telling me the same thing: stop guessing, measure. I’d researched the exact tools for this - Instruments’ Hangs instrument, the on-device Hang Detection toggle in Settings → Developer - and then tried to reason my way past needing them.

The fix came from one sentence of real device feedback: “the play, add, and ignore buttons don’t respond until the more section loads.”

That’s the whole bug. The “more from this artist” section loads asynchronously. To show only albums you don’t already own, the service fetched your entire Apple Music library and mapped it to comparison keys. The service is annotated @MainActor, so that mapping ran on the main thread. SwiftUI processes button taps on the main thread too. While the library mapping ran, every tap just queued. The buttons were fine. The thread was busy.

SwiftUI’s .task and a view’s button actions both run on the main actor by default. An await on a network call frees the thread, but the synchronous work between awaits - like mapping thousands of albums - does not. With a small library you’d never notice. With a real one it’s a visible freeze.

The fix was to not do the work at all on that path. The History screen had already computed the library keys when it loaded. So I passed those into the sheet and filtered the suggestions client-side, and told the catalog fetch to skip its whole-library pass. The buttons respond instantly now because the main thread isn’t doing anything heavy while the sheet is up.

The same shape explained a second freeze - returning to the list re-read the entire library and ran a synchronous database fetch on every dismiss. It only ever needed to re-check the one playlist that might have changed, and only when something actually changed.

The lesson I actually kept wasn’t technical. Unit tests and simulator automation verify logic and rendering. They don’t verify whether a tap feels instant, and they run on mock data that never reproduces real-library performance. So I added a rule to the project: no UI feature is “done” until a device pass with Hang Detection on confirms every tap responds quickly and the action actually persisted. The tools to catch this existed the whole time. I just needed to make myself use them before calling something finished.

Related reading