Member-only story
Async/Await and MainActor Strategies
Deciding where and how to update the main thread.

Pretty much every article that discusses Swift async/await will throw in a comment regarding @MainActor
and how it can be used to ensure that any updates to the user interface will occur on the main thread.
That’s all well and good… but just where should the @MainActor
attribute go? On the class? Only on the asynchronous function itself? Inside a nested Task block? On separate functions dedicated soley to updating the thread? Perhaps use something like await MainActor.run
instead? Or should we just punt, use DispatchQueue.main.async
, and forget the whole thing?
Lots of people have opinions on the matter, and this article is no different in that regard.
What is different, however, is that in this article we’re going to look at some of the actual SIL (Swift Intermediate Language) code generated by those mechanisms to examine the relative efficiency and code size of each.
And we’ll also explore a few of the other ramifications involved.
Ready? Coffee in hand? Let’s get started.
The View Model
The following is an extremely basic view model with two publishers, an initializer, a load function, and a “process” function that acts as a control so we can see the differences between a normal function and one marked as async.
class ContentViewModel: ObservableObject {
@Published var accounts: [Account]
@Published var message: String?
let loader: AccountLoader
init(loader: AccountLoader) {
self.accounts = []
self.loader = loader
}
func load() async {
do {
accounts = try await loader.load()
message = nil
} catch {
message = "Unable to load"
}
}
func process(_ accounts: [Account]) {
self.accounts = accounts
}
}
The AccountLoader
service simply contains a func load() async throws -> [Account]
function we can call to get our data.
Swift Intermediate Language
So let’s take a brief look at the SIL code generated for the view model class definition.