What is @MainActor, and how do you make sure UI code runs on the main thread?
TL;DR
@MainActor is a global actor that represents the main thread.
Code marked @MainActor always runs on the main thread. The compiler checks this, so you cannot update UI from the wrong thread by mistake.
UIKit views, view controllers and SwiftUI views are already @MainActor. Mark your view models with it too.
Why it exists
UIKit and SwiftUI are not thread-safe. UI updates must happen on the main thread. With GCD you had to remember:
DispatchQueue.main.async {
self.label.text = "Done"
}Forget it once, and you get random bugs or crashes. @MainActor turns this into a compile-time check.
Where to put it
On a whole type
@MainActor
@Observable
final class ProfileViewModel {
var name = ""
var isLoading = false
func load() async {
isLoading = true
let user = try? await api.fetchUser() // runs elsewhere, then comes back
name = user?.name ?? "" // main thread, guaranteed
isLoading = false
}
}All properties and methods are main-actor isolated.
On one method or property
final class Analytics {
@MainActor var lastScreen = ""
@MainActor
func showBanner() { /* UI work */ }
}On a closure
Task { @MainActor in
label.text = "Updated"
}Calling main-actor code from elsewhere
From outside the main actor, you need await:
func backgroundWork(model: ProfileViewModel) async {
let result = compute()
await model.update(with: result) // hops to the main thread
}Or run a block on it:
await MainActor.run {
label.text = "Done"
}Inheritance of @MainActor in Tasks
Task { } inherits the actor of the code that creates it. Task.detached { } does not.
@MainActor
final class FeedViewController: UIViewController {
func reload() {
Task {
// still on the main actor
let items = await service.items() // suspends, main thread is free
tableView.reloadData() // back on main
}
}
}An await inside a main-actor task does not block the main thread. The UI stays responsive.
Heavy work: get off the main actor
Being on the main actor is not free. Heavy CPU work there freezes the UI.
@MainActor
final class EditorModel {
var thumbnail: Data?
func makeThumbnail(from data: Data) async {
thumbnail = await Self.resize(data)
}
@concurrent
nonisolated static func resize(_ data: Data) async -> Data {
// heavy image work, runs on the background pool
data
}
}In Swift 6.0 and 6.1, a nonisolated async function already runs off the main actor. In Swift 6.2 with approachable concurrency, it runs on the caller's actor, so add @concurrent.
Default main actor isolation (Swift 6.2)
App targets can set Default Actor Isolation = MainActor. Then every type in the module is @MainActor unless marked nonisolated. New Xcode 26 app projects use this. It removes most concurrency errors in UI-heavy apps.
MainActor.assumeIsolated
Some old callbacks are always called on the main thread, but the compiler does not know it.
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil, queue: .main
) { _ in
MainActor.assumeIsolated {
refreshUI() // crashes if not really on main
}
}It checks at run time and traps if you are wrong. Use it only when the API documents main-thread delivery.
Common follow-up questions
- Is
@MainActorthe same asDispatchQueue.main? They both use the main thread.@MainActoris checked by the compiler and works withawait. - Does
@MainActoron a class make itsasyncmethods run on the main thread? Yes, the method body runs on main. Awaited calls inside it may run elsewhere. - Is SwiftUI
Viewmain-actor isolated? Yes. Since the iOS 18 SDK, the wholeViewprotocol is@MainActor, not justbody. - Can you call a
@MainActormethod fromdeinit? Not directly in Swift 6, becausedeinitis nonisolated. Swift 6.2 addsisolated deinitfor this. - What does
nonisolateddo in a@MainActorclass? It opts one member out, so it can be called from any thread withoutawait. It must not touch main-actor state.