top of page

01 · INTRODUCTION

Does @MainActor Mean the Work Executes on the Main Thread?

Yes.

If code is isolated to @MainActor, then when that actor-isolated code executes on an Apple platform such as iOS, it executes on the main thread.

This is the simple answer, and it is important that we do not make it sound more complicated than it needs to be.

For example:

CODE EXAMPLE

@MainActor
func updateTitle() {
    title = "Launch Ready"
}

The work inside updateTitle() executes on the main thread.

The complication begins only when the MainActor-isolated function is asynchronous and contains an await.

An asynchronous MainActor Task can execute on the main thread, reach an await, suspend, stop executing for a period of time, and later continue executing MainActor-isolated code on the main thread again.

🔑 The Answer

@MainActor means that MainActor-isolated work executes on the main thread.

If an asynchronous MainActor Task suspends at an await, that Task temporarily stops executing. During the suspension, the main thread is available for other work.

When the MainActor-isolated continuation later resumes, that work executes on the main thread again.

02 · TUTORIAL

Start With Synchronous MainActor Code

The simplest case contains no asynchronous work at all.

CODE EXAMPLE

@MainActor
func updateUI() {
    title = "Ready"
    isLoading = false
}

When this function executes, its work executes on the main thread.

Conceptually:

EXECUTION DIAGRAM

@MainActor updateUI()

        │
        ▼

    MAIN THREAD

title = "Ready"
isLoading = false

████████████████

        │
        ▼

      FINISH

This synchronous MainActor job executes until it finishes.

Other MainActor-isolated jobs do not execute simultaneously with it.

03 · TUTORIAL

🔑 Terminology — MainActor

🔑 Terminology — MainActor

MainActor is Swift's global actor associated with main-thread execution.

Code isolated to MainActor executes on the main thread.

MainActor also establishes an isolation domain so Swift can understand which state and operations belong to that main execution context.

This means that:

CODE EXAMPLE

@MainActor
final class LaunchModel {

    var launches: [Launch] = []
    var isLoading = false
}

declares that the isolated state and operations of LaunchModel belong to MainActor.

When MainActor-isolated code accesses that state, that work executes on the main thread.

04 · TUTORIAL

So Why Does This Become Confusing?

Because asynchronous functions can suspend.

Consider:

CODE EXAMPLE

@MainActor
func refresh() async throws {

    isLoading = true

    let launches = try await api.loadLaunches()

    self.launches = launches
    isLoading = false
}

The function is MainActor-isolated.

So the MainActor-isolated portions of this function execute on the main thread.

But the function also contains:

CODE EXAMPLE

await

which means the Task may suspend.

05 · TUTORIAL

What Happens Before the Await?

The function begins executing on the main thread.

EXECUTION DIAGRAM

@MainActor refresh()

        │
        ▼

    MAIN THREAD

isLoading = true

████████

        │
        ▼

call api.loadLaunches()

        │
        ▼

      await

Everything here that is executing as MainActor-isolated work is executing on the main thread.

There is no ambiguity about that.

06 · TUTORIAL

Then the Task May Suspend

Suppose api.loadLaunches() cannot immediately produce its result.

The Task may suspend at:

CODE EXAMPLE

let launches = try await api.loadLaunches()

At that moment, the MainActor Task has not finished.

But it is temporarily not executing.

EXECUTION DIAGRAM

MAINACTOR TASK

isLoading = true

████████
       │
       ▼
     await
       │
       ▼
    SUSPEND

This is the important distinction.

💡 Suspended Does Not Mean Running Somewhere Else

When the Task suspends, it is temporarily not executing its continuation.

It is not sitting there continuously executing on the main thread.

It is also not automatically running on some mysterious background thread.

It is suspended.

07 · TUTORIAL

The Main Thread Is Now Available for Other Work

This is one of the main reasons asynchronous code works so well in interactive applications.

While our refresh Task is suspended, the main thread can execute other eligible main-thread work.

EXECUTION DIAGRAM

MAIN THREAD

Refresh Task

████████
       │
       ▼
     await
    SUSPEND


        Handle Tap

        ████████


                Update Animation

                ████████


                        Process UI Event

                        ████████

The refresh Task still exists.

It still needs to finish eventually.

But it is no longer occupying the main thread while waiting for the asynchronous operation to allow it to continue.

08 · TUTORIAL

This Is Exactly What We Want for a Responsive UI

Imagine the network request takes half a second.

If the main thread were synchronously blocked for the entire duration, we could get something like:

EXECUTION DIAGRAM

MAIN THREAD

Start request

████████████████████████████████████████████

500ms later

response arrives

continue

That would be terrible for an interactive application.

At 60 frames per second, 500 milliseconds spans roughly thirty frame intervals.

Instead, suspension allows the asynchronous operation to remain unfinished while the main thread continues processing other important work.

EXECUTION DIAGRAM

TIME ─────────────────────────────────────────────▶

Refresh Task

████
   │
   ▼
SUSPEND
   ⋮
   ⋮
   ⋮
                                      ████
                                      resume


MAIN THREAD

    ██    ██    ██    ██    ██    ██    ██
    UI    UI    UI    UI    UI    UI    UI

This is a much better model for a modern UI application.

09 · TUTORIAL

What Happens When the Awaited Operation Completes?

Eventually the asynchronous operation allows the Task to continue.

The continuation still belongs to MainActor isolation.

So when that MainActor-isolated continuation executes, it executes on the main thread.

EXECUTION DIAGRAM

MAIN THREAD


refresh begins

████████
       │
       ▼
     await
    SUSPEND


        other main-thread work
        ███████████████████████


                                refresh resumes

                                ███████████

                                self.launches = launches
                                isLoading = false

So we can reduce the complete journey to three stages:

EXECUTION DIAGRAM

MAINACTOR WORK

Main thread
    │
    ▼
 EXECUTE

    │
    ▼
  await

    │
    ▼
 SUSPEND

    │
    │ main thread performs
    │ other work
    │
    ▼

continuation eligible

    │
    ▼

Main thread
    │
    ▼
 EXECUTE AGAIN

10 · TUTORIAL

MainActor Work → Main Thread

This is the rule worth committing to memory.

💡 MainActor Work → Main Thread

When MainActor-isolated work executes, it executes on the main thread.

If the Task suspends, it temporarily stops executing.

When its MainActor-isolated continuation resumes, that work executes on the main thread again.

11 · TUTORIAL

What About the Asynchronous Operation We Await?

Now consider the call:

CODE EXAMPLE

let launches = try await api.loadLaunches()

There are two things we should keep separate.

First, our refresh() function is MainActor-isolated.

Second, loadLaunches() may have its own isolation and implementation.

We should not assume that every piece of work involved in implementing an asynchronous API must continuously execute on the main thread merely because the caller belongs to MainActor.

The important model from the caller's perspective is:

EXECUTION DIAGRAM

@MainActor refresh()

        │
        ▼

MAIN THREAD

isLoading = true

        │
        ▼

call async operation

        │
        ▼

      await

        │
        ▼

     SUSPEND

        │
        │
        │ asynchronous operation
        │ progresses according to
        │ its own implementation
        │
        ▼

result becomes available

        │
        ▼

MainActor continuation

        │
        ▼

MAIN THREAD

self.launches = launches
isLoading = false

This is much clearer than trying to reduce every asynchronous operation to:

CODE EXAMPLE

main thread

or

background thread

Swift Concurrency gives us a richer execution model than that.

12 · TUTORIAL

An Async Function Can Therefore Have More Than One MainActor Job

Our original function:

CODE EXAMPLE

@MainActor
func refresh() async throws {

    isLoading = true

    let launches = try await api.loadLaunches()

    self.launches = launches

    isLoading = false
}

can be understood as separate synchronous periods of MainActor execution divided by a potential suspension point.

EXECUTION DIAGRAM

REFRESH TASK


MAINACTOR JOB 1

isLoading = true
begin load

████████
       │
       ▼
     await
       │
       ▼
    SUSPEND


       ⋮


MAINACTOR JOB 2

self.launches = launches
isLoading = false

                        ████████

The same asynchronous Task spans both execution periods.

But it does not continuously occupy the main thread between them.

13 · TUTORIAL

This Is Why @MainActor Async Code Can Be Perfectly Responsive

Seeing:

CODE EXAMPLE

@MainActor
func refresh() async throws

should therefore not make us think:

CODE EXAMPLE

This function might take
three seconds.

Therefore the main thread
must be blocked for three seconds.

That is not how we should measure the operation.

The useful question is:

How long does each synchronous MainActor region execute before the Task either suspends or finishes?

If it performs a small amount of synchronous state work and then suspends while waiting for a genuine asynchronous operation, the main thread can remain responsive throughout most of the Task's lifetime.

14 · TUTORIAL

But @MainActor Can Absolutely Block the UI

Now consider a completely different example:

CODE EXAMPLE

@MainActor
func processEverything() {

    for item in oneMillionItems {
        performExpensiveCalculation(item)
    }
}

This MainActor work executes on the main thread.

And there is no suspension point.

So the main-thread execution can look like:

EXECUTION DIAGRAM

MAIN THREAD

processEverything()

████████████████████████████████████████


Tap Handling

                                        ████


UI Update

                                            ████

If the computation takes 200 milliseconds, we can delay other main-thread work by roughly 200 milliseconds.

That is exactly the kind of behaviour that can make an application appear frozen or jerky.

15 · TUTORIAL

Adding async Does Not Automatically Fix It

Suppose we change the declaration:

CODE EXAMPLE

@MainActor
func processEverything() async {

    for item in oneMillionItems {
        performExpensiveCalculation(item)
    }
}

The function is now asynchronous.

But the loop itself is still synchronous.

There is no await inside that work.

There is no automatic suspension simply because the function declaration contains async.

EXECUTION DIAGRAM

@MainActor async function

        │
        ▼

heavy synchronous loop

████████████████████████████

        │
        ▼

no suspension occurred

So yes, that synchronous work can still monopolise the main thread.

16 · TUTORIAL

This Returns Us to Cooperative Code

This is why Swift Concurrency is described as cooperative.

Swift does not automatically take:

CODE EXAMPLE

for item in oneMillionItems {
    process(item)
}

and secretly transform it into:

CODE EXAMPLE

Process 100 items

yield

Run UI work

Process another 100

yield

Run another Task

If our synchronous MainActor job keeps executing, it keeps executing.

Our architecture must avoid placing large amounts of uninterrupted synchronous CPU work on the main execution domain when the UI needs that same execution resource.

17 · TUTORIAL

What About Task { }?

This creates another very common misunderstanding.

Imagine:

CODE EXAMPLE

@MainActor
func calculate() {

    Task {
        performHugeCalculation()
    }
}

A developer may see Task and think:

CODE EXAMPLE

I have moved the calculation
to a background thread.

That is not what Task { } means.

A Task created from MainActor-isolated code can inherit that MainActor isolation.

So this can still conceptually be:

EXECUTION DIAGRAM

@MainActor context
       │
       ▼
     Task
       │
       ▼
inherits MainActor isolation
       │
       ▼
synchronous Task work
       │
       ▼
MAIN THREAD

If that work performs an enormous synchronous calculation, it can still delay other MainActor work.

💡 Task Does Not Mean Background

Task { } creates asynchronous Task work.

It does not mean “create a background thread.”

A Task created inside MainActor-isolated code can inherit MainActor isolation.

18 · TUTORIAL

Why Is MainActor Useful Then?

Because UI-facing mutable state needs a coherent isolation rule.

Imagine:

CODE EXAMPLE

var launches: [Launch] = []
var isLoading = false
var errorMessage: String?
var selectedLaunch: Launch?

without a clear concurrency owner.

Multiple concurrently executing Tasks could potentially attempt to change that state from different execution contexts.

Instead we can write:

CODE EXAMPLE

@MainActor
final class LaunchModel {

    var launches: [Launch] = []
    var isLoading = false
    var errorMessage: String?
    var selectedLaunch: Launch?
}

Now the architecture says:

EXECUTION DIAGRAM

Who owns this UI-facing state?

            │
            ▼

         MainActor

That is much stronger than hoping every developer remembers to manually dispatch individual mutations onto the main queue.

19 · TUTORIAL

MainActor Is More Than DispatchQueue.main

Historically, we might write:

CODE EXAMPLE

DispatchQueue.main.async {
    self.launches = launches
}

This tells GCD where to submit one particular closure.

With:

CODE EXAMPLE

@MainActor
final class LaunchModel {
    var launches: [Launch] = []
}

we are expressing an isolation requirement on the state itself.

That changes the question from:

CODE EXAMPLE

Where should I dispatch
this closure?

to:

CODE EXAMPLE

Who owns this mutable state?

Swift can understand and enforce the second question much more deeply.

20 · TUTORIAL

What About MainActor.run?

Sometimes code outside MainActor needs to perform a small piece of MainActor-isolated work.

Swift provides:

CODE EXAMPLE

await MainActor.run {
    model.isLoading = false
}

This explicitly asks for the closure to execute within MainActor isolation.

That work therefore executes on the main thread.

But if the entire type fundamentally owns UI-facing state, it can often be clearer to declare the ownership directly:

CODE EXAMPLE

@MainActor
final class LaunchModel {
    var isLoading = false
}

Then the isolation rule belongs to the architecture rather than being reconstructed manually at every mutation site.

21 · TUTORIAL

Does MainActor Mean Only One Task Can Exist?

No.

Many asynchronous Tasks can exist concurrently while all needing periods of MainActor execution.

EXECUTION DIAGRAM

Task A ────────────────┐
                       │
Task B ────────────────┤
                       ├────▶ MainActor
Task C ────────────────┤
                       │
Task D ────────────────┘

The Tasks can have overlapping lifetimes.

Their MainActor-isolated jobs execute serially.

EXECUTION DIAGRAM

Task A — MainActor Job

████████


        Task B — MainActor Job

        ████████


                Task C — MainActor Job

                ████████

And if one of those Tasks suspends, another eligible MainActor job can execute before the original Task resumes.

22 · TUTORIAL

MainActor State Can Therefore Change Across Await

Consider:

CODE EXAMPLE

@MainActor
func refresh() async {

    let currentSelection = selectedLaunch

    await loadSomething()

    // selectedLaunch may have changed
}

While this Task was suspended, another MainActor job may have executed.

That other job could legitimately change selectedLaunch.

MainActor isolation ensures that the two jobs did not execute the isolated state access simultaneously.

It does not mean MainActor state is frozen for the entire lifetime of every asynchronous Task.

23 · TUTORIAL

The Complete Mental Model

We can now answer the entire question with one diagram.

EXECUTION DIAGRAM

@MainActor async Task


MAIN THREAD

MainActor Job 1
████████████

      │
      ▼
    await

      │
      ▼

   SUSPEND

      │
      │
      │  main thread executes
      │  other eligible work
      │
      ▼

   result ready

      │
      ▼

MainActor continuation
becomes eligible

      │
      ▼


MAIN THREAD

MainActor Job 2
                        ████████████

So:

CODE EXAMPLE

MainActor work
      =
executes on main thread


Task suspended
      =
not currently executing


MainActor continuation
      =
executes on main thread again

That is the model to remember.

24 · TUTORIAL

Why This Matters to an iOS Developer

There are two very different mistakes we can make if we do not understand this distinction.

The first is being unnecessarily frightened of code such as:

CODE EXAMPLE

@MainActor
func refresh() async throws {
    launches = try await api.loadLaunches()
}

because we assume that a two-second asynchronous operation must continuously block the main thread for two seconds.

It does not need to.

The Task can suspend.

The second mistake is believing that:

CODE EXAMPLE

async

or:

CODE EXAMPLE

Task { }

magically makes CPU-heavy synchronous work leave MainActor.

It does not.

The questions we should ask are:

CODE EXAMPLE

What isolation does this work belong to?

How much synchronous work
is performed before suspension?

Where can this Task suspend?

What other work needs
the main thread to remain responsive?

Those questions give us a much more reliable understanding of modern iOS concurrency.

25 · TUTORIAL

What to Remember

💡 What to Remember

Yes. MainActor-isolated work executes on the main thread.

MainActor is Swift's global actor associated with main-thread execution.

Synchronous MainActor-isolated work executes on the main thread until that synchronous job finishes.

An asynchronous MainActor Task can reach an await and suspend.

While the Task is suspended, that Task is temporarily not executing.

The main thread is therefore available to execute other eligible work.

When the suspended Task becomes ready to continue, its MainActor-isolated continuation executes on the main thread again.

await does not mean “move to a background thread.”

async does not automatically make CPU-heavy synchronous work cooperative.

Task { } does not mean “background thread” and can inherit MainActor isolation from its surrounding context.

Long-running synchronous MainActor work can still delay the UI.

MainActor state can also legitimately change across an await because other MainActor jobs may execute while the original Task is suspended.

The simplest mental model is:

EXECUTION DIAGRAM

MainActor work
      ↓
MAIN THREAD
      ↓
    await
      ↓
Task may suspend
      ↓
main thread performs other work
      ↓
continuation becomes eligible
      ↓
MAIN THREAD
      ↓
MainActor work continues

26 · TUTORIAL

Your Next Move

We now have a clear answer to one of the most commonly confused Swift Concurrency questions.

@MainActor means MainActor-isolated work executes on the main thread.

But a Task does not need to continuously occupy that thread throughout its entire asynchronous lifetime.

It can execute.

It can suspend.

The main thread can perform other work.

And the MainActor continuation can later execute on the main thread again.

That brings us naturally to another question developers ask constantly:

CODE EXAMPLE

Task {
    await loadSomething()
}

What actually happens when we write Task { }?

Does Swift create a thread?

Which actor isolation does the Task inherit?

What executor receives its work?

What happens when the Task suspends?

And why does Task { } behave differently from Task.detached { }?

That is the next article:

What Actually Happens When You Write Task { }?

bottom of page