MainActor
The global actor that owns the UI — and why being on it isn't the same as being fast.
@MainActor is a global actor whose executor is the main thread. Marking a type, method, or property with it means that code runs on the main thread, and the compiler checks it — calling into it from elsewhere requires await.
UIKit and SwiftUI are main-actor bound because view state must be mutated from one place. Before Swift Concurrency this was a documented convention enforced by crashes and assertions. Now it's a type-system rule, so "UIKit called from a background thread" moves from a runtime mystery to a compile error.
This project is built with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so types are main-actor isolated unless they opt out — the safe default for an app that's mostly UI.
await is automatic.@MainActorfinal class ProfileViewModel { var name = "" // main-actor isolated var isLoading = false
func load() async { isLoading = true // hop off to do the slow part let profile = await ProfileLoader().fetch() // not main-actor isolated // back on the main actor automatically after the await name = profile.name isLoading = false }}
// Not main-actor bound: free to run on the cooperative poolstruct ProfileLoader { nonisolated func fetch() async -> Profile { … }}
// Marking one function rather than a whole type@MainActor func present(_ alert: Alert) { … }
Show all 22 linesShow less
@MainActorfinal class ProfileViewModel { var name = "" // main-actor isolated var isLoading = false
func load() async { isLoading = true // hop off to do the slow part let profile = await ProfileLoader().fetch() // not main-actor isolated // back on the main actor automatically after the await name = profile.name isLoading = false }}
// Not main-actor bound: free to run on the cooperative poolstruct ProfileLoader { nonisolated func fetch() async -> Profile { … }}
// Marking one function rather than a whole type@MainActor func present(_ alert: Alert) { … }Under the hood
A global actor is an actor whose instance is a singleton and whose executor you can name. MainActor's executor dispatches to the main dispatch queue, so "main actor" and "main thread" line up in practice — but the isolation is the concept, and the thread is the implementation.
When a main-actor function awaits something that isn't main-actor isolated, the continuation hops back to the main actor on resume. That hop is real work: enqueueing onto the main queue and waiting your turn. A tight loop that awaits a non-isolated function on each iteration pays it every time, which is a common and invisible performance bug.
MainActor.assumeIsolated lets you assert you're already on it without a hop, and traps if you're wrong — useful in synchronous callbacks from frameworks that guarantee main-thread delivery but aren't annotated.
Quick check
Why can code running on @MainActor still cause the UI to freeze?
- It can't — MainActor code is always fast
- Because @MainActor means the code runs on the main thread, so any slow synchronous work there blocks rendering and touch handling
- Because MainActor uses a background thread that competes with the main one
- Only if you also use DispatchQueue.main.sync
Reveal answer
Because @MainActor means the code runs on the main thread, so any slow synchronous work there blocks rendering and touch handling
@MainActor is about *isolation*, not speed. Its executor is the main thread — the same thread that runs the run loop, handles touches, and commits frames. Slow synchronous work there blocks all of it. Adding await doesn't help if the awaited work is also main-actor isolated; the work has to actually move to a non-isolated type.
Interview question
Why can an app freeze even though all its code is correctly on the main actor?
Think first, then reveal
Because @MainActor is a statement about *where* code runs, not about how long it takes. Its executor is the main thread — the same thread that processes touches, runs the run loop, and commits frames to the render server. Any synchronous work on it delays all of that, and past roughly sixteen milliseconds you're dropping frames.
The confusing part is that await doesn't automatically help. If a main-actor function awaits another main-actor function, you suspend and resume on the same actor, and the work still happens on the main thread. The await makes it look asynchronous while changing nothing about who does the work. To actually get off, the awaited function has to be nonisolated or belong to a type that isn't main-actor bound.
The other freeze I'd look for is hop thrash: a main-actor loop that awaits non-isolated work each iteration pays a scheduling round trip every time. A thousand hops adds up, and nothing in the source looks slow. Doing the batch in one non-isolated call and returning the result costs one hop instead.
So the rule I work to is: the main actor holds UI state and applies results. Computation — parsing, decoding, image work, database queries — lives on types that aren't isolated to it.
What an interviewer listens for
- Separates isolation from performance clearly
- Knows await between two main-actor functions changes nothing
- Raises repeated actor hops as a distinct, hard-to-see cost
- States a usable rule for what belongs on the main actor
Key takeaway
@MainActor is where UI state must live, not where work should happen. Keep it for state and presentation, push computation to non-isolated types, and remember every hop back has a cost.