"The scrubber felt sluggish. Nobody's code was slow on its own. Everything was just queueing at the same door."
One Thread Owns the Screen
AppKit and UIKit are not thread-safe. Views, windows, the responder chain and most of their state may only be touched from the main thread, which runs the app's run loop: it takes an input event, lets your code react, lays out and draws, and goes back for the next event. Every millisecond your code holds the main thread is a millisecond no click is handled and no frame is drawn. Hold it for a few hundred milliseconds and the app feels broken; hold it for seconds and macOS shows the spinning cursor.
Swift gives that thread a static name: the @MainActor global actor. Code marked @MainActor runs on the main thread, and the compiler refuses to let non-isolated code reach into it synchronously. SwiftUI views are main-actor isolated. So are AppKit and UIKit view types in the SDK. The rule stops being a comment and becomes something the type checker enforces.
How a Room Gets Crowded
A family video player on the Mac felt sluggish whenever you dragged the scrubber. No single function was slow. Two patterns were queueing at the main thread's door:
- Polling instead of observing. On every wakeup and on a 200 ms timer, the player re-read 70 to 100 engine properties synchronously from the main thread. Each read waited for the playback engine to reach a safe point — a whole playloop iteration mid-seek. Measured: about 10 ms per poll, 26 polls a second, a quarter of the main thread gone, plus the SwiftUI updates those writes triggered. The fix was to observe: let the engine push only the values that changed, and publish a UI update only when a value actually differed.
- Waiting on the display. The video frame was rendered inside an AppKit draw call, and the render call deliberately sleeps until the frame's display time. That put the main thread to sleep for 43% of 4K playback. Moving rendering onto a dedicated queue behind a layer left the main thread 94% idle during the same playback.
The Shape of the Fix
Keep the main actor for what only it can do — mutating UI state and talking to views — and send everything that waits, scans, decodes or blocks somewhere else, then hop back with the result. In Swift concurrency that hop is an await: your @MainActor function suspends, the work runs off the main actor, and execution resumes on the main actor with the value. While it is suspended, the main thread is free to handle input.