What @MainActor actually promises
It guarantees where your code runs, not that nothing changes while it waits. Three places where that difference turns into bugs.
Marking a view model @MainActor is the first thing most of us do when moving UIKit or SwiftUI code to Swift concurrency. It is the right move, and it removes a whole class of "UI touched from a background thread" crashes. But the annotation promises less than it seems to, and the gaps are where the interesting bugs live.
What it guarantees
A type or function marked @MainActor is isolated to the main actor. Every synchronous piece of it runs on the main thread, one piece at a time, and the compiler makes callers from other isolation domains await before they can touch it.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var name = ""
func load(using api: ProfileAPI) async {
let profile = await api.fetchProfile() // suspends here
name = profile.name // back on the main actor
}
}That is the whole promise: where the code runs. It says nothing about what else runs while this function is suspended.
Gap 1: an await is a door left open
Every await is a suspension point. While load waits for the network, the main actor is free to run anything else: a second tap on the same button, a .task that restarted because the view's identity changed, a notification handler. When load resumes, the state it checked before the await may no longer be true.
// Broken: two quick taps both pass the guard.
func refresh() async {
guard !isLoading else { return }
// Suspends here: a second call can pass the guard meanwhile.
let token = await auth.validToken()
isLoading = true
items = await api.fetchItems(token: token)
isLoading = false
}
// Fixed: claim the work before the first await.
func refresh() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
let token = await auth.validToken()
items = await api.fetchItems(token: token)
}In the first version both calls pass the guard, because the flag is only set after a suspension. Two requests go out, and whichever returns last decides what the list shows. Setting the flag before the first await closes the gap.
Gap 2: Task {} inherits, Task.detached does not
A Task { } created inside main-actor code inherits the main actor. A Task.detached { } inherits nothing: its body runs on the global concurrent executor, and touching main-actor state from it requires an await.
@MainActor
func onAppear() {
Task {
await load(using: api) // runs on the main actor
}
Task.detached(priority: .background) {
await Thumbnailer.shared.prewarm() // off the main actor
}
}Detached tasks are rarely what you want. They also drop task-local values and priority inheritance. Reach for them only for work that truly has no relationship with the code that started it.
Gap 3: where nonisolated async functions run
An async function that is not isolated to any actor has historically run on the global concurrent executor, not on the caller's actor (SE-0338). So calling a plain async helper from a main-actor view model hops off the main thread, and the arguments you pass have to be Sendable.
Swift 6.2 changes this default behind the upcoming feature NonisolatedNonsendingByDefault (SE-0461): such functions run on the caller's actor unless they are marked @concurrent. Which behaviour your module gets depends on its build settings, so it is worth checking before reasoning about thread hops.
Bridging old callbacks
Delegate methods and completion handlers from older APIs often arrive on the main queue without the compiler knowing it. MainActor.assumeIsolated lets you state that fact, and traps at runtime if you are wrong:
// A delegate the system calls on the main thread,
// declared in a framework that predates concurrency annotations.
nonisolated func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
MainActor.assumeIsolated {
viewModel.location = locations.last
}
}It is a better tool than DispatchQueue.main.async here, because it does not delay the update to the next run-loop turn, and a wrong assumption fails loudly in debug instead of silently racing.
Summary
@MainActorfixes where code runs, not what interleaves with it.- Treat each
awaitas a point where state can change underneath you. Task {}stays on the actor;Task.detached {}leaves it.- Check which default your module uses for nonisolated
asyncfunctions.