iosNudge

What is @MainActor, and how do you make sure UI code runs on the main thread?

MediumAsked oftenConcurrency 2 min read

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 @MainActor the same as DispatchQueue.main? They both use the main thread. @MainActor is checked by the compiler and works with await.
  • Does @MainActor on a class make its async methods run on the main thread? Yes, the method body runs on main. Awaited calls inside it may run elsewhere.
  • Is SwiftUI View main-actor isolated? Yes. Since the iOS 18 SDK, the whole View protocol is @MainActor, not just body.
  • Can you call a @MainActor method from deinit? Not directly in Swift 6, because deinit is nonisolated. Swift 6.2 adds isolated deinit for this.
  • What does nonisolated do in a @MainActor class? It opts one member out, so it can be called from any thread without await. It must not touch main-actor state.