Why Task under MainActor can hurts performance

Transitioning to Swift 6 and the new concurrency model forces you to gradually adapt old code. Some functions become async, they start updating the UI, and legacy code starts to grow with Task insertions. In many of these cases you need to update the UI, and a simple solution appears: mark the whole task as @MainActor. The errors disappear, the UI updates, and the job feels done.

However, this solution can reduce the app’s performance. Let’s look at an example and see why.

Imagine you had a button that requested some data from a couple of sources and then displayed it in the UI.


let workers = dataProvider.getAllWorkers()
var result: [Schedule] = []
for worker in workers {
    let schedule = dataProvider.getSchedule(for: worker)
    result.append(schedule)
}
display(schedule: result)

At some point these were very simple operations from a small local file, but later they became network calls, so obviously they must be async. You will need to wrap them in a Task, and to make sure nothing crashes there, you mark the whole block with @MainActor


Task { @MainActor in
    setActivityIndicator(visible: true)
    let workers = await dataProvider.getAllWorkers()
    var result: [Schedule] = []
    for worker in workers {
        let schedule = await  dataProvider.getSchedule(for: worker)
        result.append(schedule)
    }
    setActivityIndicator(visible: false)
    display(schedule: result)
}

This code really works and works correctly. The data provider calls run in the background and do not block the main thread. But inside this construct there is a mechanism that starts overloading the main thread.

What happens under the hood

Each await creates a suspension point. After the async function finishes, Swift must return execution to the same context where it started, and in this case it means MainActor.

In the example above, four await calls produce four returns to the main thread.

In simple terms, every time MainActor gets such a return, it puts the continuation into its queue, schedules execution on the main thread, and when the main thread is free, it continues the next step.

This results in 5–10–15 extra returns to MainActor just to load the schedule of the next worker. The Swift compiler cannot merge these hops into one because the actor model forbids such optimization.

When many parts of the app work like this, and each of them has several await calls on MainActor, the total load on the main thread grows. On hot paths (like app startup), this can show up as UI stutters, delayed animations, and reduced responsiveness.

🤔

Does the example look too artificial?

Actually no. In hybrid code that is only starting to move toward the new concurrency model, this pattern is very common. Also there is a strong temptation to ask an LLM to fix such issues automatically, and without careful prompting it will often put @MainActor everywhere to avoid crashes. Be careful!

How to write it correctly

Async work must be done outside MainActor. UI updates should happen in one short block.

A correct approach is to use a detached task and not be lazy about adding MainActor.run:


Task.detached {
    await MainActor.run { setActivityIndicator(visible: true) }
    let workers = await dataProvider.getAllWorkers()
    var result: [Schedule] = []
    for worker in workers {
        let schedule = await  dataProvider.getSchedule(for: worker)
        result.append(schedule)
    }
    await MainActor.run {
        setActivityIndicator(visible: false)
        display(schedule: result)
    }
}

If you use agents for refactoring, try adding this paragraph to your rules:

Always analyze async/await code from the perspective of MainActor load: do not mark the whole Task as @MainActor if there is heavy work inside. Remember that each await in a MainActor context creates a hop to the main thread. Do the heavy part in the background (Task or structured concurrency), and move UI updates into short blocks via MainActor.run. Use Task.detached only consciously — it breaks structured concurrency. The main goal is to minimize hops back to the main thread and maximize UI responsiveness.

Summary

Marking the whole Task as MainActor is a simple but often incorrect solution when architecture is not well-defined. With several await calls inside, it triggers multiple returns to the main thread, reduces its throughput, and causes UI lag.

The main thread should update only the UI.
Async work should run outside MainActor.
One short UI update at the end is the optimal strategy that scales and keeps the app responsive.

Share
Send
27 d