@MainActor — what it is, usage, and features in Swift asynchronous code

Author: IT Sectr Published: 2026-03-19 Reading time: 8 min

@MainActor is a global actor in the Swift language that guarantees code execution on the main thread. According to Apple Developer, 2024, @MainActor automates switching to the main thread when working with the UI, freeing the developer from manually calling DispatchQueue.main.async. The annotation appeared in Swift 5.5 along with the async/await system.

Key Takeaways

  • @MainActor — a global Swift actor for guaranteeing execution on the main thread.
  • The async/await system — the foundation on which @MainActor is built.
  • Class annotation automatically places all its methods on the main thread.
  • Unlike DispatchQueue.main, @MainActor checks the thread at the compiler level.
  • UI updates — the primary use case for @MainActor in iOS development.

What is @MainActor?

@MainActor is a global actor in Swift that combines the properties of actors with the guarantee of execution on the application's main thread. It is part of the Swift concurrency system introduced in Swift 5.5 along with async/await and structured concurrency. The annotation allows the developer to avoid thinking about manual thread switching and reduces the number of UI bugs.

Definition and place in Swift Concurrency

An actor in Swift is a reference type that isolates its state and guarantees that only one thread can modify it. @MainActor is a special global actor whose executor is the main thread. Any code marked with @MainActor runs on the main thread — even if called from a background task.

Before @MainActor, developers manually switched to the main thread using DispatchQueue.main.async. This was a source of frequent errors: developers forgot to switch, leading to crashes due to UI updates not on the main thread. @MainActor solves this problem at the type system level.

Reasons for creation

The source of most bugs in iOS applications is UI unsafety — updating the interface from a background thread. Apple built @MainActor into Swift Concurrency to make switching to the main thread automatic and compiler-checkable, eliminating an entire class of runtime errors.

How does @MainActor work?

The working principle of @MainActor is based on the Swift Concurrency execution system. When a thread calls a function marked with @MainActor, the scheduler suspends it on the current executor and resumes it on the main thread. The compiler tracks call boundaries and guarantees safety.

Main thread executor

@MainActor execution is handled by MainActor.shared — an executor associated with the application's main thread. When an asynchronous function is marked with @MainActor, it always resumes on this executor, regardless of which thread the original task was started on.

swift
import SwiftUI

class ViewModel: ObservableObject {
    @Published var items: [String] = []

    @MainActor
    func loadData() async {
        let result = await fetchRemoteData()
        items = result  // safely, MainActor guarantees the main thread
    }
}

Actor context inheritance

If a function is marked with @MainActor and calls another asynchronous function, it inherits the actor context by default. This means all nested calls also execute on the main thread, unless specified otherwise. The compiler tracks this and issues an error when trying to pass an inconsistent closure.

@MainActor vs DispatchQueue.main

Comparing @MainActor and DispatchQueue.main helps understand why the new mechanism is considered safer and more convenient, even though both solve the same task — executing code on the main thread.

Type-level safety

@MainActor is a check at the compiler level. If you try to call a @MainActor function from an unsafe context, the compiler will issue a warning or error. DispatchQueue.main.async is a runtime call: the code will compile but may crash at runtime when trying to update the UI from a background thread.

Performance and overhead

DispatchQueue.main.async adds a block to the queue that may be executed with a delay. @MainActor with async/await performs direct executor switching without creating unnecessary closures. This reduces overhead and makes execution time more predictable.

swift
// Old approach
DispatchQueue.main.async {
    self.updateUI()
}

// New approach with @MainActor
@MainActor
func updateUI() {
    // runs on the main thread
    self.label.text = "Updated"
}
Criterion@MainActorDispatchQueue.main
Checkcompilerruntime
Syntaxannotation (declarative)call (imperative)
Overheadlow (executor switching)medium (closure + queue)
Testabilityhigh (MainActor.shared can be replaced)low (hard to mock)

Using @MainActor in iOS projects

In real iOS projects, @MainActor is used in ViewModel layers, SwiftUI views, and UIKit controllers. The annotation can be applied both to individual methods and to the entire type.

Class or structure annotation

By marking a class with @MainActor, you guarantee that all its methods and properties are only accessible on the main thread. This is especially convenient for SwiftUI views and ObservableObject classes: you simply add @MainActor before class, and all @Published properties update safely.

swift
@MainActor
final class UserListViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false

    func fetchUsers() async {
        isLoading = true
        users = await api.getUsers()
        isLoading = false
    }
}

Wrapping legacy code

When working with legacy UIKit code where thread switching was manual, you can use MainActor.run for explicit switching. This is convenient for an incremental transition to Swift Concurrency without rewriting the entire codebase.

swift
await MainActor.run {
    self.tableView.reloadData()
}

Limitations of @MainActor

Despite all its advantages, @MainActor has a number of limitations that are important to consider when designing application architecture. Understanding the boundaries of applicability helps avoid incorrect usage.

Performance under intensive use

If the entire call chain is marked with @MainActor, then any heavy work will be performed on the main thread, causing UI freezes. It is recommended to mark only the UI layer with @MainActor, while leaving business logic and network requests in background actors or the global executor.

Incompatibility with some APIs

Older callback-based APIs (for example, URLSession without async/await) do not support the actor context. Integration requires a wrapper with CheckedContinuation. Also, @MainActor is not compatible with performSelector, target-action, and other non-asynchronous UIKit patterns.

Multithreaded debugging

When debugging applications with @MainActor, it is harder to reproduce race conditions because the compiler prevents many of them at build time rather than at runtime. However, this can create a false sense of security: incorrect work with shared mutable objects (for example, NSCache or shared global variables) is still possible if they are not marked with @MainActor and are used without explicit synchronization.

Testing @MainActor

@MainActor significantly simplifies UI logic testing since it eliminates the need to manually switch threads in tests. However, there are specifics that need to be considered when writing unit tests and UI tests.

Unit tests with MainActor

In XCTest, the test environment automatically sets up the main thread executor. When a test method runs on the main thread, calling @MainActor functions requires no additional setup — they execute in the same context. For testing background scenarios, use MainActor.run inside a Task with an explicit priority and executor, separately verifying that the code works correctly when called from the background.

One common approach is testing ViewModel with @MainActor, where you verify that @Published properties update correctly after asynchronous operations. Thanks to actor context inheritance, calling await inside the test guarantees execution on the main thread without additional DispatchQueue guarantees or manual context switching, which simplifies writing tests.

Checking isolation during refactoring

When refactoring existing code to Swift Concurrency, check @MainActor isolation through the compiler: any calls to synchronous methods without @MainActor from a @MainActor context are flagged as an error. This property is used for gradually migrating a project to async/await: you mark the ViewModel layer as @MainActor, and the compiler highlights all unsafe calls that need to be moved to background actors.

Mocking and Actor context

When creating mocks for @MainActor dependencies, use protocols with async methods that declare asynchronous functions with return types. This allows replacing network services, databases, and other external dependencies without breaking actor isolation. The compiler verifies that the mock implements all isolation requirements, preventing accidental access to @MainActor code from background test threads.

Wait for asynchronous operations

When synchronously testing @MainActor code, use XCTestExpectation to wait for the completion of asynchronous operations. Set the expectation in the test and call fulfillment inside a closure that executes on the main thread. If the test hangs indefinitely — the call on the main thread is probably not happening, and you need to check actor isolation. For debugging execution context, it is useful to add a Thread.isMainThread check inside the test code.

Frequently Asked Questions

Is it necessary to mark the entire class with @MainActor?

No, it is sufficient to mark only the methods that update the UI. However, if a class has several such methods, it is simpler to add @MainActor to the entire class. This guarantees that all its members execute on the main thread and simplifies code maintenance.

How is @MainActor different from @globalActor?

@MainActor is a specific instance of a global actor tied to the main thread. @globalActor is a protocol for creating your own global actors. For example, you can create a @BackgroundActor for executing code on a background thread if required by the project architecture.

Can @MainActor be used without async/await?

Yes, synchronous functions with @MainActor also execute on the main thread. However, the main value of @MainActor is revealed with async/await, when an asynchronous function automatically resumes on the main thread without manual switching via DispatchQueue.main.

How to cancel a @MainActor task?

Task.cancel() works with @MainActor tasks the same way as with regular ones. A @MainActor task can check Task.isCancelled or throw a CancellationError. Upon cancellation, the main thread is not blocked — the task simply stops execution at the nearest suspension point.

What happens if @MainActor is called from a background thread?

The compiler guarantees safety: if you call a @MainActor function from a background context, the compiler will point out the error. For asynchronous calls, simply mark the calling code with await, and the executor will switch to the main thread. For synchronous calls, explicit switching via MainActor.run is required.

Summary

  • @MainActor — a global Swift actor that guarantees execution on the main thread.
  • Compiler checking eliminates an entire class of UI safety errors.
  • Full class annotation automatically places all its methods on the main thread.
  • MainActor.run — explicit switching for legacy code and synchronous contexts.
  • Unlike DispatchQueue.main, @MainActor does not create closures and uses executor switching.
  • Heavy computations should not be performed under @MainActor to avoid UI freezes.
  • Actor context inheritance simplifies chains of asynchronous calls and makes code consistent, predictable, and safe for the UI.

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also