@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 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.
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.
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.
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.
@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.
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
}
}
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.
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.
@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.
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.
// Old approach
DispatchQueue.main.async {
self.updateUI()
}
// New approach with @MainActor
@MainActor
func updateUI() {
// runs on the main thread
self.label.text = "Updated"
}
| Criterion | @MainActor | DispatchQueue.main |
|---|---|---|
| Check | compiler | runtime |
| Syntax | annotation (declarative) | call (imperative) |
| Overhead | low (executor switching) | medium (closure + queue) |
| Testability | high (MainActor.shared can be replaced) | low (hard to mock) |
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.
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.
@MainActor
final class UserListViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
func fetchUsers() async {
isLoading = true
users = await api.getUsers()
isLoading = false
}
}
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.
await MainActor.run {
self.tableView.reloadData()
}
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.
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.
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.
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.
@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.
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.
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.
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.
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
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.
@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.
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.
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.
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
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.
Read also