How it works
The explanation, with examples you can run.
One turn of the loop
You can picture the main thread doing this, forever:
- Sleep until an input source or timer fires.
- Call your code: a gesture handler, a
bodyevaluation, a timer callback, a block queued withDispatchQueue.main.async, a main-actor task resuming. - Lay out and commit any UI changes, so the next frame can show them.
While step 2 runs, steps 1 and 3 can't. A slow handler doesn't just delay itself: it delays every touch and every frame after it. Apple's responsiveness guide puts a discrete delay above 100 ms in "hang" territory, and suggests keeping the main thread's work for a frame under about 5 ms, since a 120 Hz display gives you 8.3 ms in total.
Keep the UI on main, the rest off it
UI objects aren't thread-safe. Changing a view or a value SwiftUI observes from a background thread can corrupt state or crash, and Xcode's Main Thread Checker reports such calls while you debug. The pattern is: do heavy work elsewhere, then come back to the main thread to show the result.
@MainActorfinal class PhotoViewModel { var thumbnail: UIImage? func load(file: URL) async { let image = await Self.makeThumbnail(file) // runs on the thread pool thumbnail = image // back on the main actor } // @concurrent (Swift 6.2) means "run on the thread pool, not on the caller's actor". // In Swift 6.0 and 6.1, write nonisolated instead: that already ran async code off the actor. @concurrent static func makeThumbnail(_ file: URL) async -> UIImage? { guard let data = try? Data(contentsOf: file) else { return nil } return UIImage(data: data)?.preparingThumbnail(of: CGSize(width: 200, height: 200)) }}With Swift concurrency, "the main thread" is spelled @MainActor, and the compiler checks it for you; with Grand Central Dispatch it is DispatchQueue.main. The idea is the same. One caution from Apple's guide: hopping back to the main thread costs something too, so don't schedule work there that doesn't need to be there, not even asynchronously.
Run loop modes
A run loop runs in a mode that decides which sources it listens to. While a scroll view is tracking a finger, the main run loop switches to a tracking mode, and a Timer scheduled in the default mode stops firing until the scroll ends. Adding the timer to the common modes keeps it running:
let timer = Timer(timeInterval: 1, repeats: true) { _ in updateClock() }RunLoop.main.add(timer, forMode: .common)You rarely manage run loops yourself (secondary threads only have one if you run it), but knowing the main thread is a loop explains most "why didn't my UI update" and "why did it freeze" questions.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.