Prove the main thread is blocked during a 400 ms UI hang, and by what
Tapping a row freezes the UI for ~400 ms. Prove the main thread is blocked and identify what blocks it — use tooling, not guesswork.
Below is the heaviest stack trace captured on the main thread during the hang window in Time Profiler. Say which signal confirms the block and which frame is responsible.
Thread 0 (main) — 396 ms in this stack during the hang
-[RowController didSelect:]
DataStore.loadAll()
+[NSJSONSerialization JSONObjectWithData:options:error:]
__read_nocancel libsystem_kernel 380 ms
Diagnose the cause.
Confirm it with a tool, not a guess — Main Thread Checker or a hang report flags the stall, and the Time Profiler heaviest stack on Thread 0 shows ~396 ms in one synchronous call. Here DataStore.loadAll() parses JSON on the main thread, blocked on disk I/O.
- ✗Guessing at the cause instead of confirming the stall with a tool
- ✗Assuming a background thread is blocked when the main-thread stack is stuck
- ✗Blaming heavy rendering or a leak rather than a synchronous main-thread call
- →Which signal in Time Profiler tells you the main thread stalled rather than ran?
- →Why does a
__read_nocancelleaf prove the block is disk I/O?
Two signals prove it, and neither is a guess. Main Thread Checker (or an Xcode hang report) fires on a main-thread stall; Time Profiler then shows the heaviest stack sitting on Thread 0 for ~396 of the 400 ms — the main thread is in one call, not scheduling frames. The leaf is the proof:
-[RowController didSelect:] ← tap handler, main thread
DataStore.loadAll() ← reads a file, then parses JSON — synchronously
__read_nocancel (kernel) ← 380 ms blocked in a disk read
__read_nocancel is a blocking kernel read: the main thread is parked on disk I/O, not doing work. The fix is to move the load off the main thread and hand the result back:
Task.detached(priority: .userInitiated) {
let items = try await DataStore.loadAllAsync() // read + parse off-main
await MainActor.run { self.apply(items) }
}