01 · INTRODUCTION
What Is MainActor in Swift?
The short answer
MainActor is Swift’s global actor for main-facing state and work. When a declaration is isolated to MainActor, Swift requires access to it to happen through that shared isolation domain.
CODE EXAMPLE
@MainActor
final class LaunchListFeature: ObservableObject {
@Published var launches: [Launch] = []
@Published var isLoading = false
}
This does more than suggest that the class is related to the user interface. It creates a concurrency rule: code must enter MainActor before reading or changing its isolated state.
The central idea: MainActor gives all main-actor-isolated code one shared place where access is coordinated.
02 · TUTORIAL
MainActor Is a Global Actor
A normal actor has its own isolated state. A global actor provides one globally shared isolation domain that many declarations can join.
CODE EXAMPLE
@MainActor
final class LaunchListFeature: ObservableObject {
// Main-actor-isolated state
}
@MainActor
func presentLaunch(_ launch: Launch) {
// Also isolated to the same MainActor
}
The class and the function do not each receive a separate actor. They belong to the same MainActor domain.
The following is a conceptual diagram:
EXECUTION DIAGRAM
LaunchListFeature ─┐
presentLaunch() ─┼─▶ MainActor isolation ─▶ MainActor executor ─▶ main thread
UI closure ─┘
The layers are related, but they are not interchangeable. MainActor describes isolation. Its executor arranges eligible jobs. On Apple platforms, that executor is integrated with the application’s main thread and run loop.
03 · TUTORIAL
Why Main-Facing State Needs an Isolation Domain
Imagine a launch screen that can be refreshed, filtered and updated after network requests. Without an ownership rule, different concurrent tasks could try to change the screen’s state at the same time.
CODE EXAMPLE
final class LaunchListFeature: ObservableObject {
@Published var launches: [Launch] = []
@Published var isLoading = false
@Published var errorMessage: String?
}
Which task may set isLoading? Which one may replace launches? What prevents two updates from overlapping?
Adding @MainActor answers those questions at the type boundary.
CODE EXAMPLE
@MainActor
final class LaunchListFeature: ObservableObject {
@Published private(set) var launches: [Launch] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
}
Now the mutable state has an explicit owner. The compiler can diagnose code that attempts to cross that boundary incorrectly.
04 · TUTORIAL
Isolating an Entire Type
For a feature model whose mutable properties directly describe what the user sees, isolating the whole type is often the clearest design.
CODE EXAMPLE
@MainActor
final class LaunchListFeature: ObservableObject {
@Published private(set) var launches: [Launch] = []
@Published private(set) var isLoading = false
func beginLoading() {
isLoading = true
}
func replaceLaunches(with launches: [Launch]) {
self.launches = launches
isLoading = false
}
}
The stored properties, initialiser and instance methods are main-actor isolated unless a declaration says otherwise. Callers do not need to remember a separate threading convention for every member.
Actor isolation means that access to mutable state is restricted through an actor’s concurrency boundary. It is an ownership and correctness rule, not a performance optimisation.
05 · TUTORIAL
Isolating One Declaration
You can also apply @MainActor to a single function or property when the whole type does not belong there.
CODE EXAMPLE
final class LaunchCoordinator {
let api: LaunchAPI
@MainActor
func show(_ launches: [Launch], in feature: LaunchListFeature) {
feature.replaceLaunches(with: launches)
}
}
The annotation says that this operation must execute while isolated to MainActor. It does not silently move every operation performed by LaunchCoordinator onto the main actor.
Closures can carry the same requirement:
CODE EXAMPLE
let displayUpdate: @MainActor ([Launch]) -> Void = { launches in
feature.replaceLaunches(with: launches)
}
This is useful when the isolation requirement is part of the operation’s meaning.
06 · TUTORIAL
Calling MainActor from the Outside
Code already running on MainActor can call another main-actor-isolated synchronous method directly.
CODE EXAMPLE
@MainActor
func selectFirstLaunch(in feature: LaunchListFeature) {
feature.selectFirstLaunch()
}
Code outside that isolation domain must cross the boundary asynchronously.
CODE EXAMPLE
func apply(
_ launches: [Launch],
to feature: LaunchListFeature
) async {
await feature.replaceLaunches(with: launches)
}
The await marks a potential suspension point. The current task may need to suspend until the main actor’s executor can run the call. It does not promise that a suspension will occur every time.
07 · TUTORIAL
Using MainActor.run for a Scoped Update
Sometimes an asynchronous operation mostly runs outside MainActor but needs a small, clearly bounded main-actor update.
CODE EXAMPLE
func refresh(
api: LaunchAPI,
feature: LaunchListFeature
) async throws {
let launches = try await api.fetchUpcomingLaunches()
await MainActor.run {
feature.replaceLaunches(with: launches)
}
}
MainActor.run executes its closure in main-actor isolation. It is useful for a scoped handoff, but it should not replace a well-designed isolation boundary. If a type’s state consistently belongs to MainActor, annotate the type and let its API express that fact.
08 · TUTORIAL
A Task Can Inherit MainActor Isolation
An unstructured task created inside main-actor-isolated code inherits that actor context.
CODE EXAMPLE
@MainActor
func startRefresh(feature: LaunchListFeature, api: LaunchAPI) {
Task {
feature.beginLoading()
let launches = try await api.fetchUpcomingLaunches()
feature.replaceLaunches(with: launches)
}
}
The task begins with permission to access feature. After the network operation finishes, it can continue with main-actor-isolated state.
This does not mean that Task { } is a general-purpose way to move expensive work away from the main actor. In this example, the task inherits MainActor.
Creating a task changes the unit of asynchronous work. It does not automatically choose a background thread or discard the surrounding actor isolation.
09 · TUTORIAL
Suspension Does Not Hold MainActor
Consider the refresh method as one task moving through several stages:
CODE EXAMPLE
@MainActor
func refresh(using api: LaunchAPI) async {
isLoading = true
do {
let result = try await api.fetchUpcomingLaunches()
launches = result
errorMessage = nil
} catch {
errorMessage = "Could not load launches."
}
isLoading = false
}
When the task reaches await, it may suspend. While it is suspended, it does not reserve or block MainActor. Other eligible main-actor jobs can run, such as handling a tap or updating another feature.
The following timeline is conceptual:
EXECUTION DIAGRAM
MainActor: set loading ── handle tap ── update clock ── apply launches
Task: run ───────── suspend while waiting ─────── resume
This is why awaiting an asynchronous network operation can keep an application responsive. The task waits cooperatively instead of blocking the main thread.
10 · TUTORIAL
State Can Change While a Task Is Suspended
Serial isolation prevents two main-actor jobs from executing at the same instant. It does not make an entire asynchronous function one indivisible operation.
CODE EXAMPLE
@MainActor
func refresh(using api: LaunchAPI) async {
let requestedAgency = selectedAgency
let result = try? await api.fetchLaunches(for: requestedAgency)
guard selectedAgency == requestedAgency else {
return
}
launches = result ?? []
}
Another main-actor job may change selectedAgency while this task is suspended. When the task resumes, it should verify any assumptions that must still be true.
This behaviour is called actor reentrancy. It deserves its own article; for now, remember that an await can divide one method into separate periods of actor-isolated execution.
11 · TUTORIAL
MainActor Is Not Simply Another Name for the Main Thread
In an Apple application, main-actor execution and the main thread are closely connected. That practical relationship is why MainActor is appropriate for UI-facing state.
But the terms describe different layers:
• MainActor is an isolation domain expressed in Swift’s type and concurrency system.
• The main actor executor arranges eligible main-actor jobs one at a time.
• The main thread is the system thread used for main execution and event processing.
Thinking only in terms of “dispatch this to the main thread” loses the most important part: the compiler can understand MainActor as an ownership boundary and check crossings into it.
12 · TUTORIAL
MainActor Does Not Make Expensive Work Cheap
Serial isolation protects access, but synchronous code still occupies the executor until it returns or reaches a suspension point.
CODE EXAMPLE
@MainActor
func calculateVisibility(for launches: [Launch]) -> [Launch] {
// A very large synchronous calculation here can still
// delay input, animation and other main-actor work.
launches.filter(isVisible)
}
If this calculation takes a long time, annotating it with @MainActor does not make it asynchronous. Wrapping the same synchronous calculation in a Task created from MainActor does not necessarily help either, because that task inherits the actor context.
Use MainActor for state and operations that require main isolation. Design substantial model processing as a separate operation with an appropriate concurrency boundary.
13 · TUTORIAL
An Architectural Boundary for a Feature
MainActor gives a feature a useful outer boundary: the state presented to the user belongs to one isolation domain, while services perform asynchronous work through their own APIs.
CODE EXAMPLE
struct LaunchAPI {
func fetchUpcomingLaunches() async throws -> [Launch] {
// Request data, decode it and return model values.
}
}
@MainActor
final class LaunchListFeature: ObservableObject {
@Published private(set) var launches: [Launch] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let api: LaunchAPI
init(api: LaunchAPI) {
self.api = api
}
func refresh() async {
isLoading = true
do {
launches = try await api.fetchUpcomingLaunches()
errorMessage = nil
} catch {
errorMessage = "Could not load launches."
}
isLoading = false
}
}
The feature does not manually dispatch every property change. Its type declares where that state belongs. The API does not own visible state; it returns values that the feature can apply after the asynchronous call completes.
This suggests a broader way to describe an application:
• Features own user-facing state.
• Tasks describe asynchronous units of work.
• Actors define isolation boundaries for mutable state.
• Executors schedule the jobs produced as tasks make progress.
MainActor is the boundary that connects this concurrency architecture to the application’s main-facing state.
14 · TUTORIAL
The Complete Mental Model
When a task calls a main-actor-isolated operation, reason through these layers:
EXECUTION DIAGRAM
Task reaches MainActor call
│
▼
Is the task already MainActor-isolated?
│
├─ yes ─▶ call can proceed within that isolation
│
└─ no ─▶ await a crossing to MainActor
│
▼
eligible MainActor job
│
▼
MainActor executor
│
▼
main execution
If the operation reaches an await and suspends, it stops occupying MainActor. Other eligible jobs can run. When the awaited work completes, the task becomes eligible to resume in the isolation it requires.
The main actor provides serial isolation, not a permanent reservation for one task, not guaranteed ordering between every submitted job and not permission to perform unlimited synchronous work.
15 · TUTORIAL
What to Remember
• MainActor is Swift’s global actor for main-facing state and work.
• Annotating a type gives its isolated members one shared concurrency boundary.
• Calls from outside that boundary may require await.
• A task created in main-actor-isolated code inherits that actor context.
• A suspended task does not block or reserve MainActor.
• Other main-actor jobs may run across an await, so assumptions may need to be checked again.
• MainActor, its executor and the main thread are related but distinct concepts.
• Main-actor isolation does not make expensive synchronous work inexpensive.
16 · TUTORIAL
Frequently Asked Questions
Does @MainActor mean this code always runs immediately?
No. It means the code requires main-actor isolation. A task crossing into that isolation may suspend until its job becomes eligible to run.
Is MainActor a thread?
No. It is a global actor and isolation domain. On Apple platforms, its executor is integrated with the main thread, but actor isolation and operating-system threads are different layers.
Should every async function be marked @MainActor?
No. Mark declarations whose state or behaviour belongs to the main isolation domain. Applying it everywhere can move unrelated synchronous work into a serial main-facing bottleneck and make the architecture less clear.
Does Task { } move work off MainActor?
Not when it is created in main-actor-isolated code. That task inherits the actor context. A task is not a request for a new background thread.
Why can another action happen while a @MainActor method is waiting?
Because an asynchronous method can suspend at await. Suspension releases the actor for other eligible jobs. The original task can resume later.
Should I use MainActor.run or annotate my feature type?
Use a type annotation when the type’s state consistently belongs to MainActor. Use MainActor.run for a small, scoped update from code whose overall isolation belongs elsewhere.
17 · TUTORIAL
Continue Learning
MainActor is one especially important actor, but the underlying idea is broader. The next article, What Is an Actor in Swift?, will explain how an actor owns mutable state, how calls cross its isolation boundary and why serial access prevents data races without turning an actor into a thread.
18 · TUTORIAL
Download the Xcode Playground
Use the accompanying playground to trace a launch refresh as it enters MainActor, suspends during an asynchronous request and resumes to update visible state. Add a second main-actor task while the first is suspended to observe that the original task does not own the actor while it waits.
